From f318fcbceac5ed13071bfdc9eda559f84800bf9f Mon Sep 17 00:00:00 2001 From: Diffora Date: Tue, 7 Jul 2026 08:20:49 +0200 Subject: [PATCH 1/2] feat(bss-ledger): Billing Ledger gear, SDK, coord lease lib, and design docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The double-entry posting foundation and its handler slices (invoice posting, audit/immutability, payments & allocation, ASC 606 recognition, adjustments/notes/refunds, FX/multi-currency, reconciliation & close), plus the `bss-ledger-sdk` contract crate, the shared `coord` lease library, the full design set (PRD, slices 01–07, ADR-0001), SeaORM entities + migrations, and unit/SQLite/Postgres/e2e test suites. Wired into cf-gears-example-server as an opt-in cargo feature. Signed-off-by: Diffora --- .cf-studio/config/artifacts.toml | 8 + Cargo.lock | 101 +- Cargo.toml | 9 + apps/cf-gears-example-server/Cargo.toml | 4 + .../src/registered_gears.rs | 3 + config/e2e-features.txt | 2 +- config/e2e-local.yaml | 13 + deny.toml | 3 + ...bss-ledger-adr-book-ownership-predicate.md | 139 + .../docs/PRD.md | 0 .../docs/design/01-repository-foundation.md | 549 +++ .../ledger/docs/design/01a-invoice-posting.md | 568 +++ .../02-audit-immutability-observability.md | 821 ++++ .../docs/design/03-payments-allocation.md | 809 ++++ .../docs/design/04-asc606-recognition.md | 636 +++ .../design/05-adjustments-notes-refunds.md | 761 +++ .../ledger/docs/design/06-fx-multicurrency.md | 528 +++ .../docs/design/07-reconciliation-export.md | 826 ++++ gears/bss/ledger/docs/design/README.md | 60 + gears/bss/ledger/ledger-sdk/Cargo.toml | 24 + gears/bss/ledger/ledger-sdk/src/api.rs | 445 ++ .../ledger-sdk/src/bill_run_finished.rs | 47 + gears/bss/ledger/ledger-sdk/src/close.rs | 12 + gears/bss/ledger/ledger-sdk/src/enums.rs | 221 + gears/bss/ledger/ledger-sdk/src/error.rs | 155 + .../bss/ledger/ledger-sdk/src/error_tests.rs | 335 ++ .../ledger-sdk/src/issued_invoice_manifest.rs | 68 + gears/bss/ledger/ledger-sdk/src/lib.rs | 48 + gears/bss/ledger/ledger-sdk/src/posting.rs | 745 +++ .../bss/ledger/ledger-sdk/src/provisioning.rs | 120 + .../ledger-sdk/src/psp_settlement_feed.rs | 58 + .../ledger/ledger-sdk/src/rate_provider.rs | 116 + gears/bss/ledger/ledger/Cargo.toml | 121 + ...g_ledger_credit_note_posted.v1.schema.json | 51 + ...ng_ledger_debit_note_posted.v1.schema.json | 51 + ...ing_ledger_dispute_recorded.v1.schema.json | 47 + ...billing_ledger_entry_posted.v1.schema.json | 85 + ...lling_ledger_entry_reversed.v1.schema.json | 31 + ...er_fx_revaluation_completed.v1.schema.json | 52 + ...ger_fx_revaluation_reversed.v1.schema.json | 57 + ...ling_ledger_invariant_alarm.v1.schema.json | 101 + ...er_manual_adjustment_posted.v1.schema.json | 46 + ...illing_ledger_period_closed.v1.schema.json | 36 + ...er_reconciliation_completed.v1.schema.json | 43 + ...ling_ledger_refund_recorded.v1.schema.json | 70 + ...evenue_recognition_reversed.v1.schema.json | 41 + ...g_ledger_revenue_recognized.v1.schema.json | 41 + ...ing_ledger_schedule_changed.v1.schema.json | 46 + ..._ledger_settlement_returned.v1.schema.json | 33 + gears/bss/ledger/ledger/src/api.rs | 7 + .../bss/ledger/ledger/src/api/local_client.rs | 1294 ++++++ .../ledger/src/api/local_client_tests.rs | 1612 +++++++ gears/bss/ledger/ledger/src/api/rest.rs | 28 + .../ledger/ledger/src/api/rest/adjustments.rs | 854 ++++ .../ledger/ledger/src/api/rest/approvals.rs | 800 ++++ gears/bss/ledger/ledger/src/api/rest/audit.rs | 982 ++++ .../ledger/ledger/src/api/rest/audit_tests.rs | 363 ++ .../ledger/src/api/rest/auth_context.rs | 35 + .../ledger/src/api/rest/canonical_json.rs | 78 + .../bss/ledger/ledger/src/api/rest/closure.rs | 217 + .../bss/ledger/ledger/src/api/rest/control.rs | 341 ++ .../bss/ledger/ledger/src/api/rest/credit.rs | 190 + .../ledger/ledger/src/api/rest/disputes.rs | 411 ++ gears/bss/ledger/ledger/src/api/rest/dto.rs | 3497 ++++++++++++++ .../ledger/ledger/src/api/rest/dto_tests.rs | 1099 +++++ gears/bss/ledger/ledger/src/api/rest/error.rs | 245 + .../ledger/ledger/src/api/rest/error_tests.rs | 109 + .../ledger/ledger/src/api/rest/exceptions.rs | 272 ++ gears/bss/ledger/ledger/src/api/rest/fx.rs | 369 ++ .../src/api/rest/fx_revaluation_mode.rs | 232 + .../ledger/src/api/rest/journal_entries.rs | 1087 +++++ .../src/api/rest/journal_entries_tests.rs | 54 + .../bss/ledger/ledger/src/api/rest/payers.rs | 227 + .../ledger/ledger/src/api/rest/payments.rs | 503 ++ .../ledger/src/api/rest/posting_policy.rs | 240 + .../ledger/src/api/rest/provisioning.rs | 193 + .../ledger/ledger/src/api/rest/recognition.rs | 825 ++++ .../ledger/src/api/rest/reconciliation.rs | 272 ++ .../bss/ledger/ledger/src/api/rest/refunds.rs | 484 ++ gears/bss/ledger/ledger/src/authz.rs | 338 ++ gears/bss/ledger/ledger/src/authz_tests.rs | 373 ++ gears/bss/ledger/ledger/src/config.rs | 511 ++ gears/bss/ledger/ledger/src/config_tests.rs | 260 ++ gears/bss/ledger/ledger/src/domain.rs | 23 + .../ledger/ledger/src/domain/adjustment.rs | 73 + .../src/domain/adjustment/credit_note.rs | 490 ++ .../domain/adjustment/credit_note_tests.rs | 352 ++ .../src/domain/adjustment/debit_note.rs | 395 ++ .../src/domain/adjustment/debit_note_tests.rs | 358 ++ .../ledger/src/domain/adjustment/manual.rs | 362 ++ .../src/domain/adjustment/manual_tests.rs | 198 + .../ledger/src/domain/adjustment/refund.rs | 579 +++ .../src/domain/adjustment/refund_tests.rs | 472 ++ .../ledger/src/domain/adjustment/splitter.rs | 473 ++ .../src/domain/adjustment/splitter_tests.rs | 288 ++ .../bss/ledger/ledger/src/domain/allocate.rs | 119 + .../bss/ledger/ledger/src/domain/approval.rs | 139 + .../ledger/src/domain/approval/intent.rs | 1077 +++++ .../src/domain/approval/intent_tests.rs | 521 +++ .../ledger/src/domain/approval/policy.rs | 215 + .../src/domain/approval/policy_tests.rs | 260 ++ .../ledger/src/domain/approval_tests.rs | 57 + .../ledger/ledger/src/domain/audit_chain.rs | 125 + .../ledger/src/domain/audit_chain_tests.rs | 127 + .../bss/ledger/ledger/src/domain/canonical.rs | 67 + gears/bss/ledger/ledger/src/domain/chain.rs | 106 + .../ledger/ledger/src/domain/chain_tests.rs | 174 + gears/bss/ledger/ledger/src/domain/error.rs | 191 + .../bss/ledger/ledger/src/domain/exception.rs | 164 + .../ledger/src/domain/exception_tests.rs | 80 + gears/bss/ledger/ledger/src/domain/fx.rs | 9 + .../ledger/ledger/src/domain/fx/realized.rs | 198 + .../ledger/src/domain/fx/realized_tests.rs | 215 + .../ledger/src/domain/fx/revaluation.rs | 239 + .../ledger/src/domain/fx/revaluation_mode.rs | 86 + .../src/domain/fx/revaluation_mode_tests.rs | 42 + .../ledger/src/domain/fx/revaluation_tests.rs | 266 ++ .../ledger/ledger/src/domain/fx/translate.rs | 105 + .../ledger/src/domain/fx/translate_tests.rs | 100 + gears/bss/ledger/ledger/src/domain/invoice.rs | 16 + .../ledger/ledger/src/domain/invoice/aging.rs | 115 + .../ledger/src/domain/invoice/aging_tests.rs | 170 + .../ledger/src/domain/invoice/builder.rs | 485 ++ .../src/domain/invoice/builder_tests.rs | 464 ++ .../ledger/src/domain/invoice/mapping.rs | 57 + .../src/domain/invoice/mapping_tests.rs | 63 + .../ledger/src/domain/invoice/policy.rs | 162 + .../ledger/src/domain/invoice/policy_tests.rs | 77 + .../ledger/src/domain/invoice/reversal.rs | 213 + .../src/domain/invoice/reversal_tests.rs | 300 ++ gears/bss/ledger/ledger/src/domain/model.rs | 243 + gears/bss/ledger/ledger/src/domain/money.rs | 107 + .../ledger/ledger/src/domain/money_math.rs | 77 + gears/bss/ledger/ledger/src/domain/payment.rs | 38 + .../ledger/src/domain/payment/allocation.rs | 260 ++ .../src/domain/payment/allocation_tests.rs | 209 + .../ledger/src/domain/payment/chargeback.rs | 599 +++ .../src/domain/payment/chargeback_tests.rs | 451 ++ .../ledger/src/domain/payment/credit.rs | 503 ++ .../ledger/src/domain/payment/credit_tests.rs | 384 ++ .../ledger/src/domain/payment/precedence.rs | 208 + .../src/domain/payment/precedence_tests.rs | 326 ++ .../ledger/src/domain/payment/settlement.rs | 171 + .../src/domain/payment/settlement_return.rs | 194 + .../domain/payment/settlement_return_tests.rs | 176 + .../src/domain/payment/settlement_tests.rs | 122 + gears/bss/ledger/ledger/src/domain/period.rs | 113 + .../ledger/ledger/src/domain/period_tests.rs | 109 + gears/bss/ledger/ledger/src/domain/ports.rs | 5 + .../ledger/ledger/src/domain/ports/metrics.rs | 484 ++ .../src/domain/ports/obligation_state.rs | 150 + gears/bss/ledger/ledger/src/domain/posting.rs | 181 + .../ledger/ledger/src/domain/posting_tests.rs | 236 + .../ledger/ledger/src/domain/provisioning.rs | 4 + .../ledger/src/domain/provisioning/plan.rs | 131 + .../ledger/ledger/src/domain/recognition.rs | 39 + .../ledger/src/domain/recognition/builder.rs | 342 ++ .../src/domain/recognition/builder_tests.rs | 264 ++ .../ledger/src/domain/recognition/change.rs | 99 + .../src/domain/recognition/change_tests.rs | 61 + .../ledger/src/domain/recognition/input.rs | 117 + .../src/domain/recognition/input_tests.rs | 69 + .../ledger/src/domain/recognition/ports.rs | 206 + .../src/domain/recognition/ports_tests.rs | 137 + gears/bss/ledger/ledger/src/domain/scale.rs | 38 + gears/bss/ledger/ledger/src/domain/status.rs | 103 + gears/bss/ledger/ledger/src/gts.rs | 3 + .../bss/ledger/ledger/src/gts/permissions.rs | 262 ++ .../ledger/src/gts/permissions_tests.rs | 110 + gears/bss/ledger/ledger/src/infra.rs | 28 + .../bss/ledger/ledger/src/infra/adjustment.rs | 48 + .../infra/adjustment/credit_note_service.rs | 1187 +++++ .../infra/adjustment/debit_note_service.rs | 793 ++++ .../adjustment/manual_adjustment_service.rs | 607 +++ .../src/infra/adjustment/refund_service.rs | 4130 +++++++++++++++++ .../infra/adjustment/refund_service_tests.rs | 198 + .../bss/ledger/ledger/src/infra/annotation.rs | 477 ++ .../ledger/src/infra/annotation_tests.rs | 73 + gears/bss/ledger/ledger/src/infra/approval.rs | 8 + .../ledger/src/infra/approval/executor.rs | 349 ++ .../ledger/src/infra/approval/service.rs | 1174 +++++ gears/bss/ledger/ledger/src/infra/audit.rs | 19 + .../ledger/src/infra/audit/event_type.rs | 70 + .../ledger/src/infra/audit/retrieval.rs | 252 + .../src/infra/audit/secured_audit_sink.rs | 220 + .../ledger/ledger/src/infra/audit/store.rs | 233 + gears/bss/ledger/ledger/src/infra/authz.rs | 8 + .../ledger/src/infra/authz/cross_tenant.rs | 234 + .../src/infra/authz/cross_tenant_tests.rs | 112 + .../ledger/ledger/src/infra/control_feed.rs | 120 + .../ledger/src/infra/control_feed_tests.rs | 167 + .../ledger/ledger/src/infra/currency_scale.rs | 50 + .../ledger/ledger/src/infra/error_mapping.rs | 388 ++ .../ledger/src/infra/error_mapping_tests.rs | 490 ++ gears/bss/ledger/ledger/src/infra/events.rs | 10 + .../ledger/src/infra/events/alarm_catalog.rs | 190 + .../src/infra/events/alarm_catalog_tests.rs | 105 + .../ledger/src/infra/events/payloads.rs | 856 ++++ .../ledger/src/infra/events/publisher.rs | 357 ++ .../src/infra/events/publisher_tests.rs | 169 + .../ledger/ledger/src/infra/events/schemas.rs | 288 ++ .../bss/ledger/ledger/src/infra/exception.rs | 179 + gears/bss/ledger/ledger/src/infra/fx.rs | 20 + .../ledger/ledger/src/infra/fx/rate_locker.rs | 152 + .../ledger/src/infra/fx/rate_locker_tests.rs | 123 + .../ledger/ledger/src/infra/fx/rate_source.rs | 213 + .../ledger/src/infra/fx/rate_source_tests.rs | 111 + .../ledger/src/infra/fx/revaluation_run.rs | 921 ++++ .../src/infra/fx/revaluation_run_tests.rs | 61 + gears/bss/ledger/ledger/src/infra/inquiry.rs | 694 +++ .../ledger/ledger/src/infra/inquiry_tests.rs | 64 + .../ledger/ledger/src/infra/invoice_post.rs | 751 +++ gears/bss/ledger/ledger/src/infra/jobs.rs | 38 + .../ledger/src/infra/jobs/aged_alarms.rs | 1145 +++++ .../src/infra/jobs/aged_alarms_tests.rs | 927 ++++ .../src/infra/jobs/attribution_sweep.rs | 114 + .../ledger/src/infra/jobs/period_open.rs | 109 + .../ledger/src/infra/jobs/queue_applier.rs | 374 ++ .../ledger/ledger/src/infra/jobs/rate_sync.rs | 249 + .../ledger/src/infra/jobs/rate_sync_tests.rs | 195 + .../ledger/src/infra/jobs/recognition_run.rs | 169 + .../src/infra/jobs/recognition_run_tests.rs | 31 + .../ledger/src/infra/jobs/revaluation_run.rs | 240 + .../ledger/ledger/src/infra/jobs/tieout.rs | 1925 ++++++++ .../ledger/src/infra/jobs/tieout_tests.rs | 1081 +++++ .../ledger/ledger/src/infra/jobs/verifier.rs | 777 ++++ gears/bss/ledger/ledger/src/infra/metrics.rs | 855 ++++ .../ledger/ledger/src/infra/metrics_tests.rs | 319 ++ gears/bss/ledger/ledger/src/infra/payment.rs | 26 + .../ledger/src/infra/payment/allocate.rs | 1629 +++++++ .../ledger/src/infra/payment/chargeback.rs | 1582 +++++++ .../ledger/ledger/src/infra/payment/credit.rs | 606 +++ .../ledger/src/infra/payment/queue_apply.rs | 278 ++ .../ledger/ledger/src/infra/payment/settle.rs | 367 ++ .../src/infra/payment/settlement_return.rs | 531 +++ .../ledger/src/infra/payment/sidecar.rs | 418 ++ .../ledger/ledger/src/infra/period_close.rs | 836 ++++ gears/bss/ledger/ledger/src/infra/pii.rs | 677 +++ .../bss/ledger/ledger/src/infra/pii_tests.rs | 165 + .../ledger/ledger/src/infra/policy_version.rs | 212 + .../ledger/src/infra/policy_version_tests.rs | 98 + gears/bss/ledger/ledger/src/infra/posting.rs | 12 + .../ledger/ledger/src/infra/posting/chain.rs | 183 + .../ledger/ledger/src/infra/posting/chart.rs | 103 + .../ledger/ledger/src/infra/posting/freeze.rs | 258 + .../ledger/src/infra/posting/idempotency.rs | 376 ++ .../src/infra/posting/idempotency_tests.rs | 130 + .../ledger/ledger/src/infra/posting/period.rs | 169 + .../ledger/src/infra/posting/projector.rs | 1167 +++++ .../src/infra/posting/projector_tests.rs | 424 ++ .../ledger/src/infra/posting/service.rs | 1187 +++++ .../ledger/ledger/src/infra/provisioning.rs | 3 + .../ledger/src/infra/provisioning/service.rs | 251 + .../ledger/ledger/src/infra/recognition.rs | 37 + .../src/infra/recognition/change_service.rs | 598 +++ .../src/infra/recognition/run_service.rs | 305 ++ .../ledger/src/infra/recognition/runner.rs | 866 ++++ .../src/infra/recognition/runner_tests.rs | 218 + .../ledger/src/infra/recognition/sidecar.rs | 556 +++ .../ledger/ledger/src/infra/reconciliation.rs | 879 ++++ .../ledger/src/infra/reconciliation_tests.rs | 120 + .../bss/ledger/ledger/src/infra/retention.rs | 436 ++ .../ledger/ledger/src/infra/seller_guard.rs | 125 + .../ledger/src/infra/seller_guard_tests.rs | 99 + gears/bss/ledger/ledger/src/infra/storage.rs | 8 + .../ledger/ledger/src/infra/storage/entity.rs | 50 + .../infra/storage/entity/account_balance.rs | 34 + .../storage/entity/ar_invoice_balance.rs | 44 + .../infra/storage/entity/ar_payer_balance.rs | 34 + .../infra/storage/entity/audit_chain_state.rs | 24 + .../infra/storage/entity/audit_pack_export.rs | 61 + .../infra/storage/entity/chain_checkpoint.rs | 33 + .../src/infra/storage/entity/chain_state.rs | 24 + .../src/infra/storage/entity/credit_note.rs | 48 + .../storage/entity/currency_scale_registry.rs | 23 + .../src/infra/storage/entity/debit_note.rs | 42 + .../src/infra/storage/entity/dispute.rs | 42 + .../storage/entity/dual_control_approval.rs | 45 + .../storage/entity/dual_control_comment.rs | 36 + .../storage/entity/dual_control_policy.rs | 40 + .../infra/storage/entity/entry_annotation.rs | 31 + .../infra/storage/entity/exception_queue.rs | 41 + .../infra/storage/entity/fiscal_calendar.rs | 31 + .../src/infra/storage/entity/fiscal_period.rs | 29 + .../src/infra/storage/entity/fx_rate.rs | 41 + .../infra/storage/entity/fx_rate_snapshot.rs | 30 + .../storage/entity/fx_revaluation_mode.rs | 39 + .../storage/entity/fx_revaluation_run.rs | 42 + .../infra/storage/entity/idempotency_dedup.rs | 33 + .../infra/storage/entity/invoice_exposure.rs | 42 + .../src/infra/storage/entity/journal_entry.rs | 41 + .../src/infra/storage/entity/journal_line.rs | 57 + .../src/infra/storage/entity/payer_pii_map.rs | 29 + .../src/infra/storage/entity/payer_state.rs | 30 + .../storage/entity/payment_allocation.rs | 35 + .../entity/payment_allocation_refund.rs | 31 + .../storage/entity/payment_settlement.rs | 34 + .../storage/entity/pending_event_queue.rs | 37 + .../src/infra/storage/entity/period_close.rs | 40 + .../infra/storage/entity/posting_policy.rs | 42 + .../infra/storage/entity/recognition_run.rs | 39 + .../storage/entity/recognition_schedule.rs | 55 + .../storage/entity/recognition_segment.rs | 45 + .../storage/entity/reconciliation_run.rs | 35 + .../ledger/src/infra/storage/entity/refund.rs | 49 + .../entity/reusable_credit_subbalance.rs | 40 + .../src/infra/storage/entity/scope_freeze.rs | 32 + .../storage/entity/secured_audit_record.rs | 33 + .../infra/storage/entity/tax_subbalance.rs | 32 + .../infra/storage/entity/tenant_account.rs | 31 + .../storage/entity/tenant_posting_lock.rs | 30 + .../entity/tenant_precedence_policy.rs | 30 + .../storage/entity/unallocated_balance.rs | 35 + .../infra/storage/entity/verified_balance.rs | 55 + .../ledger/src/infra/storage/migrations.rs | 137 + .../m20260619_000001_create_bss_schema.rs | 46 + .../m20260619_000002_create_journal_tables.rs | 358 ++ ...0619_000002_create_journal_tables_tests.rs | 55 + .../m20260619_000003_create_balance_caches.rs | 290 ++ ...000004_create_idempotency_and_reference.rs | 208 + ...m20260619_000005_create_fiscal_calendar.rs | 86 + .../m20260622_000006_create_payment_tables.rs | 193 + ...0260623_000007_create_precedence_policy.rs | 101 + ...60623_000008_create_pending_event_queue.rs | 121 + .../m20260623_000009_add_ar_status.rs | 129 + .../m20260623_000010_create_dispute.rs | 135 + .../m20260624_000011_create_chain_state.rs | 116 + ...260624_000011_create_recognition_tables.rs | 245 + ...60624_000012_create_dual_control_tables.rs | 244 + ...0624_000012_relax_journal_entry_trigger.rs | 115 + .../m20260624_000013_create_scope_freeze.rs | 104 + .../m20260624_000014_create_secured_audit.rs | 157 + ...60624_000014_create_secured_audit_tests.rs | 45 + ...20260624_000015_create_entry_annotation.rs | 88 + .../m20260624_000016_create_payer_pii_map.rs | 91 + ...20260624_000017_create_chain_checkpoint.rs | 111 + ...0260624_000018_create_audit_pack_export.rs | 121 + ...625_000013_dual_control_approving_state.rs | 86 + ...20260626_000019_create_invoice_exposure.rs | 134 + .../m20260626_000020_create_credit_note.rs | 129 + .../m20260626_000021_create_debit_note.rs | 119 + .../m20260626_000022_create_refund.rs | 152 + .../m20260626_000023_refund_approval_kind.rs | 69 + ..._000024_manual_adjustment_approval_kind.rs | 71 + .../m20260626_000025_note_approval_kinds.rs | 67 + .../m20260627_000026_create_fx_rate_tables.rs | 149 + .../m20260627_000027_journal_line_rate_ref.rs | 107 + ...60627_000028_wide_cache_functional_cols.rs | 102 + ...0260627_000029_dual_column_commit_check.rs | 195 + ...7_000030_fiscal_calendar_functional_ccy.rs | 75 + ...628_000031_cache_functional_consistency.rs | 119 + ...628_000032_snapshot_identity_rate_micro.rs | 102 + .../m20260628_000033_create_period_close.rs | 107 + ...m20260628_000034_create_exception_queue.rs | 128 + ...260628_000035_create_reconciliation_run.rs | 117 + ...260628_000036_exception_queue_open_uniq.rs | 69 + .../m20260629_000037_create_posting_policy.rs | 109 + ...20260629_000038_create_verified_balance.rs | 118 + ...260629_000039_create_fx_revaluation_run.rs | 97 + ...60630_000040_create_fx_revaluation_mode.rs | 106 + ...0260706_000041_currency_scale_immutable.rs | 103 + .../ledger/src/infra/storage/odata_mapping.rs | 487 ++ .../src/infra/storage/odata_mapping_tests.rs | 371 ++ .../ledger/ledger/src/infra/storage/repo.rs | 39 + .../src/infra/storage/repo/adjustment_repo.rs | 964 ++++ .../src/infra/storage/repo/approval_repo.rs | 595 +++ .../infra/storage/repo/chain_state_repo.rs | 164 + .../src/infra/storage/repo/dispute_repo.rs | 330 ++ .../storage/repo/exception_queue_repo.rs | 289 ++ .../ledger/src/infra/storage/repo/fx_repo.rs | 275 ++ .../storage/repo/fx_revaluation_mode_repo.rs | 163 + .../storage/repo/fx_revaluation_run_repo.rs | 90 + .../src/infra/storage/repo/journal_repo.rs | 816 ++++ .../infra/storage/repo/payer_state_repo.rs | 148 + .../src/infra/storage/repo/payment_repo.rs | 1508 ++++++ .../infra/storage/repo/pending_queue_repo.rs | 451 ++ .../infra/storage/repo/period_close_repo.rs | 120 + .../infra/storage/repo/posting_policy_repo.rs | 134 + .../infra/storage/repo/recognition_repo.rs | 1768 +++++++ .../storage/repo/reconciliation_run_repo.rs | 130 + .../src/infra/storage/repo/reference_repo.rs | 662 +++ .../storage/repo/verified_balance_repo.rs | 121 + gears/bss/ledger/ledger/src/lib.rs | 49 + gears/bss/ledger/ledger/src/module.rs | 1562 +++++++ gears/bss/ledger/ledger/src/odata.rs | 469 ++ gears/bss/ledger/ledger/src/odata_tests.rs | 335 ++ gears/bss/ledger/ledger/tests/metrics_emit.rs | 125 + gears/bss/ledger/ledger/tests/module_test.rs | 10 + .../ledger/tests/postgres_allocate_fx.rs | 450 ++ .../bss/ledger/ledger/tests/postgres_audit.rs | 541 +++ .../ledger/tests/postgres_balance_caches.rs | 94 + .../bss/ledger/ledger/tests/postgres_bola.rs | 118 + .../bss/ledger/ledger/tests/postgres_chain.rs | 975 ++++ .../tests/postgres_chargeback_concurrency.rs | 499 ++ .../ledger/tests/postgres_chargeback_fx.rs | 545 +++ .../ledger/tests/postgres_chargebacks.rs | 1885 ++++++++ .../ledger/ledger/tests/postgres_credit.rs | 828 ++++ .../tests/postgres_credit_concurrency.rs | 646 +++ .../ledger/tests/postgres_credit_note.rs | 771 +++ .../ledger/tests/postgres_cross_tenant.rs | 415 ++ .../ledger/tests/postgres_debit_note.rs | 839 ++++ .../ledger/tests/postgres_dual_control.rs | 1046 +++++ .../ledger/tests/postgres_entry_annotation.rs | 476 ++ .../ledger/ledger/tests/postgres_exception.rs | 222 + .../ledger/ledger/tests/postgres_executor.rs | 539 +++ .../tests/postgres_fx_revaluation_mode.rs | 186 + .../ledger/tests/postgres_idempotency.rs | 159 + .../ledger/ledger/tests/postgres_inquiry.rs | 468 ++ .../ledger/tests/postgres_invoice_post.rs | 1190 +++++ .../ledger/tests/postgres_invoice_post_fx.rs | 312 ++ .../ledger/ledger/tests/postgres_journal.rs | 773 +++ .../tests/postgres_manual_adjustment.rs | 412 ++ .../tests/postgres_migration_idempotency.rs | 274 ++ .../ledger/tests/postgres_payer_state.rs | 179 + .../tests/postgres_payment_concurrency.rs | 899 ++++ .../ledger/tests/postgres_payment_returns.rs | 474 ++ .../ledger/ledger/tests/postgres_payments.rs | 1393 ++++++ .../ledger/tests/postgres_period_close.rs | 555 +++ .../ledger/tests/postgres_period_guard.rs | 92 + .../ledger/tests/postgres_period_open.rs | 131 + gears/bss/ledger/ledger/tests/postgres_pii.rs | 637 +++ .../ledger/tests/postgres_policy_version.rs | 400 ++ .../ledger/ledger/tests/postgres_posting.rs | 1224 +++++ .../tests/postgres_precedence_policy.rs | 560 +++ .../ledger/ledger/tests/postgres_projector.rs | 616 +++ .../ledger/tests/postgres_provisioning.rs | 321 ++ .../bss/ledger/ledger/tests/postgres_queue.rs | 914 ++++ .../tests/postgres_queue_concurrency.rs | 652 +++ .../ledger/tests/postgres_read_surface.rs | 809 ++++ .../tests/postgres_recognition_build.rs | 726 +++ .../tests/postgres_recognition_change.rs | 936 ++++ .../postgres_recognition_disaggregation.rs | 616 +++ .../ledger/tests/postgres_recognition_run.rs | 1195 +++++ .../ledger/tests/postgres_reconciliation.rs | 753 +++ .../ledger/ledger/tests/postgres_reference.rs | 118 + .../ledger/ledger/tests/postgres_refund.rs | 2635 +++++++++++ .../tests/postgres_refund_dispute_hold.rs | 1018 ++++ .../ledger/ledger/tests/postgres_refund_fx.rs | 487 ++ .../ledger/ledger/tests/postgres_retention.rs | 267 ++ .../ledger/tests/postgres_revaluation_fx.rs | 610 +++ .../ledger/tests/postgres_scale_lock.rs | 159 + .../ledger/ledger/tests/postgres_schema.rs | 39 + .../tests/postgres_settlement_return_fx.rs | 340 ++ .../ledger/ledger/tests/postgres_tieout.rs | 1136 +++++ .../ledger/ledger/tests/rest_adjustments.rs | 924 ++++ gears/bss/ledger/ledger/tests/rest_audit.rs | 287 ++ gears/bss/ledger/ledger/tests/rest_credit.rs | 969 ++++ .../bss/ledger/ledger/tests/rest_disputes.rs | 724 +++ .../ledger/tests/rest_journal_entries.rs | 1410 ++++++ .../bss/ledger/ledger/tests/rest_payments.rs | 1348 ++++++ .../ledger/ledger/tests/rest_provisioning.rs | 614 +++ .../ledger/ledger/tests/rest_recognition.rs | 683 +++ gears/bss/ledger/ledger/tests/rest_refunds.rs | 726 +++ .../ledger/tests/sqlite_adjustment_repo.rs | 494 ++ .../ledger/ledger/tests/sqlite_chain_state.rs | 92 + .../ledger/tests/sqlite_debit_note_repo.rs | 225 + .../ledger/tests/sqlite_payment_refund_cap.rs | 346 ++ .../ledger/ledger/tests/sqlite_refund_repo.rs | 211 + gears/bss/ledger/ledger/tests/sqlite_repo.rs | 155 + .../ledger/tests/sqlite_scale_resolver.rs | 86 + gears/bss/libs/coord/Cargo.toml | 45 + gears/bss/libs/coord/README.md | 180 + gears/bss/libs/coord/src/lease.rs | 20 + gears/bss/libs/coord/src/lease/entity.rs | 55 + gears/bss/libs/coord/src/lease/error.rs | 77 + gears/bss/libs/coord/src/lease/guard.rs | 410 ++ gears/bss/libs/coord/src/lease/manager.rs | 278 ++ .../bss/libs/coord/src/lease/sqlite_tests.rs | 244 + gears/bss/libs/coord/src/lib.rs | 32 + gears/bss/libs/coord/src/migration.rs | 10 + .../migration/m0001_create_coord_leases.rs | 138 + testing/e2e/gears/bss/__init__.py | 0 testing/e2e/gears/bss/ledger/__init__.py | 0 testing/e2e/gears/bss/ledger/conftest.py | 54 + .../e2e/gears/bss/ledger/test_ledger_seams.py | 183 + 475 files changed, 153485 insertions(+), 14 deletions(-) create mode 100644 gears/bss/ledger/docs/ADR/0001-cpt-cf-bss-ledger-adr-book-ownership-predicate.md rename gears/bss/{billing-ledger-balance => ledger}/docs/PRD.md (100%) create mode 100644 gears/bss/ledger/docs/design/01-repository-foundation.md create mode 100644 gears/bss/ledger/docs/design/01a-invoice-posting.md create mode 100644 gears/bss/ledger/docs/design/02-audit-immutability-observability.md create mode 100644 gears/bss/ledger/docs/design/03-payments-allocation.md create mode 100644 gears/bss/ledger/docs/design/04-asc606-recognition.md create mode 100644 gears/bss/ledger/docs/design/05-adjustments-notes-refunds.md create mode 100644 gears/bss/ledger/docs/design/06-fx-multicurrency.md create mode 100644 gears/bss/ledger/docs/design/07-reconciliation-export.md create mode 100644 gears/bss/ledger/docs/design/README.md create mode 100644 gears/bss/ledger/ledger-sdk/Cargo.toml create mode 100644 gears/bss/ledger/ledger-sdk/src/api.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/bill_run_finished.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/close.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/enums.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/error.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/error_tests.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/issued_invoice_manifest.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/lib.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/posting.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/provisioning.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/psp_settlement_feed.rs create mode 100644 gears/bss/ledger/ledger-sdk/src/rate_provider.rs create mode 100644 gears/bss/ledger/ledger/Cargo.toml create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_credit_note_posted.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_debit_note_posted.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_dispute_recorded.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_entry_posted.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_entry_reversed.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_fx_revaluation_completed.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_fx_revaluation_reversed.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_invariant_alarm.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_manual_adjustment_posted.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_period_closed.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_reconciliation_completed.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_refund_recorded.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_revenue_recognition_reversed.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_revenue_recognized.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_schedule_changed.v1.schema.json create mode 100644 gears/bss/ledger/ledger/schemas/billing_ledger_settlement_returned.v1.schema.json create mode 100644 gears/bss/ledger/ledger/src/api.rs create mode 100644 gears/bss/ledger/ledger/src/api/local_client.rs create mode 100644 gears/bss/ledger/ledger/src/api/local_client_tests.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/adjustments.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/approvals.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/audit.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/audit_tests.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/auth_context.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/canonical_json.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/closure.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/control.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/credit.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/disputes.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/dto.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/dto_tests.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/error.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/error_tests.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/exceptions.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/fx.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/fx_revaluation_mode.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/journal_entries.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/journal_entries_tests.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/payers.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/payments.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/posting_policy.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/provisioning.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/recognition.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/reconciliation.rs create mode 100644 gears/bss/ledger/ledger/src/api/rest/refunds.rs create mode 100644 gears/bss/ledger/ledger/src/authz.rs create mode 100644 gears/bss/ledger/ledger/src/authz_tests.rs create mode 100644 gears/bss/ledger/ledger/src/config.rs create mode 100644 gears/bss/ledger/ledger/src/config_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/credit_note.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/credit_note_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/debit_note.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/debit_note_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/manual.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/manual_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/refund.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/refund_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/splitter.rs create mode 100644 gears/bss/ledger/ledger/src/domain/adjustment/splitter_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/allocate.rs create mode 100644 gears/bss/ledger/ledger/src/domain/approval.rs create mode 100644 gears/bss/ledger/ledger/src/domain/approval/intent.rs create mode 100644 gears/bss/ledger/ledger/src/domain/approval/intent_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/approval/policy.rs create mode 100644 gears/bss/ledger/ledger/src/domain/approval/policy_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/approval_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/audit_chain.rs create mode 100644 gears/bss/ledger/ledger/src/domain/audit_chain_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/canonical.rs create mode 100644 gears/bss/ledger/ledger/src/domain/chain.rs create mode 100644 gears/bss/ledger/ledger/src/domain/chain_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/error.rs create mode 100644 gears/bss/ledger/ledger/src/domain/exception.rs create mode 100644 gears/bss/ledger/ledger/src/domain/exception_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/fx.rs create mode 100644 gears/bss/ledger/ledger/src/domain/fx/realized.rs create mode 100644 gears/bss/ledger/ledger/src/domain/fx/realized_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/fx/revaluation.rs create mode 100644 gears/bss/ledger/ledger/src/domain/fx/revaluation_mode.rs create mode 100644 gears/bss/ledger/ledger/src/domain/fx/revaluation_mode_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/fx/revaluation_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/fx/translate.rs create mode 100644 gears/bss/ledger/ledger/src/domain/fx/translate_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/aging.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/aging_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/builder.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/builder_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/mapping.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/mapping_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/policy.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/policy_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/reversal.rs create mode 100644 gears/bss/ledger/ledger/src/domain/invoice/reversal_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/model.rs create mode 100644 gears/bss/ledger/ledger/src/domain/money.rs create mode 100644 gears/bss/ledger/ledger/src/domain/money_math.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/allocation.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/allocation_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/chargeback.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/chargeback_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/credit.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/credit_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/precedence.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/precedence_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/settlement.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/settlement_return.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/settlement_return_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/payment/settlement_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/period.rs create mode 100644 gears/bss/ledger/ledger/src/domain/period_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/ports.rs create mode 100644 gears/bss/ledger/ledger/src/domain/ports/metrics.rs create mode 100644 gears/bss/ledger/ledger/src/domain/ports/obligation_state.rs create mode 100644 gears/bss/ledger/ledger/src/domain/posting.rs create mode 100644 gears/bss/ledger/ledger/src/domain/posting_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/provisioning.rs create mode 100644 gears/bss/ledger/ledger/src/domain/provisioning/plan.rs create mode 100644 gears/bss/ledger/ledger/src/domain/recognition.rs create mode 100644 gears/bss/ledger/ledger/src/domain/recognition/builder.rs create mode 100644 gears/bss/ledger/ledger/src/domain/recognition/builder_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/recognition/change.rs create mode 100644 gears/bss/ledger/ledger/src/domain/recognition/change_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/recognition/input.rs create mode 100644 gears/bss/ledger/ledger/src/domain/recognition/input_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/recognition/ports.rs create mode 100644 gears/bss/ledger/ledger/src/domain/recognition/ports_tests.rs create mode 100644 gears/bss/ledger/ledger/src/domain/scale.rs create mode 100644 gears/bss/ledger/ledger/src/domain/status.rs create mode 100644 gears/bss/ledger/ledger/src/gts.rs create mode 100644 gears/bss/ledger/ledger/src/gts/permissions.rs create mode 100644 gears/bss/ledger/ledger/src/gts/permissions_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra.rs create mode 100644 gears/bss/ledger/ledger/src/infra/adjustment.rs create mode 100644 gears/bss/ledger/ledger/src/infra/adjustment/credit_note_service.rs create mode 100644 gears/bss/ledger/ledger/src/infra/adjustment/debit_note_service.rs create mode 100644 gears/bss/ledger/ledger/src/infra/adjustment/manual_adjustment_service.rs create mode 100644 gears/bss/ledger/ledger/src/infra/adjustment/refund_service.rs create mode 100644 gears/bss/ledger/ledger/src/infra/adjustment/refund_service_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/annotation.rs create mode 100644 gears/bss/ledger/ledger/src/infra/annotation_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/approval.rs create mode 100644 gears/bss/ledger/ledger/src/infra/approval/executor.rs create mode 100644 gears/bss/ledger/ledger/src/infra/approval/service.rs create mode 100644 gears/bss/ledger/ledger/src/infra/audit.rs create mode 100644 gears/bss/ledger/ledger/src/infra/audit/event_type.rs create mode 100644 gears/bss/ledger/ledger/src/infra/audit/retrieval.rs create mode 100644 gears/bss/ledger/ledger/src/infra/audit/secured_audit_sink.rs create mode 100644 gears/bss/ledger/ledger/src/infra/audit/store.rs create mode 100644 gears/bss/ledger/ledger/src/infra/authz.rs create mode 100644 gears/bss/ledger/ledger/src/infra/authz/cross_tenant.rs create mode 100644 gears/bss/ledger/ledger/src/infra/authz/cross_tenant_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/control_feed.rs create mode 100644 gears/bss/ledger/ledger/src/infra/control_feed_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/currency_scale.rs create mode 100644 gears/bss/ledger/ledger/src/infra/error_mapping.rs create mode 100644 gears/bss/ledger/ledger/src/infra/error_mapping_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/events.rs create mode 100644 gears/bss/ledger/ledger/src/infra/events/alarm_catalog.rs create mode 100644 gears/bss/ledger/ledger/src/infra/events/alarm_catalog_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/events/payloads.rs create mode 100644 gears/bss/ledger/ledger/src/infra/events/publisher.rs create mode 100644 gears/bss/ledger/ledger/src/infra/events/publisher_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/events/schemas.rs create mode 100644 gears/bss/ledger/ledger/src/infra/exception.rs create mode 100644 gears/bss/ledger/ledger/src/infra/fx.rs create mode 100644 gears/bss/ledger/ledger/src/infra/fx/rate_locker.rs create mode 100644 gears/bss/ledger/ledger/src/infra/fx/rate_locker_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/fx/rate_source.rs create mode 100644 gears/bss/ledger/ledger/src/infra/fx/rate_source_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/fx/revaluation_run.rs create mode 100644 gears/bss/ledger/ledger/src/infra/fx/revaluation_run_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/inquiry.rs create mode 100644 gears/bss/ledger/ledger/src/infra/inquiry_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/invoice_post.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/aged_alarms.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/aged_alarms_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/attribution_sweep.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/period_open.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/queue_applier.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/rate_sync.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/rate_sync_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/recognition_run.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/recognition_run_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/revaluation_run.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/tieout.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/tieout_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/jobs/verifier.rs create mode 100644 gears/bss/ledger/ledger/src/infra/metrics.rs create mode 100644 gears/bss/ledger/ledger/src/infra/metrics_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/payment.rs create mode 100644 gears/bss/ledger/ledger/src/infra/payment/allocate.rs create mode 100644 gears/bss/ledger/ledger/src/infra/payment/chargeback.rs create mode 100644 gears/bss/ledger/ledger/src/infra/payment/credit.rs create mode 100644 gears/bss/ledger/ledger/src/infra/payment/queue_apply.rs create mode 100644 gears/bss/ledger/ledger/src/infra/payment/settle.rs create mode 100644 gears/bss/ledger/ledger/src/infra/payment/settlement_return.rs create mode 100644 gears/bss/ledger/ledger/src/infra/payment/sidecar.rs create mode 100644 gears/bss/ledger/ledger/src/infra/period_close.rs create mode 100644 gears/bss/ledger/ledger/src/infra/pii.rs create mode 100644 gears/bss/ledger/ledger/src/infra/pii_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/policy_version.rs create mode 100644 gears/bss/ledger/ledger/src/infra/policy_version_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting/chain.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting/chart.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting/freeze.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting/idempotency.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting/idempotency_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting/period.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting/projector.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting/projector_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/posting/service.rs create mode 100644 gears/bss/ledger/ledger/src/infra/provisioning.rs create mode 100644 gears/bss/ledger/ledger/src/infra/provisioning/service.rs create mode 100644 gears/bss/ledger/ledger/src/infra/recognition.rs create mode 100644 gears/bss/ledger/ledger/src/infra/recognition/change_service.rs create mode 100644 gears/bss/ledger/ledger/src/infra/recognition/run_service.rs create mode 100644 gears/bss/ledger/ledger/src/infra/recognition/runner.rs create mode 100644 gears/bss/ledger/ledger/src/infra/recognition/runner_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/recognition/sidecar.rs create mode 100644 gears/bss/ledger/ledger/src/infra/reconciliation.rs create mode 100644 gears/bss/ledger/ledger/src/infra/reconciliation_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/retention.rs create mode 100644 gears/bss/ledger/ledger/src/infra/seller_guard.rs create mode 100644 gears/bss/ledger/ledger/src/infra/seller_guard_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/account_balance.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/ar_invoice_balance.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/ar_payer_balance.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/audit_chain_state.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/audit_pack_export.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/chain_checkpoint.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/chain_state.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/credit_note.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/currency_scale_registry.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/debit_note.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/dispute.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_approval.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_comment.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_policy.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/entry_annotation.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/exception_queue.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/fiscal_calendar.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/fiscal_period.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/fx_rate.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/fx_rate_snapshot.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/fx_revaluation_mode.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/fx_revaluation_run.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/idempotency_dedup.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/invoice_exposure.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/journal_entry.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/journal_line.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/payer_pii_map.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/payer_state.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/payment_allocation.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/payment_allocation_refund.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/payment_settlement.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/pending_event_queue.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/period_close.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/posting_policy.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/recognition_run.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/recognition_schedule.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/recognition_segment.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/reconciliation_run.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/refund.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/reusable_credit_subbalance.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/scope_freeze.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/secured_audit_record.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/tax_subbalance.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/tenant_account.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/tenant_posting_lock.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/tenant_precedence_policy.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/unallocated_balance.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/entity/verified_balance.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000001_create_bss_schema.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000002_create_journal_tables.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000002_create_journal_tables_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000003_create_balance_caches.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000004_create_idempotency_and_reference.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000005_create_fiscal_calendar.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260622_000006_create_payment_tables.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000007_create_precedence_policy.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000008_create_pending_event_queue.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000009_add_ar_status.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000010_create_dispute.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000011_create_chain_state.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000011_create_recognition_tables.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000012_create_dual_control_tables.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000012_relax_journal_entry_trigger.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000013_create_scope_freeze.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000014_create_secured_audit.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000014_create_secured_audit_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000015_create_entry_annotation.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000016_create_payer_pii_map.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000017_create_chain_checkpoint.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000018_create_audit_pack_export.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260625_000013_dual_control_approving_state.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000019_create_invoice_exposure.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000020_create_credit_note.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000021_create_debit_note.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000022_create_refund.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000023_refund_approval_kind.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000024_manual_adjustment_approval_kind.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000025_note_approval_kinds.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000026_create_fx_rate_tables.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000027_journal_line_rate_ref.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000028_wide_cache_functional_cols.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000029_dual_column_commit_check.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000030_fiscal_calendar_functional_ccy.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000031_cache_functional_consistency.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000032_snapshot_identity_rate_micro.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000033_create_period_close.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000034_create_exception_queue.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000035_create_reconciliation_run.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000036_exception_queue_open_uniq.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000037_create_posting_policy.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000038_create_verified_balance.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000039_create_fx_revaluation_run.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260630_000040_create_fx_revaluation_mode.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/migrations/m20260706_000041_currency_scale_immutable.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/odata_mapping.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/odata_mapping_tests.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/adjustment_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/approval_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/chain_state_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/dispute_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/exception_queue_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/fx_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/fx_revaluation_mode_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/fx_revaluation_run_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/journal_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/payer_state_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/payment_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/pending_queue_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/period_close_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/posting_policy_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/recognition_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/reconciliation_run_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/reference_repo.rs create mode 100644 gears/bss/ledger/ledger/src/infra/storage/repo/verified_balance_repo.rs create mode 100644 gears/bss/ledger/ledger/src/lib.rs create mode 100644 gears/bss/ledger/ledger/src/module.rs create mode 100644 gears/bss/ledger/ledger/src/odata.rs create mode 100644 gears/bss/ledger/ledger/src/odata_tests.rs create mode 100644 gears/bss/ledger/ledger/tests/metrics_emit.rs create mode 100644 gears/bss/ledger/ledger/tests/module_test.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_allocate_fx.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_audit.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_balance_caches.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_bola.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_chain.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_chargeback_concurrency.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_chargeback_fx.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_chargebacks.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_credit.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_credit_concurrency.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_credit_note.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_cross_tenant.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_debit_note.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_dual_control.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_entry_annotation.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_exception.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_executor.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_fx_revaluation_mode.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_idempotency.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_inquiry.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_invoice_post.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_invoice_post_fx.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_journal.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_manual_adjustment.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_migration_idempotency.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_payer_state.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_payment_concurrency.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_payment_returns.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_payments.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_period_close.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_period_guard.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_period_open.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_pii.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_policy_version.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_posting.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_precedence_policy.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_projector.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_provisioning.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_queue.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_queue_concurrency.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_read_surface.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_recognition_build.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_recognition_change.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_recognition_disaggregation.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_recognition_run.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_reconciliation.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_reference.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_refund.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_refund_dispute_hold.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_refund_fx.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_retention.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_revaluation_fx.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_scale_lock.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_schema.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_settlement_return_fx.rs create mode 100644 gears/bss/ledger/ledger/tests/postgres_tieout.rs create mode 100644 gears/bss/ledger/ledger/tests/rest_adjustments.rs create mode 100644 gears/bss/ledger/ledger/tests/rest_audit.rs create mode 100644 gears/bss/ledger/ledger/tests/rest_credit.rs create mode 100644 gears/bss/ledger/ledger/tests/rest_disputes.rs create mode 100644 gears/bss/ledger/ledger/tests/rest_journal_entries.rs create mode 100644 gears/bss/ledger/ledger/tests/rest_payments.rs create mode 100644 gears/bss/ledger/ledger/tests/rest_provisioning.rs create mode 100644 gears/bss/ledger/ledger/tests/rest_recognition.rs create mode 100644 gears/bss/ledger/ledger/tests/rest_refunds.rs create mode 100644 gears/bss/ledger/ledger/tests/sqlite_adjustment_repo.rs create mode 100644 gears/bss/ledger/ledger/tests/sqlite_chain_state.rs create mode 100644 gears/bss/ledger/ledger/tests/sqlite_debit_note_repo.rs create mode 100644 gears/bss/ledger/ledger/tests/sqlite_payment_refund_cap.rs create mode 100644 gears/bss/ledger/ledger/tests/sqlite_refund_repo.rs create mode 100644 gears/bss/ledger/ledger/tests/sqlite_repo.rs create mode 100644 gears/bss/ledger/ledger/tests/sqlite_scale_resolver.rs create mode 100644 gears/bss/libs/coord/Cargo.toml create mode 100644 gears/bss/libs/coord/README.md create mode 100644 gears/bss/libs/coord/src/lease.rs create mode 100644 gears/bss/libs/coord/src/lease/entity.rs create mode 100644 gears/bss/libs/coord/src/lease/error.rs create mode 100644 gears/bss/libs/coord/src/lease/guard.rs create mode 100644 gears/bss/libs/coord/src/lease/manager.rs create mode 100644 gears/bss/libs/coord/src/lease/sqlite_tests.rs create mode 100644 gears/bss/libs/coord/src/lib.rs create mode 100644 gears/bss/libs/coord/src/migration.rs create mode 100644 gears/bss/libs/coord/src/migration/m0001_create_coord_leases.rs create mode 100644 testing/e2e/gears/bss/__init__.py create mode 100644 testing/e2e/gears/bss/ledger/__init__.py create mode 100644 testing/e2e/gears/bss/ledger/conftest.py create mode 100644 testing/e2e/gears/bss/ledger/test_ledger_seams.py diff --git a/.cf-studio/config/artifacts.toml b/.cf-studio/config/artifacts.toml index 6ffc17d9b..611e6d5cb 100644 --- a/.cf-studio/config/artifacts.toml +++ b/.cf-studio/config/artifacts.toml @@ -10,6 +10,14 @@ patterns = ["gears/system"] reason = "Ignore 'gears/bss' parent directory — not a module, only a grouping for BSS submodules." patterns = ["gears/bss"] +[[ignore]] +reason = "Ignore 'gears/bss/libs' — not a module, only a grouping for shared BSS utility libraries." +patterns = ["gears/bss/libs"] + +[[ignore]] +reason = "Ignore 'coord' shared utility library (distributed-lease primitive; infrastructure crate, not an SDLC-documented product module — no PRD/DESIGN required, README-documented)." +patterns = ["gears/bss/libs/coord/*"] + [[ignore]] reason = "Ignore authn-resolver root module node while allowing nested plugin docs scopes." patterns = ["gears/system/authn-resolver"] diff --git a/Cargo.lock b/Cargo.lock index 81cdb5aa2..35345ac23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,9 +573,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-fips-sys", "aws-lc-sys", @@ -585,14 +585,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -1031,6 +1032,61 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bss-ledger" +version = "0.0.0" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "aws-lc-rs", + "axum", + "bss-ledger", + "bss-ledger-sdk", + "cf-gears-account-management-sdk", + "cf-gears-authz-resolver-sdk", + "cf-gears-toolkit", + "cf-gears-toolkit-canonical-errors", + "cf-gears-toolkit-db", + "cf-gears-toolkit-db-macros", + "cf-gears-toolkit-gts", + "cf-gears-toolkit-macros", + "cf-gears-toolkit-odata", + "cf-gears-toolkit-security", + "cf-gears-types-registry-sdk", + "chrono", + "coord", + "futures", + "gts", + "opentelemetry", + "opentelemetry_sdk", + "sea-orm", + "sea-orm-migration", + "serde", + "serde_json", + "testcontainers-modules", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tracing", + "utoipa", + "uuid", +] + +[[package]] +name = "bss-ledger-sdk" +version = "0.0.0" +dependencies = [ + "async-trait", + "cf-gears-toolkit-canonical-errors", + "cf-gears-toolkit-odata", + "cf-gears-toolkit-security", + "chrono", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1556,6 +1612,7 @@ name = "cf-gears-example-server" version = "0.6.1" dependencies = [ "anyhow", + "bss-ledger", "calculator", "calculator-gateway", "cf-gears-account-management", @@ -3124,6 +3181,24 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "coord" +version = "0.0.0" +dependencies = [ + "async-trait", + "cf-gears-toolkit-db", + "cf-gears-toolkit-db-macros", + "cf-gears-toolkit-security", + "chrono", + "sea-orm", + "sea-orm-migration", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -3584,7 +3659,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3765,7 +3840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4755,7 +4830,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -5078,7 +5153,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5990,7 +6065,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8096,7 +8171,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8193,7 +8268,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9531,7 +9606,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10847,7 +10922,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5631a1248..c1d435fb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,9 @@ members = [ "gears/system/usage-collector/plugins/noop-usage-collector-plugin", "gears/mini-chat/mini-chat-sdk", "gears/mini-chat/mini-chat", + "gears/bss/ledger/ledger", + "gears/bss/ledger/ledger-sdk", + "gears/bss/libs/coord", ] exclude = ["tools/fuzz"] resolver = "3" @@ -283,6 +286,12 @@ resource-group-sdk = { package = "cf-gears-resource-group-sdk", version = "0.2.0 oagw-sdk = { package = "cf-gears-oagw-sdk", version = "0.5.3", path = "gears/system/oagw/oagw-sdk" } usage-collector-sdk = { package = "cf-gears-usage-collector-sdk", version = "0.1.0", path = "gears/system/usage-collector/usage-collector-sdk" } +# BSS gears (ported from vhp-core) +bss-ledger-sdk = { path = "gears/bss/ledger/ledger-sdk" } +coord = { path = "gears/bss/libs/coord" } +# FIPS-validated SHA-256 for the ledger idempotency payload fingerprint. +aws-lc-rs = "1.17" + # credstore credstore-sdk = { package = "cf-gears-credstore-sdk", version = "0.1.26", path = "gears/credstore/credstore-sdk" } diff --git a/apps/cf-gears-example-server/Cargo.toml b/apps/cf-gears-example-server/Cargo.toml index cfd746225..3a8b7a7e2 100644 --- a/apps/cf-gears-example-server/Cargo.toml +++ b/apps/cf-gears-example-server/Cargo.toml @@ -29,6 +29,7 @@ otel = ["toolkit/otel"] account-management = ["dep:account_management"] static-idp = ["dep:static-idp-plugin"] file-storage = ["dep:file_storage"] +bss-ledger = ["dep:bss_ledger"] [dependencies] mimalloc = { version = "0.1" } @@ -78,6 +79,9 @@ mini-chat = { package = "cf-gears-mini-chat", path = "../../gears/mini-chat/mini account_management = { package = "cf-gears-account-management", path = "../../gears/system/account-management/account-management", optional = true } static-idp-plugin = { package = "cf-gears-static-idp-plugin", path = "../../gears/system/account-management/plugins/static-idp-plugin", optional = true } +# Optional BSS Billing Ledger gear +bss_ledger = { package = "bss-ledger", path = "../../gears/bss/ledger/ledger", optional = true } + # Optional example module users-info = { path = "../../examples/toolkit/users-info/users-info", optional = true } calculator-gateway = { path = "../../examples/oop-gears/calculator-gateway/calculator-gateway", optional = true } diff --git a/apps/cf-gears-example-server/src/registered_gears.rs b/apps/cf-gears-example-server/src/registered_gears.rs index 2879ee309..7708fb416 100644 --- a/apps/cf-gears-example-server/src/registered_gears.rs +++ b/apps/cf-gears-example-server/src/registered_gears.rs @@ -70,3 +70,6 @@ use static_idp_plugin as _; #[cfg(feature = "account-management")] use account_management as _; + +#[cfg(feature = "bss-ledger")] +use bss_ledger as _; diff --git a/config/e2e-features.txt b/config/e2e-features.txt index a5608bd8b..df61d5d69 100644 --- a/config/e2e-features.txt +++ b/config/e2e-features.txt @@ -1 +1 @@ -users-info-example,mini-chat,static-tenants,static-authn,static-authz,static-credstore,account-management,static-idp +users-info-example,mini-chat,static-tenants,static-authn,static-authz,static-credstore,account-management,static-idp,bss-ledger \ No newline at end of file diff --git a/config/e2e-local.yaml b/config/e2e-local.yaml index 29121cb53..91dfc2a45 100644 --- a/config/e2e-local.yaml +++ b/config/e2e-local.yaml @@ -265,6 +265,10 @@ gears: identity: subject_id: "11111111-6a88-4768-9dfc-6bcd5187d9ed" subject_tenant_id: "00000000-df51-5b42-9538-d2b56b7ee953" + # A real AuthN resolver always sets a subject_type; some gears + # (e.g. bss-ledger) require its presence. Additive — gears that do + # not check it are unaffected. + subject_type: "gts.cf.core.security.subject_user.v1~" token_scopes: ["*"] # Hierarchy tenant tokens — used by budget allocation e2e tests - token: "e2e-token-hierarchy-root" @@ -364,6 +368,15 @@ gears: # rather than silently shipping a half-wired stack. required: true + bss-ledger: + # Gear-specific database (SQLite for E2E, like the other user gears; the + # gear ships dual PG/SQLite migrations). No config block needed — the + # defaults (events_enabled=false, seller_tenant_types=partner+platform) + # are correct for E2E. + database: + server: "sqlite_users" + file: "bss_ledger.db" + simple-user-settings: # Gear-specific database configuration database: diff --git a/deny.toml b/deny.toml index 38f26ef80..28785cce3 100644 --- a/deny.toml +++ b/deny.toml @@ -39,6 +39,9 @@ ignore = [ "RUSTSEC-2023-0071", # Marvin Attack timing sidechannel in rsa 0.9 — dev-only dep of rustls-corecrypto-provider; used solely for RSA-PSS *verification* (public-key op, no private key involved) in rsa_pss_apple_salt_length_matches_rfc8017 test; no safe upgrade available; advisory itself permits "local use on a non-compromised computer" "RUSTSEC-2026-0173", # proc-macro-error2 unmaintained — direct dep of our proc-macro crate cf-gears-toolkit-db-macros (compile-time only, never in any runtime artifact); no advisory of a vulnerability, only maintenance status. Tracked for migration to std syn::Error -> compile_error! emission. "RUSTSEC-2026-0187", # lopdf <0.42 unbounded-recursion DoS on crafted PDFs — transitive via kreuzberg→cf-gears-file-parser→cf-gears-example-server (example app only, never parses untrusted PDFs). No usable fix: kreuzberg 4.9.x pins lopdf "^0.41" (<0.42) and the fix landed in 0.42.0; 5.x drops the bundled-pdfium feature we use. Remove once kreuzberg ships a release allowing lopdf >=0.42. + "RUSTSEC-2026-0194", # quick-xml <0.41 quadratic-CPU DoS on crafted XML (duplicate-attribute check) — transitive (multiple pinned versions 0.37–0.39 via office/doc parsers under the file-parser example app); we never parse untrusted XML on any request hot path. No unified upgrade yet (each upstream pins its own <0.41). Remove once the transitive pins reach quick-xml >=0.41. + "RUSTSEC-2026-0195", # quick-xml <0.41 unbounded namespace-declaration allocation → memory-exhaustion DoS on crafted XML — same transitive quick-xml as RUSTSEC-2026-0194; same rationale (no untrusted-XML hot path, no unified upgrade). Remove together with it once the pins reach >=0.41. + "RUSTSEC-2026-0204", # crossbeam invalid-pointer-deref in the `fmt::Pointer` impl for `Atomic`/`Shared` (only when Debug-formatting an already-invalid pointer) — deep transitive dep (tokio/rayon/sccache/… epoch GC), not reachable from our code (we never Debug-print crossbeam atomics). Remove once the transitive crossbeam pins ship the fixed release. ] # Note: diff --git a/gears/bss/ledger/docs/ADR/0001-cpt-cf-bss-ledger-adr-book-ownership-predicate.md b/gears/bss/ledger/docs/ADR/0001-cpt-cf-bss-ledger-adr-book-ownership-predicate.md new file mode 100644 index 000000000..03e46523b --- /dev/null +++ b/gears/bss/ledger/docs/ADR/0001-cpt-cf-bss-ledger-adr-book-ownership-predicate.md @@ -0,0 +1,139 @@ +--- +status: accepted +date: 2026-06-17 +decision-makers: "@vstudzinskyi (BSS Billing Platform team)" +--- + +# ADR-0001: Ledger Book Ownership — Only Selling Entities Own Billing Books + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Any tenant owns books (untyped owner)](#any-tenant-owns-books-untyped-owner) + - [Hardcoded seller type list in the ledger](#hardcoded-seller-type-list-in-the-ledger) + - [New seller/buyer enum on the tenant type](#new-sellerbuyer-enum-on-the-tenant-type) + - [Trait-driven predicate owned by the AMS catalogue (chosen)](#trait-driven-predicate-owned-by-the-ams-catalogue-chosen) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-bss-ledger-adr-book-ownership-predicate` + +## Context and Problem Statement + +Several ledger-wide anchors hang off "the owning `tenant_id`": it is the tenant-isolation/owner axis, the `export_target` holder, the period-close unit, and the functional-currency carrier. VHP tenants are **typed** (`tenant_type` on the platform `TenantInfo` — e.g. `platform`, `partner`, `organization`) and sit in one tenant tree that serves two different commercial roles: entities that **sell** (and must keep AR / Revenue / Contract-liability books, close periods, and export to an ERP) and entities that only **buy** (and appear on ledger lines as payers or resources). Which tenants own billing books, and how does the ledger decide? + +## Decision Drivers + +* The book owner must be an entity that legally sells — books, period close, `export_target`, and functional currency are properties of a selling legal entity, not of every tenant +* The buyer axis and the seller axis run over the *same* tenant tree and must not be conflated — payer resolution / AR consolidation is a different hierarchy walk than book-ownership / subtree reads +* The `tenant ↔ commercial-account ↔ legal-entity` mapping is not ledger domain — AMS / Catalog / billing-setup own it +* The seller predicate should be resolvable from platform-owned data (`tenant_type`, AMS/GTS catalogue), not duplicated ledger config +* Adding or retiring a selling tenant type should not require a ledger change +* The mechanism must be grounded in the actual platform catalogue (what tenant types and extension points really exist), not an invented abstraction + +## Considered Options + +* Any tenant owns books (untyped owner — every tenant gets books, close, `export_target`) +* Hardcoded seller type list in the ledger (`tenant_type IN ('platform','partner')` baked into ledger code) +* New seller/buyer enum added to the tenant-type model +* Trait-driven predicate owned by the AMS catalogue — `x-gts-traits.owns_billing_books` on selling tenant-type schemas (chosen) + +## Decision Outcome + +Chosen option: "Trait-driven predicate owned by the AMS catalogue" (decision **S7-F3**, ratified in the 2026-06-17 reconciliation-export design review; hardening verified against the platform catalogue), because it grounds the seller predicate in platform-owned typed data, keeps the ledger free of tenant-taxonomy knowledge, and makes seller-set changes an AMS catalogue change rather than a ledger release. + +The normative decision (ledger-wide, stated in [`01-repository-foundation.md`](../design/01-repository-foundation.md) § Additional context): + +* **Only selling entities own billing books** (AR / Revenue / Contract liability) and therefore have an **`export_target` + period close + functional currency**. The ledger-owner `tenant_id` — the isolation/owner axis, the `export_target` holder, the period-close unit, and the functional-currency carrier — **is a selling legal-entity / commercial-account**. +* **Buyer-type tenants own no books** — they appear **only** as `payer` / `resource` on journal lines; no books, no close, no `export_target`. +* The predicate "this tenant owns books" resolves from **`tenant_type` (AMS/GTS)** + the **commercial-account mapping**. +* **Two distinct hierarchies — do NOT conflate:** the **buyer hierarchy** (org → department) drives **payer resolution / AR consolidation**; the **seller hierarchy** (platform → partner → reseller) is the **book-ownership / subtree-read** axis. Different axes on the same tenant tree. +* The `tenant ↔ commercial-account ↔ legal-entity` mapping (**1:1 or 1:N**) is owned by **AMS / Catalog / billing-setup, not the ledger**; the one-functional-currency-per-legal-entity rule (FX F5) hangs off it. +* **Hardening (verified against the platform catalogue).** The tenant-type catalogue (`vhp-core-am/config/tenant-types.yaml`; GTS ids `gts.cf.core.am.tenant_type.v1~vz.ams.tenants..v1~`) defines **`platform`, `partner`, `organization`** (hierarchy via `x-gts-traits.allowed_parent_types`: platform → partner → organization); there is no `individual` type yet, and no `seller`/`buyer` enum exists — none is needed, since tenant types already carry custom `x-gts-traits`. +* **Predicate (proposed):** book-owning **seller** = `platform` + `partner`; **buyer** = `organization` (+ a future `individual`). +* **Recommended mechanism:** AMS adds an **`x-gts-traits.owns_billing_books: true`** trait to the `platform` and `partner` type schemas; the ledger's provisioning gate and the owner predicate read **that trait**, not a hardcoded type list. +* ⏳ **Pending (AMS):** confirm platform + partner are the intended sellers (and whether an `organization`/reseller can ever sell), then add the trait to the catalogue. The predicate decision itself is ratified; this hardening item is the open remainder. + +### Consequences + +* The ledger's seller-provisioning gate (Foundation §4.12, `POST /v1/ledger/legal-entities/{id}/provisioning`) must evaluate the ownership predicate before seeding a legal entity's reference rows (chart of accounts, currency scales, fiscal calendar + initial period) — provisioning is seller-only and must precede the first post +* Buyer-type tenants are never provisioned with books: no chart of accounts, no fiscal periods, no `export_target`, no functional currency — attempts must be rejected by the predicate gate +* Payer resolution / AR consolidation (buyer axis) and book-ownership / subtree reads (seller axis) must be implemented as separate hierarchy walks over the same tenant tree; neither may reuse the other's semantics +* The ledger reads `tenant_type` / the `owns_billing_books` trait from AMS/GTS and the commercial-account mapping from AMS — it stores no tenant taxonomy of its own; adding or retiring a selling type is an AMS catalogue change, not a ledger change +* Until AMS lands the trait, the predicate is evaluated against the proposed seller set (`platform` + `partner`); the ledger must switch to reading the trait once it exists ⏳ +* Functional currency is bound per selling legal entity (via the AMS mapping), so the FX layer's one-functional-currency-per-legal-entity rule is anchored to this predicate + +### Confirmation + +* Design review: the provisioning gate and every book-anchored surface (`export_target`, period close, functional currency) key off the seller predicate; no code path grants books to a buyer-type tenant +* Integration test: provisioning a `platform`/`partner` (seller) legal entity succeeds; provisioning an `organization` (buyer) tenant is rejected by the predicate gate +* Code review: the predicate reads `tenant_type` / `x-gts-traits.owns_billing_books` from AMS/GTS; no hardcoded tenant-type taxonomy lives in ledger domain logic once the trait ships +* Cross-team checkpoint: AMS sign-off recorded and the `owns_billing_books` trait present in the tenant-type catalogue (closes the ⏳ item) + +## Pros and Cons of the Options + +### Any tenant owns books (untyped owner) + +Every tenant gets ledger books, a fiscal calendar, an `export_target`, and a functional currency. + +* Good, because no predicate to design — provisioning is uniform +* Bad, because it is semantically wrong: books, close, and ERP export are properties of a selling legal entity; a buying department has no legal-entity standing, no ERP, no functional currency of its own +* Bad, because it multiplies close/export machinery across tenants that will never post revenue, and every buyer would need placeholder configuration +* Bad, because it conflates the buyer and seller hierarchies, poisoning both payer resolution and subtree-read semantics + +### Hardcoded seller type list in the ledger + +The ledger bakes `tenant_type IN ('platform','partner')` into its provisioning gate and owner predicate. + +* Good, because trivially simple and immediately implementable — no cross-team dependency +* Good, because it matches the currently verified catalogue (only three types exist) +* Bad, because tenant taxonomy leaks into ledger code — adding/retiring a selling type (e.g. a selling reseller) becomes a ledger release +* Bad, because the ledger asserts a business fact (who sells) that AMS owns +* Bad, because divergence risk: AMS evolves the type catalogue, the ledger's list silently goes stale + +### New seller/buyer enum on the tenant type + +Extend the AMS tenant-type model with an explicit `seller`/`buyer` classification enum. + +* Good, because the classification would be explicit, platform-owned data +* Bad, because verified against the catalogue: **no such enum exists anywhere**, so it is a new modeling concept requiring AMS schema evolution +* Bad, because it is unnecessary — the tenant-type substrate already carries arbitrary custom `x-gts-traits` (`allowed_parent_types`, `idp_provisioning`, and traits exercised by RMS test scenarios), which express exactly this kind of capability flag +* Bad, because a binary enum is less extensible than traits if further billing capabilities need flagging later + +### Trait-driven predicate owned by the AMS catalogue (chosen) + +AMS adds `x-gts-traits.owns_billing_books: true` to the `platform` and `partner` tenant-type schemas; the ledger's provisioning gate and owner predicate read the trait. + +* Good, because the seller set is AMS catalogue data — adding/retiring a selling type is a catalogue change, not a ledger change +* Good, because it uses an existing, verified extension mechanism (`x-gts-traits`), not a new modeling concept +* Good, because the ledger stays taxonomy-free: it evaluates one boolean trait plus the AMS-owned commercial-account mapping +* Good, because the mechanism was verified against the actual platform catalogue (`vhp-core-am/config/tenant-types.yaml`) rather than assumed +* Neutral, because until the trait lands the ledger evaluates the proposed set (`platform` + `partner`) as an interim +* Bad, because it introduces a cross-team dependency — AMS must confirm the seller set and land the catalogue change (⏳ pending) +* Bad, because a misconfigured trait (e.g. accidentally set on `organization`) would grant books platform-wide; catalogue governance must guard the trait + +## More Information + +Normative ledger-wide statement: [`01-repository-foundation.md`](../design/01-repository-foundation.md) § Ledger-Ownership Predicate (⏳ pending AMS sign-off + one catalogue change; mechanism identified and verified). + +## Traceability + +- **PRD**: [PRD.md](../PRD.md) +- **DESIGN**: [01-repository-foundation.md](../design/01-repository-foundation.md) + +This decision directly addresses the following requirements or design elements: + +* `cpt-cf-bss-ledger-fr-tenant-isolation-posting` — the isolation/owner axis is the selling entity's `tenant_id`; buyer tenants appear only as `payer`/`resource` attributes on lines within the seller's scope +* `cpt-cf-bss-ledger-fr-multi-axis-attribution` — `payer` and `resource` line attribution is exactly how buyer-type tenants surface in the ledger; the buyer hierarchy drives payer resolution, not book ownership +* `cpt-cf-bss-ledger-fr-accounting-periods-close` — the period-close unit is the book-owning seller; buyer tenants have no periods to close +* `cpt-cf-bss-ledger-fr-multi-currency-fx` — the functional currency is carried by the selling legal entity (one functional currency per legal entity, via the AMS mapping) +* `cpt-cf-bss-ledger-contract-erp-export` — `export_target` exists only on book-owning sellers; ERP export is per selling legal entity +* `cpt-cf-bss-ledger-actor-erp-gl` — the downstream ERP/GL relationship is anchored to the selling legal entity that owns the books diff --git a/gears/bss/billing-ledger-balance/docs/PRD.md b/gears/bss/ledger/docs/PRD.md similarity index 100% rename from gears/bss/billing-ledger-balance/docs/PRD.md rename to gears/bss/ledger/docs/PRD.md diff --git a/gears/bss/ledger/docs/design/01-repository-foundation.md b/gears/bss/ledger/docs/design/01-repository-foundation.md new file mode 100644 index 000000000..bc598e87e --- /dev/null +++ b/gears/bss/ledger/docs/design/01-repository-foundation.md @@ -0,0 +1,549 @@ + + + + +# DESIGN — Repository-Foundation (shared double-entry engine) (Slice 1) + +- [ ] `p3` - **ID**: `cpt-cf-bss-ledger-design-foundation` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Naming, Glossary Discipline and Module Alignment](#41-naming-glossary-discipline-and-module-alignment) + - [4.2 Foundation Schema Ownership (normative)](#42-foundation-schema-ownership-normative) + - [4.3 Call-Driven Ingestion Model (normative)](#43-call-driven-ingestion-model-normative) + - [4.4 Ledger-Ownership Predicate (normative)](#44-ledger-ownership-predicate-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The Billing Ledger is a double-entry, AR, ASC 606-compatible posting subledger. It starts **at invoice post** — it never re-rates usage or evaluates tariffs (that is upstream in Rating/Metering) — and it owns the **posted financial subledger** as the system of record for balances, AR, revenue recognition, and the audit/tamper trail feeding ERP/GL export. + +The chosen architecture is the **Hybrid append-only journal**: `journal_line` is the immutable single source of truth, balances are an in-transaction **derived cache**, and a daily **tie-out** recomputes the cache from the lines and proves it exactly (zero tolerance). This design document is the **Repository-foundation** — it owns the shared posting engine, the schema, the universal posting invariants, the total lock order, and the in-process data-access API that every handler slice posts through. It owns **no domain policy**: what lines to build, which accounts to target, and what a balance shortfall means all live in a handler feature (invoice-posting, payments-allocation, and the rest), each of which calls this Foundation's API under the invariants defined here. + +The whole ledger is **one deployable modular monolith** (`billing-ledger`). Every posting flow must share one ACID transaction, one total lock order, and one `PostingService`; splitting handlers into separate processes would turn in-transaction invariants into distributed transactions, so the handlers are **modules**, not microservices. The same image runs in two runtime roles — `ledger-api` (synchronous posting and reads) and `ledger-workers` (tie-out, chain writer/verifier, ERP-export re-driver, outbox relay, out-of-order queue appliers) — against one PostgreSQL cluster per residency cell. + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-ledger-fr-balanced-journal-entries` | Deferrable leaf-partition `CONSTRAINT TRIGGER` re-checks `SUM(DR) = SUM(CR)` exactly (zero tolerance) per `(currency, currency_scale)`, `COUNT(line) ≥ 1`, single `payer_tenant_id` at COMMIT; `MoneyModule` residual assignment makes every built entry exact so commit needs no tolerance. | +| `cpt-cf-bss-ledger-fr-posting-immutability` | `journal_entry`/`journal_line` are append-only: `REVOKE UPDATE, DELETE` from the app role plus `BEFORE UPDATE OR DELETE` triggers; corrections only via the line-negation reversal path. | +| `cpt-cf-bss-ledger-fr-reversal-canonical-pattern` | Strict line-negation reversal (side-flip, positive amount); partial `UNIQUE` on `(tenant, reverses_period_id, reverses_entry_id)` enforces at-most-once reversal; reversing a reversal is forbidden (handler-level, `CANNOT_REVERSE_REVERSAL`). | +| `cpt-cf-bss-ledger-fr-idempotency-per-flow` / `cpt-cf-bss-ledger-fr-idempotent-replay-contract` | `idempotency_dedup` PK `(tenant_id, flow, business_id)` is both the at-most-once gate and the replay-response source; `payload_hash` covers only canonical financial content. | +| `cpt-cf-bss-ledger-fr-negative-balance-invariants` | Conditional DB `CHECK` on the guarded class set plus application re-assert on the `RETURNING` value; violation rolls back and pages Revenue Assurance. | +| `cpt-cf-bss-ledger-fr-money-rounding-scale` | Centralized `MoneyModule`: `BIGINT` minor units, banker's (half-to-even) rounding, per-`(tenant, currency)` scale registry, deterministic residual assignment, over-range hard error. | +| `cpt-cf-bss-ledger-fr-tenant-isolation-posting` | SecureORM scopes every query to the caller's tenant (MVP); no-mixed-payer-tenant enforced at commit; no-mixed-legal-entity is structural (`legal_entity_id` on `journal_entry` only). | +| `cpt-cf-bss-ledger-fr-multi-axis-attribution` | `journal_line` carries `payer_tenant_id` / `seller_tenant_id` / `resource_tenant_id` axes; payer resolution runs upstream, the ledger records and enforces the structural invariants. | +| `cpt-cf-bss-ledger-fr-account-classes` | `account_class` enum declared centrally in final form; a class is declared here and activated by its owning handler. | +| `cpt-cf-bss-ledger-fr-account-lifecycle-posting` | GL-account `lifecycle_state` gate (`ACCOUNT_CLOSED`) plus payer-level `payer_state` gate (`PAYER_CLOSED`); closure with open/positive balance requires dual approval and an audit marker. | +| `cpt-cf-bss-ledger-fr-accounting-periods-close` | `FiscalPeriodGuard` pins `fiscal_period FOR SHARE` and asserts `OPEN` inside the post txn; minimal OPEN→CLOSED transition + pre-close gate ship in MVP; full framework in reconciliation-export. | +| `cpt-cf-bss-ledger-fr-audit-retrieval` / `cpt-cf-bss-ledger-fr-immutable-audit-logs` | Who/when/source/correlation retrievable from `journal_entry`; PII lives in the secured audit store keyed on `entry_id` (audit-immutability-observability). | +| `cpt-cf-bss-ledger-fr-ar-tie-out` | Daily `TieOutJob` recomputes every balance grain from `journal_line`, matches exactly, re-checks no-negative and per-entry zero-sum independently, and blocks close on variance/open exceptions. | + +#### NFR Allocation + +| NFR ID | NFR Summary | Allocated To | Design Response | Verification Approach | +|--------|-------------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-ledger-nfr-posting-performance` | Read p95 ≤ 200 ms; write p95 ≤ 500 ms; ≥ 2,000 invoices/min; ≤ 60 min/100k | `PostingService`, `BalanceProjector`, balance caches | One ACID txn of ~6–12 single-row indexed ops; single indexed cache-row reads (no aggregation); pipelined payer-partitioned workers | Load tests (§3.7 testing); `ar_payer_balance` hot-row ceiling gates commit (B3, Mode S) | +| `cpt-cf-bss-ledger-nfr-availability` | ≥ 99.9% | Deployment topology | Multi-AZ Postgres; no external blocking dependency on the post path (C3) | E2E / operational failover scenario | +| `cpt-cf-bss-ledger-nfr-tamper-evidence-cadence` | Tamper-evidence cadence | `TamperEvidence` seam, ChainWriter/Verifier | Append-only role + triggers; reserved `row_hash`/`prev_hash`; pluggable hash-chain (Mode S in MVP) | See audit-immutability-observability | +| `cpt-cf-bss-ledger-nfr-data-residency` | Data residency | Deployment topology | One PostgreSQL cluster per residency cell; `period_id` range partitioning pins residency | Operational | +| `cpt-cf-bss-ledger-nfr-rto-rpo` | RTO/RPO | Deployment topology | Multi-AZ Postgres with replicas; posting path never leaves the primary | Operational / DR drill | + +Status: `cpt-cf-bss-ledger-nfr-posting-performance` targets are **committed as v1 SLOs** (B11, 2026-06-10); only the single-tenant hot-row ceiling (B3) remains an engineering gate — see §3.8. + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-ledger-adr-book-ownership-predicate` | Only **selling** legal entities (tenant types `platform` + `partner`) own billing books, close periods, and hold an `export_target`; buyer-type tenants are `payer`/`resource` only. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-ledger-tech-stack` + +```text +Handler slices (modules) invoice-post · payments · adjustments · recognition · fx · audit · recon + │ (in-process data-access API: postBalancedEntry / applyBalanceDeltas / …) + ▼ +Posting Engine (Foundation) PostingService · IdempotencyGate · MoneyModule · BalanceProjector + FiscalPeriodGuard · Partition automation · Provisioning · TamperEvidence seam + │ + ▼ +PostgreSQL journal_entry / journal_line (truth) · balance caches · reference data +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Presentation | REST intake behind the inbound API gateway (provisioning endpoint here; handler post/read surfaces in feature docs); RFC 9457 problem responses; OAuth 2.0 | Rust, REST/OpenAPI, inbound API gateway (C4) | +| Application | Handler modules build balanced lines and call the Foundation API; each is a bounded feature | Rust modules in the `billing-ledger` monolith | +| Domain | The Foundation posting engine, invariants, total lock order, money model | Rust; GTS + Rust domain structs | +| Infrastructure | Append-only journal, derived caches, reference tables, outbox, partition automation | PostgreSQL (single primary + replicas), SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Hybrid append-only journal + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-principle-hybrid-append-only-journal` + +`journal_line` is the immutable source of truth; balances are a **derived, rebuildable cache**, never authoritative; a daily tie-out proves the cache against the lines. A rebuild reads one MVCC snapshot ordered by a commit-visible total order, writes a shadow table, then atomically swaps, so live reads never observe a half-rebuilt state. + + +#### One ACID transaction per entry + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-principle-single-posting-transaction` + +`PostingService.post(entry)` executes **one** PostgreSQL transaction at READ COMMITTED. Per-document atomicity is the rollback unit: a failed post leaves no partial posted state. SERIALIZABLE is deliberately avoided (retry storms on the hot path); correctness comes from a total, fixed lock-acquisition order plus DB constraints. + +#### Foundation owns the seam, handlers own the behavior + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-principle-foundation-seam-not-behavior` + +The Foundation provides the engine seam (post, project balances, guard invariants) and never a domain behavior. Every domain flow (leg shape, account mapping, recognition timing, FX conversion, refund clearing) lives in a handler feature that posts through the data-access API. + +#### BalanceProjector is the sole balance writer + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-principle-sole-balance-writer` + +The projector is the **only** writer of the balance caches and owns the total lock order: a caller hands it a set of deltas and the projector sorts them into the canonical lock key before any upsert, so deadlock-freedom is a property of one module, not a convention. Direct balance-table DML is `REVOKE`d from the app role and asserted by an architecture test, so a new slice physically cannot lock out of order. + +#### Naming discipline (one term per idea) + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-principle-naming-discipline` + +One primary term per concept, CI-enforced (§4.1): the posted subledger is `journal_entry`/`journal_line` and MUST NOT be called `LedgerEntry`; `UNALLOCATED` (unallocated cash) MUST NOT be conflated with `REUSABLE_CREDIT`; `SUSPENSE` is mapping-exception parking only; chargeback holds park in `DISPUTE_HOLD`, never `SUSPENSE`/`UNALLOCATED`. + +### 2.2 Constraints + +#### Tenant isolation via SecureORM (C1) + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-constraint-secureorm-tenant-isolation` + +All multi-tenant tables MUST be tenant-scoped. In MVP this is enforced at the data-access layer via the platform SecureORM (every query scope-bound to the caller's tenant); DB-level PostgreSQL RLS keyed on `current_setting('app.tenant_id')` is the eventual hardening target but is **not** an MVP migration requirement — the shipped migrations declare no `CREATE POLICY`. + +**ADRs**: `cpt-cf-bss-ledger-adr-book-ownership-predicate` + +#### Backwards-compatible migrations (C2) + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-constraint-backwards-compatible-migrations` + +Schema migrations MUST be backwards compatible; additive columns are allowed. This is why the tamper-evidence columns (`row_hash`/`prev_hash`) are reserved up front (A3) — though adding them later would itself be a permitted additive migration. + +#### PostgreSQL is the store, no bus on the posting path (C3) + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-constraint-postgres-no-bus-on-posting-path` + +Single primary + replicas; **no external event store / streaming bus on the posting path**. The success-event outbox relay runs off the posting path (at-least-once, async). + + +#### API exposure via the inbound API gateway (C4) + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-constraint-api-gateway-exposure` + +API exposure follows the inbound API gateway pattern; REST, JSON, OAuth 2.0, tenant from the authenticated context, versioned under `/v1`. + +**Blocker assumptions** (recorded with the PRD's draft direction; each confirmed by PM/Finance): A2 NFR latency/throughput (resolved via B11 — v1 SLOs, only B3 hot-row ceiling open); A3 tamper-evidence mechanism (append-only + reserved hash columns; mechanism owned by audit-immutability-observability); A4 money column type (`BIGINT` minor units, keep BIGINT, high-precision-beyond-headroom currencies out of scope); A6 material-backdating threshold (default 5 business days, range [1..30], no silent clamp). + +## 3. Technical Architecture + +### 3.1 Domain Model + +**Technology**: PostgreSQL tables; GTS + Rust domain structs. + +**Core Entities**: + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-entity-journal` + +Truth = `journal_entry` + `journal_line`; `account_balance` / `ar_invoice_balance` / `ar_payer_balance` / `tax_subbalance` / `unallocated_balance` / `reusable_credit_subbalance` are derived caches; the rest is reference data. The Foundation owns these shared core tables; handler slices own their own domain tables (see each feature doc) and never `ALTER` these. + +```mermaid +erDiagram + JOURNAL_ENTRY ||--|{ JOURNAL_LINE : contains + TENANT_ACCOUNT ||--o{ JOURNAL_LINE : "posted to" + JOURNAL_ENTRY ||--o| JOURNAL_ENTRY : reverses + FISCAL_PERIOD ||--o{ JOURNAL_ENTRY : "assigned to" + TENANT_ACCOUNT ||--o| ACCOUNT_BALANCE : "cached by" + TENANT_ACCOUNT ||--o{ AR_INVOICE_BALANCE : "cached by" + TENANT_ACCOUNT ||--o{ AR_PAYER_BALANCE : "cached by" + TENANT_ACCOUNT ||--o{ TAX_SUBBALANCE : "cached by" + IDEMPOTENCY_DEDUP ||--o| JOURNAL_ENTRY : "resolves to" +``` + +| Entity | Description | Kind | +|--------|-------------|------| +| `journal_entry` | Append-only posted-journal header: `entry_id`, `tenant_id`, `legal_entity_id`, `period_id` (YYYYMM partition key), `entry_currency`, `source_doc_type`, `source_business_id`, `reverses_entry_id`/`reverses_period_id` (reversals only), `posted_at_utc`, `effective_at`, `origin` (SYSTEM\|USER), `posted_by_actor_id` (no PII), `correlation_id`, `rounding_evidence`, `created_seq`, reserved `row_hash`/`prev_hash` (A3). | Truth | +| `journal_line` | Append-only posted line, **sole source of truth**: `line_id`, `entry_id`, `tenant_id`, `period_id`, `payer_tenant_id`, `seller_tenant_id` (nullable), `resource_tenant_id` (nullable, showback), `account_id`, `account_class` (snapshot), `gl_code`, `side` (DR\|CR), `amount_minor` (CHECK > 0), `currency`, `currency_scale`, `invoice_id`, `due_date` (AR lines), `revenue_stream`, `mapping_status` (RESOLVED\|PENDING), `functional_amount_minor`/`functional_currency` (nullable, native), tax dimensions (`tax_jurisdiction`/`tax_filing_period`/`tax_rate_ref`, TAX_PAYABLE lines only), reserved `legal_entity_id`, item traceability (`invoice_item_ref`/`sku_or_plan_ref`/`price_id`/`pricing_snapshot_ref`/`po_allocation_group`), `credit_grant_event_type` (REUSABLE_CREDIT lines only). | Truth | +| `tenant_account` | Chart-of-accounts row: `account_id`, `tenant_id`, `legal_entity_id`, `account_class`, `currency`, `revenue_stream` (nullable), `normal_side` (DR\|CR), `may_go_negative`, `lifecycle_state` (OPEN\|CLOSED — GL-account state only). | Reference | +| `account_balance` | Derived cache per `(tenant, account, currency)`: normal-side-positive `balance_minor`, `account_class`/`normal_side` denormalized, `last_entry_seq`, `version`. | Cache | +| `ar_invoice_balance` | Derived AR grain per `(tenant, payer, account, invoice)`: `balance_minor`, `currency` (denormalized), `original_posted_at`, `due_date` (aging basis — days past due). | Cache | +| `ar_payer_balance` | Derived payer-aggregate AR grain per `(tenant, payer, account, currency)`: `balance_minor`. | Cache | +| `tax_subbalance` | Derived Tax grain per `(tenant, account, jurisdiction, filing-period)`: `balance_minor` (MAY go negative during reversal). | Cache | +| `unallocated_balance` / `reusable_credit_subbalance` | Counter sub-balance caches; carry `functional_balance_minor`/`functional_currency` natively; grain meaning and draw order owned by the activating handler. | Cache | +| `idempotency_dedup` | Business-key at-most-once gate and idempotent-replay source: `(tenant_id, flow, business_id)` PK, `payload_hash`, `result_entry_id` (set pre-COMMIT), `posted_at_utc`, `status`, `retain_until`. | Reference | +| `fiscal_period` | Period state per `(tenant_id, legal_entity_id, period_id)`: `fiscal_tz`, `status` (OPEN\|CLOSED). | Reference | +| `payer_state` | Payer lifecycle per `(tenant_id, payer_tenant_id)`: `lifecycle_state` (OPEN\|CLOSED), `closed_with_open_balance`, `approved_by`, `changed_at`. **Absence = OPEN**. | Reference | +| `currency_scale_registry` | Per-`(tenant, currency)` minor-unit scale: `minor_units`, `source`; ISO-4217 defaults + non-ISO/crypto overrides; scale immutable once a posting exists. | Reference | +| `tenant_posting_lock` | Tenant-termination no-new-post flag per `tenant_id`: `locked`, `reason_code`, set/cleared audit columns. **Absence = not locked**. | Reference | +| `event_outbox` | Success-event outbox written in the post txn: `outbox_id`, `tenant_id`, `event_kind`, `payload` (PII-free), `created_at`, `dispatched_at` (nullable). | Reference | + +**Relationships**: `journal_entry` → `journal_line` (contains, 1:N); `journal_entry` → `journal_entry` (reverses, at-most-once); `fiscal_period` → `journal_entry` (assigned to); `tenant_account` → balance caches (cached by). The period link uses the natural key `(tenant_id, legal_entity_id, period_id)` — no surrogate `fiscal_period_id`. Reversal lineage is **not** a DB self-FK (a self-FK on a partitioned table blocks `DETACH`/`DROP PARTITION`); the reversal path validates the original exists instead. + + The `account_class` enum is declared centrally here in FINAL form (full enum in §3.7); a class is activated by the owning handler. None of the declared-only classes are posted by the Foundation itself — it persists whatever balanced lines a handler hands it. + +### 3.2 Component Model + +```mermaid +flowchart TB + APIIN["Data-access API (in-process) + provisioning endpoint (REST, RFC 9457)"] + + subgraph engine[Posting Engine — Foundation] + PS["PostingService (one ACID txn/entry)"] + IDEM["IdempotencyGate"] + MONEY["MoneyModule (minor-units, banker's rounding)"] + PROJ["BalanceProjector (sole balance writer)"] + PG["FiscalPeriodGuard"] + PART["Partition automation"] + PROV["Provisioning"] + TAMP["TamperEvidence seam"] + end + + TIE["TieOutJob (daily + incremental)"] + AUDIT["Secured audit / alarm writer (separate committed txn)"] + OBX["Success-event outbox + relay"] + CHAIN["ChainWriter / Verifier (audit slice)"] + DB[("PostgreSQL — journal truth · balance caches · reference")] + + APIIN --> PS + PS --> IDEM + PS --> PG + PS --> PROJ + PS --> TAMP + PS --> DB + PROJ --> DB + PS --> OBX + PS -. conflict / invariant alarm .-> AUDIT + PART --> DB + PROV --> DB + TIE --> DB + CHAIN --> DB +``` + +#### PostingService + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-posting-service` + +**Why it exists**: the single per-entry transaction that all handlers post through, so every flow shares one atomicity boundary, one lock order, and one set of commit-time invariants. + +**Responsibility scope**: opens one READ COMMITTED transaction; runs the pre-transaction gates (tenant-termination `tenant_posting_lock` → `TENANT_POSTING_LOCKED`; entry-size bound ≤ 1,000 lines → `LEDGER_ENTRY_TOO_LARGE`); then the normative sequence: (1) idempotency claim (`INSERT... ON CONFLICT DO NOTHING`), (2) build + insert `journal_entry`/`journal_line`, (3) period guard (`fiscal_period FOR SHARE`, assert OPEN), (4) lifecycle asserts (`ACCOUNT_CLOSED`, `PAYER_CLOSED`, `CREDIT_RESIDUAL_UNDISPOSED`) + balance projection in the single deterministic lock order, (5) no-negative guard, (6) finalize dedup (`result_entry_id` pre-COMMIT), (7) COMMIT fires the deferrable leaf-partition constraint trigger. Success events go to the outbox that commits with the post; conflicting-payload captures and invariant alarms are written on a separate committed transaction so they survive an aborting post. + +**Responsibility boundaries**: builds no lines and resolves no accounts (handlers do); embeds no domain policy. + +**Related components**: `cpt-cf-bss-ledger-component-idempotency-gate`, `cpt-cf-bss-ledger-component-fiscal-period-guard`, `cpt-cf-bss-ledger-component-balance-projector` — calls; `cpt-cf-bss-ledger-component-outbox-relay` — publishes to. + +#### IdempotencyGate + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-idempotency-gate` + +At-most-once + idempotent-replay (AC #19), sourcing the prior reference from `idempotency_dedup`, never a ledger scan. Replay (same key, matching `payload_hash`) returns the prior reference; conflict (same key, different payload) hard-errors `IDEMPOTENCY_PAYLOAD_CONFLICT` and captures the conflicting payload to the secured store on a separate, bounded-retried, fail-closed transaction. `payload_hash` covers only canonical financial content (business key, `effectiveAt`, per-line resolved mapping inputs / `amount_minor` / `currency` / `side` / `payer_tenant_id` / classification / tax dimensions / item refs, plus tax totals; lines sorted; amounts as minor-unit integers); transport/envelope fields excluded. Replay-under-concurrency: the loser blocks on the in-progress dedup row, then replays (winner commits) or posts (winner aborts). Retention ≈ 7 years for the business-key row. + +#### MoneyModule + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-money-module` + +Single centralized money implementation so every handler rounds identically. `BIGINT` minor units (A4); scale from the currency-scale registry (ISO-4217 defaults USD/EUR = 2, JPY = 0, BHD = 3, plus tenant non-ISO/crypto overrides); scale **immutable once any posting exists** for `(tenant, currency)` (`CURRENCY_SCALE_LOCKED`). Banker's (half-to-even) rounding by default; tenant override only via audited effective-dated policy; internal compute may carry +4 decimals, never retained. Residual-minor-unit assignment is a pure deterministic function: "last segment" = greatest `(period_id, segment_no)` then canonical-last built line by `(account_class rank, line_id)`; "largest open invoice" tie-broken by `(amount_minor DESC, invoice_id ASC)`. Over-range → `AMOUNT_OUT_OF_RANGE` (AC #16). + +#### BalanceProjector + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-balance-projector` + +The **sole** writer of the balance caches; owns the §PostingService step-4 total lock order by sorting supplied deltas into the canonical lock key `(table_rank, tenant_id, account_id, currency, payer_tenant_id, invoice_id)` before any upsert. `balance_minor` stored normal-side-positive (`delta = +amount_minor` when `line.side == account.normal_side`, else `-amount_minor`); updates via per-grain `INSERT... ON CONFLICT DO UPDATE... RETURNING`, which creates-on-first-touch and locks atomically (no lost-update / duplicate-key race). Maintains `account_balance` / `ar_invoice_balance` / `ar_payer_balance` / `tax_subbalance` and the counter sub-balances (`applyCounterDelta`, `>= 0` guard + handler-supplied underflow disposition hook). Enforces no-negative on the guarded set at both AR grains and `account_balance`; **Tax payable MAY go negative** (excluded from the aggregate check, guarded at its sub-grain). Role-sign rule: a normal-side-credit class going negative emits a **Warn** alarm (no rollback). + +**Related components**: `cpt-cf-bss-ledger-component-posting-service` — invoked by; `cpt-cf-bss-ledger-component-tieout-job` — cross-checked by. + +#### FiscalPeriodGuard + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-fiscal-period-guard` + +Posting timestamp stored in UTC; period assignment uses the tenant's fiscal-calendar timezone (AC #20). Joins `fiscal_period` by `(tenant_id, legal_entity_id, period_id)` inside the post txn, locks `FOR SHARE`, asserts OPEN (`pinOpenPeriod`). Routine post into a closed period → `PERIOD_CLOSED`; posting an effective date into a closed period requires a formal reopen → re-close under dual control, not a per-posting approval. Clock skew > ±15 min warns, > ±24 h rejects (`CLOCK_SKEW_QUARANTINE`) with re-submission via the material-backdating exception path. Tradeoff: `FOR SHARE` held through COMMIT can let a period close wait on the longest in-flight post; mitigation is close-quiesce/drain, and `ledger_period_close_wait_seconds` is a metric. + +#### TieOutJob + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-tieout-job` + +The PRD-mandated daily reconciliation (AC #7): recompute **every** balance grain from `journal_line` and compare to the cache — must match **exactly (zero tolerance)**. All-grain recompute catches a credit-class projector sign/delta drift invisible to an AR-only tie-out. Independent per-entry re-check recomputes `SUM(DR) − SUM(CR)` / `COUNT(line)` / `COUNT(DISTINCT payer_tenant_id)` per entry — a separate code path from the commit trigger, catching an entry committed into a partition whose trigger was missing/disabled/buggy. Blocks period close on (a) any nonzero variance, (b) any unresolved `mapping_status = PENDING`, or (c) any per-entry re-check failure. Incremental watermark between daily runs re-projects critical classes (AR, Contract-liability) on a short interval via a visibility-aware `created_seq` watermark. + +#### TamperEvidence seam, ChainWriter and Verifier + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-tamper-evidence-seam` + +The Foundation asserts the store is append-only and exposes a pluggable tamper-evidence seam (A3): `created_seq` gives append-assignment order; `row_hash`/`prev_hash` are declared and reserved (`bytea NULL`) so a hash-chain attaches later as an additive change. **Caveat**: `created_seq` is monotonic by *assignment*, not commit order, and gaps on rollback — so any advancing `created_seq > N` cursor (ERP export watermark, incremental tie-out) MUST be **visibility-aware** (bounded by `pg_snapshot_xmin(pg_current_snapshot)`); the outbox relay is exempt (drains all visible undispatched rows). The mechanism choice, ChainWriter/Verifier, secured audit store internals, GDPR erasure, and retention are owned by the **audit-immutability-observability** feature; see [features/audit-immutability-observability.md](./02-audit-immutability-observability.md). + +#### Partition automation and Provisioning + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-provisioning` + +Partition automation attaches the deferrable balance trigger to every new monthly leaf partition; partition creation + trigger attach run in **one DDL transaction**, so a trigger-less leaf partition is unrepresentable. Provisioning (`provisionLegalEntity`, §3.3) seeds a **seller** legal-entity's reference rows (chart of accounts, non-ISO/crypto currency scales, fiscal-calendar config + initial OPEN `fiscal_period` and its partition) in one idempotent, additive transaction; subsequent periods open via internal automation. Only seller legal-entities are provisioned (§4.4); buyer tenants need no setup (`payer_state` absence = OPEN). Provisioning MUST complete before the first post — the ledger only asserts these at post time, never creates reference rows implicitly. + +#### Handler slices (post through the Foundation) + +Each handler is a module of the monolith that builds balanced lines and calls the data-access API; its domain design and owned tables live in its feature doc. + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-invoice-posting-handler` — invoice-post leg shape (DR AR / CR Revenue + Contract-liability + Tax), account mapping, suspense routing, AR aging, full reversal. See [features/invoice-posting.md](./01a-invoice-posting.md). +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-payments-allocation-handler` — settlement, allocation (Mode A), chargebacks/disputes, wallet. See [features/payments-allocation.md](./03-payments-allocation.md). +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-asc606-recognition-handler` — recognition schedules + recognition runs, ScheduleBuilder. See [features/asc606-recognition.md](./04-asc606-recognition.md). +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-adjustments-notes-refunds-handler` — credit/debit notes, refunds, manual governance. See [features/adjustments-notes-refunds.md](./05-adjustments-notes-refunds.md). +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-fx-multicurrency-handler` — functional currency, realized FX, rate snapshots. See [features/fx-multicurrency.md](./06-fx-multicurrency.md). +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-audit-observability-handler` — tamper chain, freeze, secured store, PII/erasure, alarm catalog. See [features/audit-immutability-observability.md](./02-audit-immutability-observability.md). +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-component-reconciliation-export-handler` — reconciliations, ERP export, period close gate. See [features/reconciliation-export.md](./07-reconciliation-export.md). + +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-interface-data-access-api` + +The Foundation exposes an **in-process data-access API** to handlers; they never touch truth or cache tables directly (enforced by `REVOKE`, §3.7). The only externally-exposed HTTP endpoint is the provisioning seed; the handler-facing post/reverse/read HTTP surfaces are owned by the handler slices and funnel into this API. All money fields are `{ "amountMinor": , "currency": "USD", "scale": 2 }` — never floats. See PRD interfaces `cpt-cf-bss-ledger-interface-posting-intake` and `cpt-cf-bss-ledger-interface-ledger-inquiry`. + +**Data-access API (in-process)**: + +| API | Responsibility | +|-----|----------------| +| `postBalancedEntry(lines[], flow, businessKey)` | Single post entry point: opens the ACID transaction, claims idempotency, inserts handler-supplied balanced lines, runs lifecycle/period gates, drives the projector, commits under the leaf-partition zero-sum trigger. Returns the posting reference (or prior reference on replay). | +| `applyBalanceDeltas(deltas[])` | The sole balance writer: sorts deltas into the canonical lock key, upserts each grain, enforces no-negative on the guarded set. | +| Read-then-write ordering — via `SERIALIZABLE`/SSI, no explicit locked-read op | There is **no** distinct read-under-lock-order operation. The whole post runs in one `SERIALIZABLE` transaction; a read-then-write caller is kept correct by Postgres SSI — an overlapping write-write conflict aborts the loser, which retries the whole post — rather than by pre-locking grain rows in a canonical order. Reads take no row lock. | +| `idempotencyClaim(flow, businessId, payloadHash)` | The step-1 at-most-once claim + replay resolution against `idempotency_dedup`. | +| `pinOpenPeriod(tenant, legalEntity)` | The in-transaction `fiscal_period FOR SHARE` + OPEN assertion. | +| `provisionLegalEntity` | Seeds a seller legal-entity's reference rows; idempotent + additive; surfaced over HTTP below. | +| `applyCounterDelta(counterRef, signedDelta)` | Generic signed-delta apply against a counter sub-balance with `>= 0` CHECK + handler-supplied underflow disposition hook. | + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-interface-provisioning-endpoint` + +**Provisioning endpoint** (REST, behind the inbound API gateway): + +| Method | Path | Description | Stability | +|--------|------|-------------|-----------| +| `POST` | `/v1/ledger/legal-entities/{legalEntityId}/provisioning` | Seller billing-onboarding: one atomic, idempotent, additive seed — chart of accounts (`lifecycle_state = OPEN`), non-ISO/crypto `currency_scale_registry` rows, fiscal-calendar config + initial `fiscal_period` and its partition. Body `{ accounts[], currencyScales[], fiscalCalendar{timezone, granularity, fyStart} }`; `billing-setup` scope. Idempotent + additive on per-row natural keys. | stable | + +**Handler HTTP surfaces** (defined in feature docs, not here): invoice post/reverse and AR aging (invoice-posting); settlement/allocation/chargeback (payments-allocation); credit/debit notes, refunds (adjustments-notes-refunds); recognition runs (asc606-recognition); FX revaluation (fx-multicurrency); audit-pack/erasure/freeze (audit-immutability-observability); reconciliation/export/period-close (reconciliation-export). + +**Problem responses (RFC 9457)**: `application/problem+json` with `{ type, title, status, detail, instance, code, tenantId? }`. Engine-invariant `code`s: `LEDGER_ENTRY_UNBALANCED` (422), `LEDGER_ENTRY_EMPTY` (422), `MIXED_PAYER_TENANT` (422), `MISSING_PAYER` (422), `MIXED_LEGAL_ENTITY` (422, reserved), `IDEMPOTENCY_PAYLOAD_CONFLICT` (409), `NEGATIVE_BALANCE_VIOLATION` (409), `PERIOD_CLOSED` (409), `ACCOUNT_CLOSED` (409), `PAYER_CLOSED` (422), `CREDIT_RESIDUAL_UNDISPOSED` (422), `CURRENCY_SCALE_LOCKED` (409), `CLOCK_SKEW_QUARANTINE` (422), `TENANT_POSTING_LOCKED` (409), `LEDGER_ENTRY_TOO_LARGE` (413), `AMOUNT_OUT_OF_RANGE` (422). Domain-specific codes are owned by handler slices. + +### 3.4 Internal Dependencies + +All inter-gear communication is call-driven (§4.3): upstream modules **call the ledger's REST endpoints**; the ledger consumes no bus and has no inbound consumer/dispatcher. + +| Dependency Gear | Interface Used | Purpose | +|-----------------|----------------|---------| +| Billing Orchestration / Invoice | REST call to the invoice-post endpoint | Delivers the materialized posted invoice manifest for posting and the recon control feed | +| Rating / Subscriptions (via adapter) | REST call | The Rating-side adapter maps `ChargeAdjustment` → `POST /credit-notes`; the ledger never re-rates | +| Tax Engine | Inline on the posting request | `TaxBreakdown` carried on the post call (not a separate ingestion) | +| Payments / PSP | REST call | `PaymentSettled` / dispute / settlement-return → payments-allocation endpoints; approved `Refund` → `POST /refunds` | +| AMS / billing-setup | `provisionLegalEntity` / provisioning endpoint | Owns the `tenant ↔ commercial-account ↔ legal-entity` mapping and triggers seller provisioning; owns functional currency | +| Catalog / Contracts | Snapshots carried on posts | `glCode` + tax-category snapshots, PO tags, SSP overrides (consumed, not called back) | + +**Dependency rules**: no circular deps; always via versioned contracts / SDK clients; no cross-category sideways deps except through contracts; only integration/adapter gears talk to external systems; `SecurityContext` propagated across all in-process calls. + +### 3.5 External Dependencies + +| Dependency | Interface Used | Purpose | +|------------|----------------|---------| +| ERP / GL (downstream) | `cpt-cf-bss-ledger-contract-erp-export` — idempotent export | Period-close-gated export of posted entries per selling legal entity (reconciliation-export owns the cursor and export records) | +| PostgreSQL | SecureORM / SQL | The store: append-only journal, derived caches, reference tables, outbox (C3) | + +### 3.6 Interactions and Sequences + +#### Invoice post through the Foundation + +**ID**: `cpt-cf-bss-ledger-seq-post-through-foundation` + +**Use cases**: `cpt-cf-bss-ledger-usecase-ledger-inquiry` (balance visibility after post) + +**Actors**: `cpt-cf-bss-ledger-actor-billing-orchestration` + +```mermaid +sequenceDiagram + participant H as invoice-posting handler + participant PS as PostingService + participant IG as IdempotencyGate + participant PG as FiscalPeriodGuard + participant BP as BalanceProjector + participant DB as PostgreSQL + participant OBX as Outbox relay + H->>PS: postBalancedEntry(lines, flow, businessKey) + PS->>IG: idempotencyClaim (INSERT ON CONFLICT DO NOTHING) + alt conflict (same key) + IG-->>H: prior reference (replay) or 409 IDEMPOTENCY_PAYLOAD_CONFLICT + else claimed + PS->>DB: INSERT journal_entry + journal_line + PS->>PG: pinOpenPeriod (FOR SHARE, assert OPEN) + PS->>BP: applyBalanceDeltas (canonical lock order, upsert, no-negative) + PS->>DB: UPDATE idempotency_dedup SET result_entry_id (pre-COMMIT) + PS->>DB: COMMIT (leaf-partition zero-sum trigger fires) + PS->>OBX: billing.ledger.entry.posted (commits with post) + PS-->>H: posting reference + end +``` + +#### Idempotent replay under concurrency + +**ID**: `cpt-cf-bss-ledger-seq-idempotent-replay` + +**Actors**: `cpt-cf-bss-ledger-actor-billing-orchestration` + +The loser on a same-`business_id` conflict blocks on the in-progress dedup row; when the winner COMMITs it reads a populated `result_entry_id` and returns the prior reference; when the winner ABORTs, the loser's own `INSERT` succeeds and it takes the post path (not replay). + +#### Reversal + +**ID**: `cpt-cf-bss-ledger-seq-reversal` + +Strict line-negation: side-flipped copy with positive amounts; the partial `UNIQUE` on `(tenant, reverses_period_id, reverses_entry_id)` guarantees at-most-once reversal; reversals post at current effective time. The domain reversal flow is invoice-posting; the Foundation enforces the append-only / line-negation shape. + +#### Provisioning seed + +**ID**: `cpt-cf-bss-ledger-seq-provisioning-seed` + +**Actors**: `cpt-cf-bss-ledger-actor-billing-orchestration` + +AMS/billing-setup calls `POST /v1/ledger/legal-entities/{id}/provisioning`; the Foundation seeds chart of accounts, currency scales, fiscal-calendar config, and the initial OPEN period + partition in one transaction; a re-call is additive (existing rows no-op). + +#### Tie-out and close gate + +**ID**: `cpt-cf-bss-ledger-seq-tieout-close-gate` + +**Use cases**: `cpt-cf-bss-ledger-usecase-reconciliation-review` + +**Actors**: `cpt-cf-bss-ledger-actor-revenue-assurance` + +Daily `TieOutJob` recomputes all grains from `journal_line`, runs the independent per-entry re-check, and blocks period close on any nonzero variance, unresolved `PENDING` mapping, or per-entry failure; variance tickets Revenue Assurance. + +### 3.7 Database Schemas and Tables + +- [ ] `p3` - **ID**: `cpt-cf-bss-ledger-db-foundation-schema` + +> **⚠️ v1 implementation status (design ↔ code reconciliation).** Three DDL-highlights items below are **NOT in v1 — deferred post-v1**, and the code reflects that: +> - **Range partitioning of `journal_entry` / `journal_line`** (by `period_id`) is **deferred**: v1 ships plain (non-partitioned) tables. Consequently the "per-leaf-partition" property of the zero-sum trigger, `DETACH`/`DROP PARTITION` retention pruning, and residency-by-partition are **not** realised in v1 (the zero-sum trigger runs table-level with identical semantics). Requires 7-year-retention / residency drivers to prioritise. +> - **`REVOKE UPDATE, DELETE` on the journal + balance-write `REVOKE` encapsulation** is **deferred**: v1 enforces immutability with the `BEFORE UPDATE OR DELETE` triggers only (and `BalanceProjector` as the sole writer *by convention* + architecture test), **without** the DB-role `REVOKE` layer. A privileged DB role can therefore still bypass at the SQL layer in v1 (noted as residual, §3.9 hot-row block). Adding the `REVOKE` grants is post-v1 hardening. +> - **`event_outbox`** (and the success-event relay) is **deferred** with the parked event layer (no event broker in v1): the outbox table + relay are not built, so success events are not emitted transactionally in v1. See §3.6 / the event-layer parking. +> +> The schema is otherwise stated in its **FINAL** form so handler slices never `ALTER` it — every cross-slice column and guard is **Foundation-owned from the start** (§4.2). Handler-owned tables (`payment_settlement`, `recognition_schedule`, `credit_note`/`debit_note`, `refund`, `rate_snapshot`, `pending_event_queue`, `export_record`, `chain_state`, …) are defined in their feature docs. DDL highlights: + +- **Partitioning + composite keys.** *(v1: NOT partitioned — deferred post-v1; see the status note above. The composite keys below ship as designed.)* `journal_entry` / `journal_line` partitioned by `period_id` (YYYYMM range) for 7-year retention, residency pinning, tie-out pruning. PK `journal_entry (tenant_id, period_id, entry_id)`; `journal_line (tenant_id, period_id, line_id)` with `period_id` denormalized; FK `journal_line → journal_entry` on `(tenant_id, period_id, entry_id)`. Period link uses the natural key `(tenant_id, legal_entity_id, period_id)`. +- **Immutability.** *(v1: `BEFORE UPDATE OR DELETE` triggers ship; the `REVOKE` layer is deferred post-v1 — see the status note above.)* `REVOKE UPDATE, DELETE ON journal_entry, journal_line FROM ` + `BEFORE UPDATE OR DELETE` triggers that `RAISE EXCEPTION`. Corrections only via line-negation reversal. +- **Balance-write encapsulation.** *(v1: `BalanceProjector` is the sole writer by convention + architecture test; the `REVOKE` on direct balance-table DML is deferred post-v1.)* Direct `INSERT`/`UPDATE` on `account_balance`, `ar_invoice_balance`, `ar_payer_balance`, `tax_subbalance`, `unallocated_balance`, `reusable_credit_subbalance` is `REVOKE`d; the only granted write path is `BalanceProjector` (behind `applyBalanceDeltas`/`applyCounterDelta`), which sorts deltas into the total lock order — making the lock order structural. +- **`account_class` enum (declared centrally, activated by the owning handler):** `AR, CASH_CLEARING, UNALLOCATED, REUSABLE_CREDIT, CONTRACT_LIABILITY, REVENUE, TAX_PAYABLE, SUSPENSE, DISPUTE_HOLD, REFUND_CLEARING, CONTRA_REVENUE, GOODWILL, DISPUTE_LOSS_EXPENSE, PSP_FEE_EXPENSE, FX_GAIN_LOSS, FX_UNREALIZED`. The `source_doc_type`/`flow` enums are likewise declared centrally with all handler values: `INVOICE_POST, REVERSAL, MAPPING_CORRECTION, PAYMENT_SETTLE, PAYMENT_ALLOCATE, CHARGEBACK, CREDIT_APPLY, SETTLEMENT_RETURN, MANUAL_ADJUSTMENT, SCHEDULE_BUILD, RECOGNITION, CREDIT_NOTE, DEBIT_NOTE, REFUND, FX_REVALUATION, FX_REVAL_REVERSAL` — so a handler never needs an additive `ALTER TYPE`. +- **Balanced + ≥1-line + single-payer.** `CONSTRAINT TRIGGER... DEFERRABLE INITIALLY DEFERRED` (AFTER INSERT) on **each leaf partition** of `journal_entry` (not the parent). Partition `CREATE TABLE... PARTITION OF` + `CREATE CONSTRAINT TRIGGER` in **one DDL transaction** — a trigger-less leaf partition is unrepresentable. Asserts `SUM(DR) = SUM(CR)` exactly per `(currency, currency_scale)`, `COUNT(line) ≥ 1`, single `payer_tenant_id`; anchoring on the entry row makes the zero-line case fire. Single-legal-entity is structural (`legal_entity_id` on `journal_entry` only); `journal_line.legal_entity_id` reserved nullable. +- **No-negative (conditional) — full guarded set from the start.** `CHECK (account_class NOT IN ('AR','CASH_CLEARING','UNALLOCATED','CONTRACT_LIABILITY','DISPUTE_HOLD','REFUND_CLEARING') OR balance_minor >= 0)` on `account_balance` (incl. `DISPUTE_HOLD` per Slice 2, `REFUND_CLEARING` per Slice 3, now Foundation-owned); `CHECK (balance_minor >= 0)` on `ar_invoice_balance`, `ar_payer_balance`, `unallocated_balance`, `reusable_credit_subbalance`. `tax_subbalance` has **no** aggregate ≥ 0 check (guarded at its grain against the filing window). +- **Amount sign.** `CHECK (amount_minor > 0)` on `journal_line`; relaxed for functional-only lines: `CHECK (amount_minor > 0 OR (amount_minor = 0 AND functional_amount_minor IS NOT NULL))`. +- **(Slice 5, native) Functional-column balance check.** The leaf-partition trigger additionally asserts `SUM(DR functional_amount_minor) = SUM(CR functional_amount_minor)` exactly per `functional_currency` group on entries carrying functional amounts. Conversion policy owned by fx-multicurrency; the Foundation owns the columns + check. +- **Revenue-stream column.** `CHECK (account_class NOT IN ('REVENUE','CONTRACT_LIABILITY') OR revenue_stream IS NOT NULL)` (AC #26). +- **Single-currency-entry seam.** The trigger asserts every `journal_line.currency = entry_currency` unless a functional-only line; per-`(currency, currency_scale)` grouping is the multi-currency-ready seam, relaxed by fx-multicurrency. +- **Idempotency.** `PRIMARY KEY (tenant_id, flow, business_id)` on `idempotency_dedup`; `result_entry_id` populated before COMMIT. +- **CoA uniqueness.** `UNIQUE (tenant_id, legal_entity_id, account_class, currency, COALESCE(revenue_stream,'-'))`. +- **Reversal lineage.** `reverses_period_id` column; partial `UNIQUE (tenant_id, reverses_period_id, reverses_entry_id) WHERE reverses_entry_id IS NOT NULL`. Not a DB self-FK (would block `DETACH`/`DROP PARTITION`). +- **Currency-scale integrity.** Table `currency_scale_registry (tenant_id, currency, minor_units, source)`, PK `(tenant_id, currency)`, RLS-scoped. A non-ISO currency needs a row before its first posting (seeded by AMS provisioning); ISO resolves from defaults. Scale immutable once a posting exists (`CURRENCY_SCALE_LOCKED`). +- **Indexes.** `journal_line (tenant_id, account_id, currency)`; `journal_line (tenant_id, payer_tenant_id, invoice_id)`; `journal_entry (tenant_id, source_doc_type, source_business_id)`; `journal_entry (tenant_id, created_seq)`; `journal_line (tenant_id, invoice_id, invoice_item_ref)`; covering `journal_entry (tenant_id, entry_id) INCLUDE (period_id)`; partial `journal_entry (tenant_id, posted_at_utc, created_seq) WHERE row_hash IS NULL` (chain-writer scan); partial `event_outbox (created_at) WHERE dispatched_at IS NULL` (relay scan). +- **Money.** `amount_minor BIGINT` (A4); over-range guarded before the BIGINT bound; `functional_amount_minor BIGINT` native. +- **Tamper-evidence seam.** `journal_entry.row_hash bytea NULL`, `prev_hash bytea NULL`, `created_seq BIGSERIAL` (A3). PKs UUIDv7 for `entry_id`/`line_id`. +- **Tax dimensions.** `journal_line.tax_jurisdiction`, `tax_filing_period`, `tax_rate_ref` nullable; `CHECK (account_class <> 'TAX_PAYABLE' OR (tax_jurisdiction IS NOT NULL AND tax_filing_period IS NOT NULL))`. +- **Success-event outbox.** *(v1: NOT built — deferred post-v1 with the parked event layer; no event broker in v1, so success events are not emitted transactionally. See the status note above.)* `event_outbox (outbox_id, tenant_id, event_kind, payload jsonb PII-free, created_at, dispatched_at nullable)` written in the post txn; a separate async relay publishes per-tenant FIFO ordered by `created_seq` and stamps `dispatched_at`. Off the posting path (C3 holds). +- **Item traceability.** `journal_line.invoice_item_ref`, `sku_or_plan_ref`, `price_id`, `pricing_snapshot_ref`, `po_allocation_group` nullable; join the Slice 6 canonical hash list. +- **Credit-grant event type.** `journal_line.credit_grant_event_type` — two-sided `CHECK ((account_class = 'REUSABLE_CREDIT') = (credit_grant_event_type IS NOT NULL))`. +- **(Slice 5) Functional-column caches.** `unallocated_balance` and `reusable_credit_subbalance` carry `functional_balance_minor BIGINT` / `functional_currency char` natively. +- **AR aging basis.** `journal_line.due_date` (AR lines) + `ar_invoice_balance.due_date`; aging read computes days-past-due (handler-owned). +- **Payer lifecycle.** `payer_state (tenant_id, payer_tenant_id, lifecycle_state, closed_with_open_balance, approved_by, changed_at)`, PK `(tenant_id, payer_tenant_id)`, RLS-scoped; consulted only by payer-posting flows. **Absence = OPEN**. +- **Tenant posting lock.** `tenant_posting_lock (tenant_id, locked, reason_code, set_by, set_at, cleared_by, cleared_at)`, PK `tenant_id`, RLS-scoped. **Absence = not locked**; set/cleared only by tenant-termination workflow; distinct from the Slice 6 tamper `scope_freeze`. +- **Tenant scoping (C1).** Every table tenant-scoped via SecureORM (MVP); DB-level RLS is the eventual hardening target, not an MVP migration requirement. + +**Testing architecture** (per `design-standards.md`, correctness is the value of the ledger): Unit — `MoneyModule` (banker's rounding, ISO-4217 scale, residual determinism, over-range), reversal side-flip, period assignment in tenant fiscal tz. Integration (testcontainers Postgres) — append-only enforcement, deferrable leaf-partition trigger at COMMIT (balanced exact / zero-line rejected / mixed-payer rejected), conditional no-negative across the full guarded set, Tax-payable allowed negative at sub-grain, 1,001-line entry rejected, closed-payer `PAYER_CLOSED` while another flow still posts, `credit_grant_event_type` two-sided CHECK, role-sign Warn alarm, idempotency PK at-most-once, `result_entry_id` pre-commit, RLS isolation, closed-period `FOR SHARE` gate, `TENANT_POSTING_LOCKED`, open-exception blocks close, atomic partition provisioning (forced attach failure leaves no partition), all-grain tie-out catches injected credit-class drift, `CURRENCY_SCALE_LOCKED`, `currency ≠ entry_currency` rejected. API — RFC 9457 mapping per code, replay/conflict with durable audit capture surviving abort, provisioning idempotent + additive, audit-retrieval who/when/source/correlation (AC #8). E2E — provision → post → read → reverse → tie-out with no mocks. Concurrency — same-key posts (exactly one effect), two concurrent reversals (exactly one), first-touch upsert race, post vs period-close, `applyCounterDelta` underflow hook, architecture test that balance tables are mutated only through `BalanceProjector`. **Must NOT be mocked**: PostgreSQL constraints/triggers/RLS, the money type, the idempotency PK. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-ledger-topology-modular-monolith` + +One deployable service `billing-ledger` (modular monolith), two runtime roles from the same image: **`ledger-api`** (synchronous posting + reads) and **`ledger-workers`** (TieOutJob, chain writer/Verifier, ERP-export re-driver, outbox relay, out-of-order queue appliers — each singleton-per-scope via advisory locks). One PostgreSQL cluster **per residency cell** (data-residency NFR, Slice 6 G3); read replicas serve inquiry/recon reads; the posting path never leaves the primary. Multi-AZ Postgres backs the availability and RTO/RPO NFRs; there is no external blocking dependency on the post path (C3). + +**Known risks / hot-row ceiling (B3).** A single-tenant bill run credits the tenant's Revenue / Contract-liability rows + the `ar_payer_balance` payer-aggregate row on every invoice; the upsert row lock serializes these within a tenant. Revenue and Tax-payable are **shardable** (Revenue's only aggregate control is the Warn-only role-sign rule; Tax is guarded at the sub-grain), merged periodically. **Contract liability is the only truly non-shardable credit singleton** (hard no-negative aggregate gate), but it is inert at the engine level (a hot row only for recognition). So the **only active non-shardable hot row at the engine level is `ar_payer_balance`** — its ceiling MUST be load-tested with **Mode S enabled** before commit (target single-tenant peak 2,000/min). Fallback if below target (designed, unbuilt pending B3): per-worker partial sums on `ar_payer_balance` merged on a short interval, the payer-aggregate no-negative gate moving to the merge step while `ar_invoice_balance` keeps its per-invoice CHECK. Pre-chain immutability is app-level (`REVOKE` + triggers, bypassable by privileged DB roles); narrowed by shipping the Mode S tamper chain in MVP, residual restricted operationally. + +## 4. Additional Context + +### 4.1 Naming, Glossary Discipline and Module Alignment + +This is the canonical owner of naming/glossary discipline (was Slice 8; introduces no runtime component — it is CI-enforced discipline). One primary term per concept; synonyms stay prose-only labels for the **same** account class, never a second mechanism. + +| Idea | Primary term (use this) | Synonym (prose only) | Do not | +|------|-------------------------|----------------------|--------| +| ASC deferral / obligation bucket | **Contract liability** (`CONTRACT_LIABILITY`) | "deferred revenue" | use two different accounting meanings for the two phrases | +| Inbound funds not yet on specific invoices | **Unallocated cash** (`UNALLOCATED`) | "unapplied pool", "suspense pool" | call all such balances "customer credit"; post them to `SUSPENSE` | +| Durably on-account / reusable / wallet | **Reusable customer credit** (`REUSABLE_CREDIT`) | "on-account credit" | conflate with Unallocated cash (the split is not optional) | +| Mapping-exception parking | **`SUSPENSE`** account class | — | use it for inbound funds or chargeback holds | +| Chargeback cash hold | **`DISPUTE_HOLD`** account class | "dispute hold" | park the hold in `SUSPENSE` or `UNALLOCATED` | +| Payment-dispute flow family | **dispute** = the case (`dispute_id`); **chargeback** = the posting flow | — | use the two words interchangeably | +| Posted financial subledger entry | **`journal_entry` / `journal_line`** | "the ledger" (prose) | call it `LedgerEntry` | +| Correction shape | **strict line-negation reversal** | — | "storno" / "gross-replace" (ERP-presentation only) | + +`REUSABLE_CREDIT` is declared in the base `account_class` enum and **activated by payments-allocation**; later classes (`DISPUTE_HOLD`, `PSP_FEE_EXPENSE`, …) extend the enum additively. Composite `business_id` literals are snake_case (`dispute_id:cycle:phase`, `psp_refund_id:phase`, `invoice_id:correction_id`, `schedule_id:segment_no`) — never camelCase. + +**`LedgerEntry` naming resolution (✅ 2026-06-15; removed from manifest 2026-06-16).** The manifest §4.2 `LedgerEntry` entry was an **erroneous/dangling name** (no data model, no events, absent from the Rating PRD) → **removed from the manifest**; the Billing posted subledger is `journal_entry`/`journal_line` and MUST NOT be called `LedgerEntry`. The error-code namespace is separate: `LEDGER_ENTRY_UNBALANCED` / `LEDGER_ENTRY_EMPTY` are error codes, not the resource name, and the naming lint scopes to resource/type names only. + +**CI enforcement** (this discipline is enforced by CI, not runtime tests): traceability validation; naming-discipline lint (`LedgerEntry` for a posted entry, "deferred revenue" as a distinct mechanism, "customer credit" for Unallocated cash); suspense lint; composite-id / dispute lint; proof-matrix + decomposition coverage. **Module boundaries**: Usage ≠ Rated ≠ Invoice (distinct stores); Rating is upstream of invoice post (the ledger consumes the posted InvoiceItem + `TaxBreakdown`, never re-rates); customer-facing history is the Invoice/Payment services' SoR; worked-example amounts are illustrative, not normative. + +**Two numbering axes (never conflate):** PRD section shorthand S1–S8 (S1 Invoice post · S2 Payment · S3 Credit note · S4 Debit note · S5 Refund · S6 Recognition schedule · S7 = ASC 606 Compliance · S8 = Cross-cutting) vs design slice numbers 1–8 (1 posting-engine-core · 2 payments-allocation · 3 adjustments-notes-refunds · 4 asc606-recognition · 5 fx-multicurrency · 6 audit-immutability-observability · 7 reconciliation-export · 8 naming-alignment). They do **not** line up (PRD S7 = ASC 606 Compliance but design Slice 7 = reconciliation-export; recognition is PRD S6 but design Slice 4). + +### 4.2 Foundation Schema Ownership (normative) + +**Cross-slice schema mutations are eliminated (full extraction).** The Repository-foundation owns **all shared schema + migrations in full from the start** — the no-negative guarded set (incl. `DISPUTE_HOLD`, `REFUND_CLEARING`), the dual-column commit trigger, the functional cache columns, and the complete `account_class` / `source_doc_type` / `flow` enums. Handler slices own **only their own domain tables** (`payment_settlement`, `recognition_schedule`, `credit/debit_note`, `refund`, `rate_snapshot`, `pending_event_queue`, `export_record`, `chain_state`, …) and **post through the Foundation data-access API** (`postBalancedEntry` / `applyBalanceDeltas` / `applyCounterDelta` / `idempotencyClaim` / `pinOpenPeriod` / `provisionLegalEntity`; read-then-write ordering is handled by `SERIALIZABLE`/SSI, not a locked-read op). There are **no cross-slice `ALTER`s** and no migration-ordering dependency between slices — the only ordering is Foundation-first. + +### 4.3 Call-Driven Ingestion Model (normative) + +The ledger is **call-driven**, consistent with C3 (no external event store / streaming bus on the posting path) + the inbound API gateway. Upstream facts are delivered by **upstream modules calling the ledger's REST endpoints**; the ledger does **not** consume a bus and has **no inbound consumer/dispatcher**. Where a "Consumed" line names a fact whose natural source is an upstream event, the upstream module (or its own adapter) translates that event into the REST call — the mapping is owned upstream, not in the ledger. + +| Consumed fact | Delivered by | Ledger endpoint | +|---|---|---| +| approved `Refund` (phases) | Payments → REST call | `POST /refunds` (adjustments-notes-refunds) | +| `ChargeAdjustment` → credit note | Rating-side adapter maps → REST call | `POST /credit-notes` (adjustments-notes-refunds) | +| `TaxBreakdown` | Tax Engine — inline on the posting request | carried on the post call | +| `PaymentSettled` / dispute / settlement-return | Payments → REST call | payments-allocation | +| issued-invoice manifest | Invoice/Orchestration → control feed | recon feed (reconciliation-export), not a posting source | + +Idempotency/ordering use the existing `idempotency_dedup` + per-endpoint business keys; **no** new inbound contract. "Consumed events" in any feature doc means "consumed **facts** delivered via REST calls." + +### 4.4 Ledger-Ownership Predicate (normative) + +ADR: `cpt-cf-bss-ledger-adr-book-ownership-predicate`. VHP tenants are **typed** (`tenant_type` on the platform `TenantInfo`). **Only selling entities own billing books** (AR / Revenue / Contract liability) and therefore have an `export_target` + period close + functional currency; **buyer-type tenants own no books** — they appear only as `payer` / `resource` on lines. + +- The ledger-owner `tenant_id` — the RLS/owner axis, the `export_target` holder, the period-close unit, and the functional-currency carrier — **is a selling legal-entity / commercial-account.** +- The predicate "this tenant owns books" resolves from `tenant_type` (AMS/GTS) + the commercial-account mapping; buyer-types are `payer`/`resource` only. +- **Two distinct hierarchies — do NOT conflate:** the **buyer hierarchy** (org → department) drives payer resolution / AR consolidation; the **seller hierarchy** (platform → partner → reseller) is the book-ownership / subtree-read axis. Different axes on the same tenant tree. +- The `tenant ↔ commercial-account ↔ legal-entity` mapping (1:1 or 1:N) is owned by **AMS / Catalog / billing-setup, not the ledger**; FX (one functional currency per legal entity) hangs off it. +- **Hardening.** The tenant-type catalogue (`vhp-core-am/config/tenant-types.yaml`) defines `platform`, `partner`, `organization` (hierarchy via `x-gts-traits.allowed_parent_types`); there is no `individual` type yet and no `seller`/`buyer` enum. **Predicate:** book-owning **seller** = `platform` + `partner`; **buyer** = `organization` (+ a future `individual`). **Recommended mechanism:** AMS adds an `x-gts-traits.owns_billing_books: true` trait to the `platform`/`partner` type schemas; the ledger reads that trait rather than hardcoding a type list. ⏳ **Pending (AMS):** confirm platform + partner are the intended sellers, then add the trait. + + +## 5. Traceability + +- **PRD**: [PRD.md](../PRD.md) +- **ADR**: [ADR/](../ADR/) — `cpt-cf-bss-ledger-adr-book-ownership-predicate` +- **Index / decomposition**: [README.md](./README.md) +- **Features**: the slice designs `01a`–`07` in this folder diff --git a/gears/bss/ledger/docs/design/01a-invoice-posting.md b/gears/bss/ledger/docs/design/01a-invoice-posting.md new file mode 100644 index 000000000..4847b9127 --- /dev/null +++ b/gears/bss/ledger/docs/design/01a-invoice-posting.md @@ -0,0 +1,568 @@ + + + + +# DESIGN — Invoice Posting (Slice 1) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope Boundaries](#15-scope-boundaries) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Post Invoice Journal Entry](#post-invoice-journal-entry) + - [Reverse Invoice Entry](#reverse-invoice-entry) + - [Query Balances, AR Aging & Posted Entries](#query-balances-ar-aging--posted-entries) + - [Clear Suspense Line (Mapping Correction)](#clear-suspense-line-mapping-correction) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Build Direct-Split Entry](#build-direct-split-entry) + - [Resolve Account Mapping & Suspense Routing](#resolve-account-mapping--suspense-routing) + - [Suspense Remap (MAPPING_CORRECTION Re-Post)](#suspense-remap-mapping_correction-re-post) + - [AR Aging Rollup (Read-Time)](#ar-aging-rollup-read-time) + - [Reversal Line Negation](#reversal-line-negation) +- [4. States (CDSL)](#4-states-cdsl) + - [Invoice Journal Entry State Machine](#invoice-journal-entry-state-machine) + - [Suspense Line State Machine](#suspense-line-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Direct-Split Invoice Posting](#direct-split-invoice-posting) + - [Account Mapping & Suspense Routing](#account-mapping--suspense-routing) + - [Strict Line-Negation Reversal](#strict-line-negation-reversal) + - [AR Balances & Aging Read](#ar-balances--aging-read) + - [Invoice-Posting API Surface](#invoice-posting-api-surface) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Additional Context](#7-additional-context) + - [7.1 REST API Surface (feature-owned endpoints)](#71-rest-api-surface-feature-owned-endpoints) + - [7.2 Problem Responses (RFC 9457)](#72-problem-responses-rfc-9457) + - [7.3 Events Surface](#73-events-surface) + - [7.4 Data Model (feature-owned tables)](#74-data-model-feature-owned-tables) + - [7.5 Security & AuthZ](#75-security--authz) + - [7.6 Feature Metrics](#76-feature-metrics) + - [7.7 NFR Mapping](#77-nfr-mapping) + - [7.8 Testing Architecture](#78-testing-architecture) + - [7.9 Risks, Open Questions & Deferred Work](#79-risks-open-questions--deferred-work) + - [7.10 Decision Log (Needs Discussion)](#710-decision-log-needs-discussion) + - [7.11 References](#711-references) + + + +## 1. Context + +### 1.1 Overview + +The **first business posting** of the Billing Ledger — the **S1 invoice-post**. This feature builds the balanced **direct-split** journal entry for a posted invoice (DR AR / CR Revenue + Contract-liability + Tax), resolves account mappings, accepts the authoritative tax breakdown as-is, contributes AR balance + aging, and provides the full strict-line-negation reversal flow. + +It does **not** implement the posting engine: the atomic balanced journal entry, the append-only `journal_line` truth, the derived balance caches, the universal posting invariants, the total lock order, and the data-access API all live in the **Repository-foundation** (see 01-repository-foundation.md §Component Model — Foundation engine). This feature **calls** that API — it builds balanced lines and calls `postBalancedEntry(lines, flow=INVOICE_POST, businessKey=invoiceId)` — and posts under the Foundation's invariants. The handler **never touches the truth or cache tables directly**; it posts **through** the Foundation's in-process data-access API: `postBalancedEntry`, `applyBalanceDeltas`, `idempotencyClaim`, `pinOpenPeriod`, `applyCounterDelta` (read-then-write ordering is handled by `SERIALIZABLE`/SSI, not a locked-read op). The engine, schema, universal posting invariants, lock order, and projector are **referenced, not redefined**, here. + +This feature reuses the PRD glossary (PRD § Glossary, § Accounts) verbatim and inherits the Foundation's implementation glossary (`journal_entry` / `journal_line` = the append-only truth; the balance **caches**; `idempotency_dedup`; **tie-out**). RFC 2119 keywords (MUST / SHOULD / MAY) carry their normative meaning. + +**Traces to**: `cpt-cf-bss-ledger-fr-invoice-post-direct-split`, `cpt-cf-bss-ledger-fr-exception-suspense-handling`, `cpt-cf-bss-ledger-fr-reversal-canonical-pattern`, `cpt-cf-bss-ledger-fr-idempotency-per-flow`, `cpt-cf-bss-ledger-fr-idempotent-replay-contract`, `cpt-cf-bss-ledger-fr-account-classes`, `cpt-cf-bss-ledger-fr-money-rounding-scale`, `cpt-cf-bss-ledger-fr-multi-axis-attribution`, `cpt-cf-bss-ledger-fr-tenant-isolation-posting`, `cpt-cf-bss-ledger-fr-audit-retrieval`, `cpt-cf-bss-ledger-nfr-posting-performance`, `cpt-cf-bss-ledger-nfr-availability` + +### 1.2 Purpose + +Implements **Slice 1 (the invoice-posting handler)** of the Billing Ledger PRD: the ledger **starts at invoice post** — a posted invoice becomes a balanced, immutable, idempotent journal entry with correct AR, Revenue, Contract-liability, and Tax-payable effects, exception-safe account mapping (suspense routing), read-time AR aging, and a strictly governed reversal path. Sibling slices (payments/allocation, credit/debit notes & refunds, ASC 606 recognition, FX, reconciliation/export, audit/observability) are their own feature slices and post **through** the same Foundation. + +**Requirements**: `cpt-cf-bss-ledger-fr-invoice-post-direct-split`, `cpt-cf-bss-ledger-fr-exception-suspense-handling`, `cpt-cf-bss-ledger-fr-reversal-canonical-pattern`, `cpt-cf-bss-ledger-fr-idempotency-per-flow`, `cpt-cf-bss-ledger-fr-idempotent-replay-contract`, `cpt-cf-bss-ledger-fr-money-rounding-scale`, `cpt-cf-bss-ledger-nfr-posting-performance` + +**Use cases**: `cpt-cf-bss-ledger-usecase-ledger-inquiry`, `cpt-cf-bss-ledger-usecase-exception-resolution` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-bss-ledger-actor-billing-orchestration` | Submits the materialized posted invoice (`InvoiceItem` with `glCode` / `skuId` / `planId` / `priceId` / `pricingSnapshotRef` / revenue stream) via `POST /v1/ledger/journal-entries`; supplies the resolved `payer_tenant_id` | +| `cpt-cf-bss-ledger-actor-tax-engine` | Supplies the authoritative posted `TaxBreakdown` (accepted as-is, never recomputed) | +| `cpt-cf-bss-ledger-actor-catalog-contracts` | Supplies the `glCode` + tax-category account-class mapping snapshot (Catalog) and PO / allocation-group ids (Contracts — presence gate only at S1) | +| `cpt-cf-bss-ledger-actor-finance-ops` | Triggers reversals; performs the dual-control suspense clearing (compensating reversal + `MAPPING_CORRECTION` re-post) | +| `cpt-cf-bss-ledger-actor-finance-approver` | Approver half of the dual-control (preparer/approver, reason code) for reversal, material backdating, and suspense clearing | +| `cpt-cf-bss-ledger-actor-revenue-assurance` | Receives the suspense-backlog aging alarm and the missing-PO alarm; clears exceptions before period close | +| `cpt-cf-bss-ledger-actor-auditor` | Retrieves per-entry who/when/source/correlation via `GET /v1/ledger/journal-entries/{entryId}` (AC #8) | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — posting rules S1, account classes, invariants, ACs +- **Design**: [01-repository-foundation.md](./01-repository-foundation.md) — see 01-repository-foundation.md §Component Model (Foundation engine `postBalancedEntry` / `applyBalanceDeltas`, commit trigger, BalanceProjector, TieOutJob, outbox relay) +- **Dependencies**: Repository-foundation (shared posting engine, schema/DDL, universal invariants, total lock order, data-access API, seller-provisioning endpoint) + +### 1.5 Scope Boundaries + +**In scope** (PRD requirements covered — the invoice-post domain; the universal invariants those posts run under are Foundation-owned): + +- the S1 invoice-post direct-split posting rule (PRD § Posting rules S1; Example A) +- deferred-revenue **credit** at post (AC #4 — credit line only, no release) +- gross AR incl. tax + a separate Tax-payable line per `TaxBreakdown` (B1; tax accepted as-is, never recomputed) +- account-mapping resolution and the missing-mapping route-to-suspense path (A5) + suspense-backlog aging alarm +- AR balances & aging — the S1 contribution to the AR grains and the read-time aging rollup +- the mandatory revenue-stream line tag on Revenue / Contract-liability lines (AC #26 — invariant enforced by the Foundation; this handler populates it; derivation policy deferred to `asc606-recognition`) +- strict line-negation reversal of an invoice entry: at-most-once-per-entry domain semantics, no-reverse-of-a-reversal, amount-correction out of scope +- the invoice-post + reversal **API endpoints** (the handler-facing HTTP surface) + +The universal posting invariants (balanced/zero-sum, append-only, ≥1-line, single-payer, no-negative, idempotency + idempotent-replay, money/banker's-rounding, UTC clock + fiscal-period assignment + closed-period/backdating, account-lifecycle posting gates, the minimal period-close transition, the daily tie-out) are **enforced by the Foundation**; this feature builds lines that satisfy them and calls the Foundation API. The account classes this feature posts to — AR, Revenue, Contract liability, Tax payable, and Suspense (for unmapped lines) — are declared in the Foundation's `account_class` enum. + +**Out of scope / Non-goals.** Owned by the Foundation, not this feature (referenced, not redefined here): + +- the shared posting engine and its per-entry ACID transaction, the `journal_entry` / `journal_line` append-only truth, the balance caches, the universal invariants (balanced/zero-sum, ≥1-line, single-payer, no-negative), the total lock order, and the data-access API. +- the schema / DDL of every shared table (`journal_entry`, `journal_line`, the balance caches, `idempotency_dedup`, `tenant_account`, `fiscal_period`, `currency_scale_registry`, `tenant_posting_lock`, `payer_state`, `event_outbox`). This feature adds **no** shared-table DDL. +- the seller-provisioning endpoint that seeds chart-of-accounts / currency scales / the initial fiscal period. + +Deferred to sibling feature slices (the engine provides the seam, not the behavior): + +- **S2 payment settlement / allocation**, unallocated cash, overpayment, chargebacks, wallet credit → `payments-allocation`. +- **S3 credit notes / S4 debit notes / S5 refunds**, contra-revenue, refund-clearing → `adjustments-notes-refunds`. +- **S6 recognition runs** and ASC 606 deferral/timing/SSP/disaggregation **policy** → `asc606-recognition`. This feature posts the Contract-liability **credit line** at S1 but runs **no** releases. The revenue-stream classification **invariant** (a line tag MUST be present) is enforced by the Foundation; its **derivation policy** is deferred. +- **FX** realized/unrealized, rate snapshots, stale-rate handling → `fx-multicurrency`. Slice 1 is **single-currency** (transaction currency == functional currency). +- **Reconciliation dashboards and ERP/GL export** → `reconciliation-export`. The daily AR tie-out (correctness control) and the suspense/period-close block are Foundation-owned controls this feature relies on; cross-system export is not in scope. +- **Tamper-evidence mechanism choice**, secured-audit-store internals, GDPR erasure/tombstone, retention/archival → `audit-immutability-observability`. +- **Bad debt / write-off / recovery**, **inter-tenant settlement / reseller payout** — out of scope for the whole PRD (PRD § Out of scope; § Resolved decisions). +- **Rating math / tariff evaluation** — upstream (Metering & Pricing); the ledger starts at invoice post. +- **Historical / as-of (temporal) balance** — `GET /balances` serves only the **current** cache. A balance "as of date T" is **out of S1 scope**; it is reconstructable from `journal_line` (replay to T) when a reporting / reconciliation slice needs it, choosing the basis explicitly — `posted_at_utc` (when recorded, for audit) vs `effective_at` (economic date, for restatement), which diverge for backdated / reversal entries (those post "now" with a past `effective_at`). + +**Feature boundary & inputs.** The ledger **starts at invoice post**: this feature consumes a materialized posted invoice + tax evidence + account-mapping snapshot; it does not see raw usage or rating math. **Consumed at S1:** `InvoiceItem` (posted, with `glCode` / `skuId` / `planId` / `priceId` / `pricingSnapshotRef` / revenue-stream); `TaxBreakdown` (accepted as-is, never recomputed); `glCode` + tax-category account-class mapping (Catalog snapshot); PO / allocation-group id (Contracts — consumed only to enforce the *presence* gate; the full SSP / deferral matrix is in `asc606-recognition`). **(Ingestion model — README):** these are **consumed facts delivered via REST calls** from the owning upstream module (or its adapter), **not** events the ledger subscribes to — no inbound bus on the post path (Foundation C3). **Produced:** posted journal entries (via the Foundation); the S1 contribution to AR balance & aging; idempotent-replay posting reference; invariant alarms (raised by the Foundation). + +### 1.6 Constraints & Assumptions + +**Inherited platform constraints (normative)** are the Foundation's C1–C4 (RLS isolation, backwards-compatible migrations, PostgreSQL is the store, API exposure behind the inbound API gateway). This feature introduces none of its own; it inherits them by posting through the Foundation. + +**Blocker assumptions** — recorded with the PRD's own draft direction; each MUST be confirmed by PM Team / Finance before the dependent slice finalizes. These do **not** change the slice-1 journal shape. Only the assumptions exercised by the invoice-post domain are repeated here; the engine-level ones (A2 NFR, A3 tamper, A4 money type) live in the Foundation. + +| # | Topic | Assumption (default) | PRD source | +|---|-------|----------------------|------------| +| A1 | Net vs gross tax presentation | **Resolved — gross** (confirmed by Product; B1): S1 posts **gross** AR (incl. tax) + a **separate** Tax-payable line per `TaxBreakdown`. Net / tax-split is an export/inquiry presentation concern, not a journal change. | PRD | +| A5 | Missing account-mapping policy | Default **route-to-suspense**: the line posts to a `SUSPENSE` account class flagged `mapping_status = PENDING`, which **blocks period close** until cleared or an approved exception is recorded (PRD). Tenant-configurable to **hard-block** (`ACCOUNT_MAPPING_MISSING`). Never silent wrong-revenue. | PRD | +| A6 | Material-backdating threshold | Default **5 business days**; valid range **[1..30]**; out-of-range config rejected (no silent clamp). The threshold is evaluated by the Foundation's `FiscalPeriodGuard`; this feature's posts pass `effectiveAt` and are subject to it. | PRD | + +## 2. Actor Flows (CDSL) + +**Data-flow summary.** A post request enters via the Posting API → the invoice-post handler resolves account mappings (`AccountMappingResolver`), builds the balanced DR AR / CR Revenue + Contract-liability + Tax lines, and calls **`postBalancedEntry(lines, flow=INVOICE_POST, businessKey=invoiceId)`**. Inside that call the Foundation claims idempotency (`idempotencyClaim`), pins/locks the open period (`pinOpenPeriod`), inserts the lines, applies the AR / Revenue / Contract-liability / Tax balance deltas through `applyBalanceDeltas` under its canonical lock order (which also enforces no-negative), and commits under the leaf-partition zero-sum trigger. A reversal request builds the line-negated entry and calls `postBalancedEntry(..., flow=REVERSAL, businessKey=reverses=entryId)`. The handler issues **no** direct balance-row or journal DML — it cannot (REVOKE on shared tables, Foundation-owned). + +### Post Invoice Journal Entry + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-invoice-post` + +**Actor**: `cpt-cf-bss-ledger-actor-billing-orchestration` + +**Success Scenarios**: +- First successful post returns `201` with the posting reference; balances (AR, Revenue, Contract liability, Tax payable) reflect the entry atomically +- Idempotent replay (same key + identical payload) returns `200` with the **prior** posting reference (`entryId`, `postedAtUtc`, `status`) — not an error body +- Missing account mapping under the default tenant policy → line routes to `SUSPENSE` with `mapping_status = PENDING` (post succeeds; period close is blocked until cleared) + +**Error Scenarios**: +- Payer `lifecycle_state = CLOSED` → `PAYER_CLOSED` (422) +- Missing mapping and tenant policy = hard-block → `ACCOUNT_MAPPING_MISSING` (422) +- Missing PO / allocation-group tag → post blocked, counted in `ledger_post_blocked_missing_po_total` +- Same key + different payload → `409` `IDEMPOTENCY_PAYLOAD_CONFLICT` (Foundation) +- Any Foundation universal-invariant violation (`LEDGER_ENTRY_UNBALANCED`, `PERIOD_CLOSED`, `MISSING_PAYER`, …) surfaced unchanged + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/journal-entries (body: `invoiceId`, lines or invoice items + `taxBreakdown` + revenue-stream, `effectiveAt`, mapping refs, resolved `payer_tenant_id` + `resource_tenant_id` + resolution-provenance stamp) - `inst-ipost-api` +2. [ ] - `p1` - Algorithm: resolve account mappings for every line using `cpt-cf-bss-ledger-algo-invoice-account-mapping` (Catalog snapshot class first, then Contract override; missing mapping → suspense routing or hard-block per tenant policy A5) - `inst-ipost-map` +3. [ ] - `p1` - **IF** tenant policy = hard-block and a mapping is missing **RETURN** 422 `ACCOUNT_MAPPING_MISSING` - `inst-ipost-hardblock` +4. [ ] - `p1` - Algorithm: build the balanced direct-split lines using `cpt-cf-bss-ledger-algo-invoice-line-build` (DR AR gross incl. tax / CR Revenue recognized / CR Contract-liability deferred / CR Tax payable per `TaxBreakdown`) - `inst-ipost-build` +5. [ ] - `p1` - **IF** the PO / allocation-group presence gate fails: block the post, increment `ledger_post_blocked_missing_po_total` **RETURN** error - `inst-ipost-po-gate` +6. [ ] - `p1` - Call Foundation `postBalancedEntry(lines, flow=INVOICE_POST, businessKey=invoiceId)` — inside: `idempotencyClaim` at-most-once per `(tenant, INVOICE_POST, invoiceId)`; `pinOpenPeriod(tenant, legalEntity)` (`fiscal_period FOR SHARE` + `OPEN` assertion; `effectiveAt` subject to closed-period / material-backdating rules A6); line insert; `applyBalanceDeltas` under the canonical lock order with no-negative on the guarded set; commit under the leaf-partition zero-sum trigger - `inst-ipost-foundation` +7. [ ] - `p1` - **IF** the Foundation resolves an idempotent replay (same key + identical payload) **RETURN** 200 with the prior posting reference (`entryId`, `postedAtUtc`, `status`) - `inst-ipost-replay` +8. [ ] - `p1` - **IF** the payer is `CLOSED` (`payer_state.lifecycle_state = CLOSED`, AC #21; Foundation payer-OPEN gate for payer-posting handlers — this handler is one) **RETURN** 422 `PAYER_CLOSED` - `inst-ipost-payer-closed` +9. [ ] - `p1` - **RETURN** 201 Created (posting reference) - `inst-ipost-return` + +### Reverse Invoice Entry + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-invoice-reverse` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-ops` + +**Success Scenarios**: +- A posted invoice entry is reversed exactly once via strict line-negation; the original is untouched; AR/Revenue/Contract-liability/Tax balances net out +- Retried reversal with identical payload replays idempotently (prior reference) + +**Error Scenarios**: +- Entry already has a reversal pointing at it → rejected (at-most-once total, even with a different `reason`) +- Entry's `source_doc_type = REVERSAL` → `CANNOT_REVERSE_REVERSAL` (422) +- Referenced original entry does not exist → rejected at post time (lineage is not a DB self-FK) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/journal-entries/{entryId}/reversals (body: `reason` — **payload only**, covered by the Foundation's payload-hash conflict check, never part of the uniqueness key) - `inst-irev-api` +2. [ ] - `p1` - Reversal + material backdating follow **dual-control** per policy (preparer/approver; billing-poster / approver scopes) - `inst-irev-dualcontrol` +3. [ ] - `p1` - **IF** target entry `source_doc_type = REVERSAL` **RETURN** 422 `CANNOT_REVERSE_REVERSAL` — no undo-the-undo chains; a mistaken reversal is corrected by a fresh upstream post (new `invoiceId`), never by reversing the reversal - `inst-irev-no-undo` +4. [ ] - `p1` - Validate the referenced original entry exists at post time (lineage is **not** a DB self-FK — a self-referential FK on a partitioned table prevents detaching/dropping old partitions, breaking 7-year retention) - `inst-irev-exists` +5. [ ] - `p1` - **IF** the entry already has a reversal pointing at it **RETURN** rejection (belt-and-suspenders; the Foundation backs at-most-once-per-entry with the partial unique index on `(tenant_id, reverses_period_id, reverses_entry_id) WHERE reverses_entry_id IS NOT NULL`) - `inst-irev-once` +6. [ ] - `p1` - Algorithm: build the negated lines using `cpt-cf-bss-ledger-algo-invoice-reversal-negation` (same accounts, flipped side, positive amount; carries `reverses_entry_id` + `reverses_period_id`; posts at current effective time) - `inst-irev-negate` +7. [ ] - `p1` - Call Foundation `postBalancedEntry(negatedLines, flow=REVERSAL, businessKey=reverses=entryId)` — dedup key `(tenant, REVERSAL, reverses_entry_id)`, so two reverse calls with different reasons cannot both post - `inst-irev-foundation` +8. [ ] - `p1` - **RETURN** 201 Created (reversal posting reference); idempotent replay returns 200 prior reference - `inst-irev-return` + +### Query Balances, AR Aging & Posted Entries + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-invoice-balance-inquiry` + +**Actor**: `cpt-cf-bss-ledger-actor-auditor` + +**Success Scenarios**: +- Warm balance read from the Foundation's balance cache (filters: `accountClass`, `currency`, `payerTenantId`, optional `invoiceId` for the AR-invoice grain) +- AR aging rollup per payer derived at read time +- Per-entry audit retrieval returns lines + source linkage + actor/correlation (AC #8) +- Paginated journal-line history via cursor pagination + +**Error Scenarios**: +- Entry not found (404) +- Cross-tenant access blocked by RLS / tenant context + +**Steps**: +1. [ ] - `p1` - API: GET /v1/ledger/balances (filter: `accountClass`, `currency`, `payerTenantId`, optional `invoiceId`) — warm read from the balance cache; returns the **current** cached value only (as-of balance out of S1 scope) - `inst-iq-balances` +2. [ ] - `p1` - API: GET /v1/ledger/balances/ar-aging — Algorithm: derive buckets at read time using `cpt-cf-bss-ledger-algo-ar-aging-rollup` - `inst-iq-aging` +3. [ ] - `p1` - API: GET /v1/ledger/journal-entries/{entryId} — retrieve a posted entry with its lines + source linkage + actor/correlation (AC #8: `posted_by_actor_id`, `origin`, `posted_at_utc`, `source_doc_type` / `source_business_id` / `reverses_entry_id`, `correlation_id`; human-readable PII is **not** stored here) - `inst-iq-entry` +4. [ ] - `p1` - API: GET /v1/ledger/journal-lines — paginated transaction history (cursor pagination; filter: payer, account class, period, source business id) - `inst-iq-lines` +5. [ ] - `p1` - **RETURN** 200 with the requested read (all reads are pure reads over existing caches / truth — no new posted state, no new lock rank) - `inst-iq-return` + +### Clear Suspense Line (Mapping Correction) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-invoice-suspense-clearing` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-ops` + +**Success Scenarios**: +- A `SUSPENSE` line with `mapping_status = PENDING` is cleared by compensating reversal + corrected re-post under flow `MAPPING_CORRECTION`; the period-close block lifts +- A retried half of the two-step operation replays idempotently (deterministic `correction_id`) + +**Error Scenarios**: +- Re-post attempted under `INVOICE_POST` → blocked by the surviving dedup row (AC #19 retention rule) +- Approved exception recorded instead of clearing → close-block lifted without re-post + +**Steps**: +1. [ ] - `p1` - Revenue Assurance is alerted by the suspense-backlog aging alarm: `ledger_suspense_pending_lines_total` + `ledger_suspense_pending_age_seconds`, warn > N days / page > M days, ticketing Revenue Assurance — so a missing Catalog mapping is caught on the **first** affected invoice rather than as a wall of un-mapped lines at close - `inst-isc-alarm` +2. [ ] - `p1` - Operator executes reversal + corrected re-post as **one dual-control operator action** (preparer/approver, reason code — same governance as material backdating) - `inst-isc-dualcontrol` +3. [ ] - `p1` - Post the compensating reversal of the original entry (flow `REVERSAL`, per `cpt-cf-bss-ledger-flow-invoice-reverse`) - `inst-isc-reverse` +4. [ ] - `p1` - Algorithm: corrected re-post using `cpt-cf-bss-ledger-algo-suspense-remap` (flow `MAPPING_CORRECTION`; the original `(tenant, INVOICE_POST, invoiceId)` dedup row **survives the reversal** and must, per AC #19, so the corrected entry cannot re-use `INVOICE_POST`) - `inst-isc-repost` +5. [ ] - `p1` - The Foundation's `TieOutJob` re-derives the open-PENDING set as its close-block input — the metric and the gate share one source of truth - `inst-isc-tieout` +6. [ ] - `p1` - **RETURN** cleared suspense line; period close unblocked (or an approved exception recorded per PRD) - `inst-isc-return` + +## 3. Processes / Business Logic (CDSL) + +### Build Direct-Split Entry + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-invoice-line-build` + +**Input**: Posted invoice (`invoiceId`, `InvoiceItem[]` with `glCode` / `skuId|planId|priceId` / `pricingSnapshotRef` / revenue-stream / PO / allocation-group / service-period refs), authoritative `TaxBreakdown`, resolved account mappings, `effectiveAt` + +**Output**: Balanced line set for `postBalancedEntry(lines, flow=INVOICE_POST, businessKey=invoiceId)` + +Builds the S1 **direct-split** entry (PRD § Posting rules S1; Example A). For an invoice with ex-tax total split into recognized-now and deferred portions: + +| Line | Side | Account class | Amount | +|------|------|---------------|--------| +| AR | DR | AR | invoice total **incl. tax** | +| Revenue (recognized at post) | CR | Revenue | ex-tax recognized portion | +| Contract liability (deferred) | CR | Contract liability | ex-tax deferred portion | +| Tax | CR | Tax payable | per `TaxBreakdown` | + +**Steps**: +1. [ ] - `p1` - Validate amounts tie to `sum(InvoiceItem) + TaxBreakdown` with rounding evidence stored once on the entry - `inst-ib-tie` +2. [ ] - `p1` - **IF** nothing is deferred: the Contract-liability line MUST be **absent** — **no zero-amount placeholder** Contract-liability lines (rejected at validation, AC #4) - `inst-ib-no-placeholder` +3. [ ] - `p1` - Populate item-line traceability: item lines and every Contract-liability line persist `invoice_item_ref`, `sku_or_plan_ref`, `price_id`, `pricing_snapshot_ref`, `po_allocation_group` on `journal_line` itself — Slice 4's `source_invoice_item_ref` and Slice 3's per-item credit-note splits resolve against `journal_line.invoice_item_ref`, so item-level resolution stays unambiguous when one invoice defers several items in the same stream - `inst-ib-item-refs` +4. [ ] - `p1` - Populate a non-null `revenue_stream` on every Revenue and Contract-liability line (AC #26 — the invariant is enforced by the Foundation's commit-trigger / CHECK; this handler populates it); the Contract-liability line carries the stream it will later release into - `inst-ib-stream` +5. [ ] - `p1` - Derive the recognized/deferred split — the split is **derived, not supplied**: upstream supplies item attributes (revenue stream, PO / allocation-group, service-period refs); Slice 4's ScheduleBuilder derives the split via the R1 policy **in the same transaction as the post** — the post fails if derivation fails - `inst-ib-split` +6. [ ] - `p1` - Derivation inputs are a **local snapshot** — the item attributes carried on the materialized post request plus versioned policy / SSP refs resolved from the **same database** — so the in-transaction derivation makes **no network call into Contracts or Catalog**; Foundation C3 (no external blocking dependency on the post path) holds and a Contracts outage cannot take down posting. A stale or missing local snapshot fails the post deterministically rather than blocking on a remote service - `inst-ib-local-snapshot` +7. [ ] - `p1` - Enforce the PO / allocation-group **presence** gate (the trigger-condition and immaterial-exemption matrix is owned by `asc606-recognition`); S1 posts the resulting Contract-liability credit and runs **no** releases. A post blocked by a missing PO / allocation-group tag is counted in `ledger_post_blocked_missing_po_total` and alarms on a sustained rate, so an incomplete Catalog snapshot surfaces as a data-quality signal rather than silently stalling cash collection (softened by 's Catalog default-tagging of ordinary point-in-time lines) - `inst-ib-po-gate` +8. [ ] - `p1` - Insert Tax **as-is** from `TaxBreakdown` (never recomputed — the ledger is not a tax engine, B1): one **separate** Tax-payable (CR) line per `TaxBreakdown` entry, alongside gross AR (incl. tax). Each `TAX_PAYABLE` line carries its own tax dimensions on `journal_line`: `tax_jurisdiction`, `tax_filing_period`, `tax_rate_ref` (rate evidence ref from the breakdown) — the columns and their `CHECK (account_class <> 'TAX_PAYABLE' OR (tax_jurisdiction IS NOT NULL AND tax_filing_period IS NOT NULL))` are Foundation-owned. Carrying the grain on the line is what lets the Foundation's `tax_subbalance` cache be both projected in-transaction and **rebuildable** from `journal_line` like every other cache; Slice 3's per-`(rate, jurisdiction, filing-period)` disaggregation reads `tax_rate_ref` from these same columns - `inst-ib-tax` +9. [ ] - `p1` - Capture the invoice **due date** on the AR `journal_line` from payment terms (due-on-receipt → `due_date = original_posted_at`); the Foundation projects it onto the `ar_invoice_balance` / `ar_payer_balance` grains via `applyBalanceDeltas` - `inst-ib-due-date` +10. [ ] - `p1` - **RETURN** the balanced line set; the handler does **not** open a transaction, claim idempotency, pin the period, project balances, or run the zero-sum check itself — the Foundation does all of that - `inst-ib-return` + +**Posting-through-the-Foundation notes (normative)**: + +- **Idempotency** at-most-once per `(tenant, INVOICE_POST, invoiceId)` + idempotent replay is the Foundation's `idempotencyClaim` path; a replay with an identical payload returns the prior reference. +- **Period** is pinned via the Foundation's `pinOpenPeriod(tenant, legalEntity)` — `fiscal_period FOR SHARE` + `OPEN` assertion inside the post transaction; `effectiveAt` is subject to the closed-period / material-backdating rules (A6) there. +- **Balance deltas** — the AR (DR), Revenue (CR), Contract-liability (CR), and Tax-payable (CR) deltas — are applied by the Foundation's **`applyBalanceDeltas`** under its canonical lock order, which sorts them into `(table_rank, tenant_id, account_id, currency, payer_tenant_id, invoice_id)` and enforces no-negative on the guarded set. In S1 only AR (debit) and the credit-normal Revenue / Contract-liability / Tax lines are posted, so AR cannot be driven negative by S1 except via an erroneous double reversal — which the reversal flow prevents. +- **Money / rounding** uses the Foundation's `MoneyModule` (banker's rounding, currency-scale registry, residual-cent determinism), so the S1 tax/revenue/contract-liability split rounds identically on every recompute and the commit-time balance check needs no tolerance. +- **Tax-payable sign.** In S1 the handler posts only Tax-payable **credits**, so the negative-balance question (Tax payable MAY go negative during reversal periods, PRD) is **inert** here; the Foundation excludes Tax-payable from the aggregate ≥ 0 check and guards it at the `tax_subbalance` `(jurisdiction, filing-period)` grain instead. The in-transaction enforcement decision for Tax-payable **debits** is deferred to the handler that first posts them (reversals / credit notes). + +### Resolve Account Mapping & Suspense Routing + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-invoice-account-mapping` + +**Input**: Line candidates with `glCode` / tax-category (Catalog snapshot) and Contract overrides; tenant missing-mapping policy (A5) + +**Output**: `(account_class, gl_code)` per line, or suspense-routed line, or `ACCOUNT_MAPPING_MISSING` rejection + +**Steps**: +1. [ ] - `p1` - Resolve `(account_class, gl_code)` for each line at post time before the lines are handed to `postBalancedEntry`: **Catalog snapshot class** first, then **Contract override** where applicable (PRD § Account mapping resolution) - `inst-am-resolve` +2. [ ] - `p1` - **IF** mapping is missing and tenant policy = default (route-to-suspense): post the line to the `SUSPENSE` account class with `mapping_status = PENDING` — this opens an exception that **blocks period close** until the line is re-mapped (a compensating reversal + corrected re-post) or an approved exception is recorded (PRD); the Foundation's `TieOutJob` re-derives the open-PENDING set as its close-block input. Never silently maps to a wrong revenue account - `inst-am-suspense` +3. [ ] - `p1` - **IF** mapping is missing and tenant policy = hard-block: reject the post with `ACCOUNT_MAPPING_MISSING` - `inst-am-hardblock` +4. [ ] - `p1` - Emit the suspense-backlog aging metric + alarm on open `mapping_status = PENDING` lines, by tenant: `ledger_suspense_pending_lines_total` (count) and `ledger_suspense_pending_age_seconds` (oldest / histogram); configurable thresholds **warn** above N days and **page** above M days, ticketing Revenue Assurance — because the default is route-to-suspense, PENDING lines otherwise accumulate **silently** during the period and only surface when they block the close at month-end, under deadline - `inst-am-alarm` +5. [ ] - `p1` - **RETURN** resolved mappings / suspense-routed lines - `inst-am-return` + +### Suspense Remap (MAPPING_CORRECTION Re-Post) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-suspense-remap` + +**Input**: Original entry id, its compensating reversal entry id, corrected mappings + +**Output**: Corrected re-posted entry under flow `MAPPING_CORRECTION` (idempotent) + +**Steps**: +1. [ ] - `p1` - The clearing workflow is "compensating reversal + corrected re-post", but the original `(tenant, INVOICE_POST, invoiceId)` dedup row **survives the reversal** (and must, per the AC #19 retention rule), so the corrected entry cannot re-use the `INVOICE_POST` flow - `inst-sr-survives` +2. [ ] - `p1` - Use the **additive flow `MAPPING_CORRECTION`**, keyed `(tenant, MAPPING_CORRECTION, invoice_id:correction_id)` - `inst-sr-flow` +3. [ ] - `p1` - `correction_id` MUST be **deterministic** per logical correction — `correction_id = hash(original_entry_id, reversal_entry_id)` — so a retried half rebuilds the **same** dedup key and replays idempotently, never double-posting a second corrected entry under a freshly-generated id - `inst-sr-deterministic` +4. [ ] - `p1` - Set `source_doc_type = MAPPING_CORRECTION` (additive C2 enum); the entry carries source linkage to both the original entry and its reversal - `inst-sr-linkage` +5. [ ] - `p1` - Execute reversal + corrected re-post as one dual-control operator action (preparer/approver, reason code — same governance as material backdating); a retry of either half replays idempotently via the Foundation's idempotency claim - `inst-sr-dualcontrol` +6. [ ] - `p1` - **RETURN** corrected entry reference. Without this flow the suspense path is a dead end: reversible but never correctable - `inst-sr-return` + +### AR Aging Rollup (Read-Time) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-ar-aging-rollup` + +**Input**: `ar_invoice_balance` cache rows (`balance_minor > 0`), tenant bucket configuration, current time + +**Output**: AR aging rollup per payer, bucketed per `(payer, currency)` + +S1 is the first AR-affecting fact: a posted invoice debits AR (open AR = posted AR until a payment or note lands). AR aging is exposed as a rollup per payer (PRD Scope "AR balances & aging — HIGH"), `GET /v1/ledger/balances/ar-aging`. + +**Steps**: +1. [ ] - `p1` - DB: read `ar_invoice_balance` rows with `balance_minor > 0` for the tenant/payer filter - `inst-ag-read` +2. [ ] - `p1` - Derive buckets **at read time**: **days past due** = now − `due_date`; default buckets **current / 1–30 / 31–60 / 61–90 / 90+ days past due**, tenant-configurable - `inst-ag-buckets` +3. [ ] - `p1` - Aging is by **days past due** vs the invoice **due date** (captured on the AR posting at S1 from payment terms; due-on-receipt → `due_date = original_posted_at`), per PRD *AR aging basis* — **not** days since posting (the prior `original_posted_at` basis is superseded; `original_posted_at` stays only as the due-on-receipt fallback source) - `inst-ag-basis` +4. [ ] - `p1` - Compute buckets per `(payer, currency)` — `ar_invoice_balance` carries `currency`, so amounts of different minor-unit scales are never mixed in one bucket - `inst-ag-currency` +5. [ ] - `p2` - **Debit-note (S4) aging basis**: a debit note posts onto the **same** `ar_invoice_balance` row (per-invoice grain), so by default its delta inherits the originating invoice's `due_date`; when a debit note carries **different** payment terms the debit-note AR `journal_line.due_date` is authoritative and the aging read MUST age that delta from the **line-level** due date — a Slice 4 refinement on the same read - `inst-ag-debit-note` +6. [ ] - `p1` - **RETURN** the rollup. The aging read is a pure read over the existing cache: **no new posted state, no new lock rank** - `inst-ag-return` + +### Reversal Line Negation + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-invoice-reversal-negation` + +**Input**: Original posted entry (`entryId`, lines), `reason` (payload only) + +**Output**: Negated line set for `postBalancedEntry(negatedLines, flow=REVERSAL, businessKey=reverses=entryId)` + +The **only** correction shape is **strict line-negation** (AC #23) — the Foundation enforces the append-only / line-negation **mechanism** and the once-per-entry lineage index; this feature owns the invoice-reversal **domain flow**. + +**Steps**: +1. [ ] - `p1` - Because `amount_minor` is `CHECK > 0`, AC #23's "same accounts and sides, negated amounts" is realized as **same accounts, flipped side, positive amount** — economically identical line-negation, and explicitly **distinct from gross-replace** (which would additionally re-post a corrected entry) - `inst-rn-shape` +2. [ ] - `p1` - The reversal entry carries `reverses_entry_id` and `reverses_period_id` (the original entry's period), posts at current effective time; the original is untouched - `inst-rn-lineage` +3. [ ] - `p1` - Dedup key `(tenant, REVERSAL, reverses_entry_id)` — `reason` is **payload only** (covered by the Foundation's payload-hash conflict check), never part of the uniqueness key, so two reverse calls with different reasons cannot both post; an entry MUST be reversible **at most once total** - `inst-rn-dedup` +4. [ ] - `p1` - **Amount-correction is out of S1 scope**: S1 offers **only** full strict reversal — there is no in-place restatement of a wrong-**amount** invoice. Re-posting under `INVOICE_POST` is blocked by the surviving dedup row, and `MAPPING_CORRECTION` is for suspense-remap only. Correcting a wrong amount (not a mapping) is therefore **upstream's responsibility** — a reversal plus a **new** `invoiceId` — or a Slice 3 credit/debit note. S1 deliberately does not provide amount-correction - `inst-rn-no-amount-fix` +5. [ ] - `p1` - **RETURN** negated lines for `postBalancedEntry(negatedLines, flow=REVERSAL, businessKey=reverses=entryId)` - `inst-rn-return` + +## 4. States (CDSL) + +### Invoice Journal Entry State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-invoice-entry` + +**States**: POSTED, REVERSED + +**Initial State**: POSTED (entries are immutable append-only facts; "REVERSED" is a derived lineage state — a reversal entry points at the original via `reverses_entry_id`; the original row itself is never mutated) + +**Transitions**: +1. [ ] - `p1` - **FROM** POSTED **TO** REVERSED **WHEN** a strict line-negation reversal commits (`flow=REVERSAL`, `businessKey=reverses=entryId`); at most **once total** per entry (partial unique index on `(tenant_id, reverses_period_id, reverses_entry_id) WHERE reverses_entry_id IS NOT NULL`) - `inst-st-entry-reverse` +2. [ ] - `p1` - **FROM** REVERSED **TO** (any) — **forbidden**: no reverse-of-a-reversal (`CANNOT_REVERSE_REVERSAL`, 422) and no second reversal of the original; correction continues upstream with a new `invoiceId` or a Slice 3 note - `inst-st-entry-terminal` + +### Suspense Line State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-suspense-line` + +**States**: PENDING, CLEARED, APPROVED_EXCEPTION + +**Initial State**: PENDING (line posted to `SUSPENSE` account class with `mapping_status = PENDING` on missing mapping, A5) + +**Transitions**: +1. [ ] - `p1` - **FROM** PENDING **TO** CLEARED **WHEN** the dual-control clearing completes: compensating reversal + corrected re-post under `MAPPING_CORRECTION` (`cpt-cf-bss-ledger-algo-suspense-remap`) - `inst-st-susp-cleared` +2. [ ] - `p1` - **FROM** PENDING **TO** APPROVED_EXCEPTION **WHEN** an approved exception is recorded (PRD) — lifts the period-close block without re-post - `inst-st-susp-exception` +3. [ ] - `p1` - **WHILE** any line is PENDING: period close is **blocked** (the Foundation's `TieOutJob` re-derives the open-PENDING set as its close-block input) - `inst-st-susp-close-block` + +## 5. Definitions of Done + +### Direct-Split Invoice Posting + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-invoice-post` + +The system **MUST** post a balanced S1 direct-split entry (DR AR gross incl. tax / CR Revenue / CR Contract-liability / CR Tax payable per `TaxBreakdown`) through the Foundation's `postBalancedEntry(lines, flow=INVOICE_POST, businessKey=invoiceId)`, with amounts tied to `sum(InvoiceItem) + TaxBreakdown`, rounding evidence stored once on the entry, no zero-amount Contract-liability placeholders, item-traceability refs and a non-null `revenue_stream` on every Revenue / Contract-liability line, tax dimensions on every `TAX_PAYABLE` line, `due_date` on the AR line, and the in-transaction local-snapshot recognized/deferred split derivation. + +**Implements**: +- `cpt-cf-bss-ledger-flow-invoice-post` +- `cpt-cf-bss-ledger-algo-invoice-line-build` + +**Touches**: +- API: `POST /v1/ledger/journal-entries` +- DB: `journal_entry`, `journal_line`, balance caches, `idempotency_dedup` (all Foundation-owned; via data-access API only) +- Entities: `JournalEntry`, `JournalLine`, `TaxBreakdown`, `InvoiceItem` + +### Account Mapping & Suspense Routing + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-invoice-suspense-routing` + +The system **MUST** resolve `(account_class, gl_code)` per line (Catalog snapshot first, then Contract override), route missing-mapping lines to `SUSPENSE` with `mapping_status = PENDING` (blocking period close) or hard-block with `ACCOUNT_MAPPING_MISSING` per tenant policy, emit the suspense-backlog aging metrics/alarms, and support the idempotent `MAPPING_CORRECTION` clearing flow with deterministic `correction_id`. + +**Implements**: +- `cpt-cf-bss-ledger-flow-invoice-suspense-clearing` +- `cpt-cf-bss-ledger-algo-invoice-account-mapping` +- `cpt-cf-bss-ledger-algo-suspense-remap` +- `cpt-cf-bss-ledger-state-suspense-line` + +**Touches**: +- API: `POST /v1/ledger/journal-entries` +- DB: `journal_line` (`mapping_status`), `idempotency_dedup` (via Foundation) +- Entities: `AccountMappingResolver`, `SuspenseLine` + +### Strict Line-Negation Reversal + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-invoice-reversal` + +The system **MUST** reverse a posted invoice entry via strict line-negation only (same accounts, flipped side, positive amount), at most once total per entry, with `reason` as payload only, rejecting reverse-of-a-reversal (`CANNOT_REVERSE_REVERSAL`) and providing no in-place amount correction, validating original-entry existence at post time. + +**Implements**: +- `cpt-cf-bss-ledger-flow-invoice-reverse` +- `cpt-cf-bss-ledger-algo-invoice-reversal-negation` +- `cpt-cf-bss-ledger-state-invoice-entry` + +**Touches**: +- API: `POST /v1/ledger/journal-entries/{entryId}/reversals` +- DB: `journal_entry` (`reverses_entry_id`, `reverses_period_id`), via Foundation +- Entities: `JournalEntry` + +### AR Balances & Aging Read + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-invoice-ar-aging` + +The system **MUST** serve current-cache balance reads and a read-time AR aging rollup per payer bucketed per `(payer, currency)` by days past due vs `due_date` (default current/1–30/31–60/61–90/90+, tenant-configurable), with no new posted state and no new lock rank; as-of balances are out of S1 scope. + +**Implements**: +- `cpt-cf-bss-ledger-flow-invoice-balance-inquiry` +- `cpt-cf-bss-ledger-algo-ar-aging-rollup` + +**Touches**: +- API: `GET /v1/ledger/balances`, `GET /v1/ledger/balances/ar-aging` +- DB: `ar_invoice_balance`, `ar_payer_balance` (Foundation-owned caches, read-only) +- Entities: `ArAgingBucket` + +### Invoice-Posting API Surface + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-invoice-api` + +The system **MUST** expose the six §7.1 endpoints per the `rest-api-design` standard behind the inbound API gateway (JSON, OAuth 2.0, tenant from authenticated context + RLS, `/v1` versioning, minor-unit money objects, never floats), with 201-first-post / 200-idempotent-replay semantics and RFC 9457 problem responses passing Foundation invariant codes through unchanged plus the three domain codes. + +**Implements**: +- `cpt-cf-bss-ledger-flow-invoice-post` +- `cpt-cf-bss-ledger-flow-invoice-reverse` +- `cpt-cf-bss-ledger-flow-invoice-balance-inquiry` + +**Touches**: +- API: `POST /v1/ledger/journal-entries`, `POST /v1/ledger/journal-entries/{entryId}/reversals`, `GET /v1/ledger/balances`, `GET /v1/ledger/balances/ar-aging`, `GET /v1/ledger/journal-entries/{entryId}`, `GET /v1/ledger/journal-lines` +- DB: none directly (all via Foundation data-access API) +- Entities: RFC 9457 problem model + +## 6. Acceptance Criteria + +Derived from the source design's testing architecture (§7.8). Mocking by level: Unit = in-memory fakes (including a fake Foundation API); Integration = real PostgreSQL via testcontainers behind the real Foundation; API = real DB + in-process HTTP, mock only the AuthZ enforcer + true external clients; E2E = nothing mocked. + +- [ ] **Unit:** S1 line builder — direct split; deferred-zero ⇒ no contract-liability line; gross AR incl. tax; Revenue/Contract-liability line always carries a stream; reversal side-flip with positive amount; `AccountMappingResolver` (Catalog-first then Contract override; missing ⇒ route-to-suspense with `mapping_status = PENDING` vs hard-block per tenant policy); `TaxBreakdown` accepted as-is (never recomputed) with tax dimensions populated on each `TAX_PAYABLE` line; AR aging bucket derivation (days-past-due vs `due_date`, per `(payer, currency)`); the builder calls `postBalancedEntry(flow=INVOICE_POST, businessKey=invoiceId)` with balanced lines (fake Foundation) +- [ ] **Integration (testcontainers Postgres, real Foundation):** closed-payer invoice post rejected (`PAYER_CLOSED`) while a reversal on the same payer still posts; a Revenue/Contract-liability line missing `revenue_stream` is rejected by the Foundation invariant; a suspense line blocks period close until cleared (the open-PENDING set re-derived by the tie-out); the `MAPPING_CORRECTION` corrected re-post replays idempotently on a retried half (deterministic `correction_id`); reverse-of-a-reversal rejected (`CANNOT_REVERSE_REVERSAL`); an entry is reversible at most once total even across two reverse calls with different reasons; amount-correction has no in-place path (re-post under `INVOICE_POST` blocked by the surviving dedup row) +- [ ] **API:** RFC 9457 mapping for the domain codes (`PAYER_CLOSED`, `CANNOT_REVERSE_REVERSAL`, `ACCOUNT_MAPPING_MISSING`) and pass-through of the Foundation invariant codes; replay (identical payload ⇒ `200` prior reference), conflict (different payload ⇒ `409`); `POST /journal-entries`, `POST /{entryId}/reversals`, `GET /balances`, `GET /balances/ar-aging`, `GET /journal-entries/{entryId}`, `GET /journal-lines` cursor pagination; audit-retrieval response carries who/when/source/correlation (AC #8) +- [ ] **E2E:** post invoice → read balance → read AR aging → reverse → tie-out, end-to-end with no mocks (through the real Foundation) +- [ ] **Concurrency (domain-specific):** concurrent posts of the same `invoiceId` ⇒ exactly one ledger effect; loser blocks then returns the prior reference (winner commits) or posts (winner aborts) — never an absent reference; two concurrent reversals of the same entry with different reasons ⇒ exactly one reversal, the other replays/409 (cross-table deadlock-freedom and first-touch upsert-race tests are Foundation-owned) + +## 7. Additional Context + +### 7.1 REST API Surface (feature-owned endpoints) + +**Conventions.** REST per the `rest-api-design` standard, behind the inbound API gateway. JSON; OAuth 2.0; tenant from the authenticated context (also enforced by RLS, §7.5). Versioned under `/v1`. All money fields are `{ "amountMinor": , "currency": "USD", "scale": 2 }` — never floats. Mutating posts are idempotent on a client-supplied business key (`invoiceId`). This is the **handler-facing HTTP surface** for invoice-posting; it funnels into the Foundation's data-access API (the post/reverse/read HTTP surface is handler-owned). The seller-provisioning endpoint is **not** here — it belongs to the Foundation. + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST` | `/v1/ledger/journal-entries` | Post an S1 invoice entry (the only post type in slice 1). Body: `invoiceId`, lines (or invoice items + `taxBreakdown` + revenue-stream), `effectiveAt`, mapping refs. The handler builds balanced lines and calls `postBalancedEntry(lines, flow=INVOICE_POST, businessKey=invoiceId)`. sub-resource create, was `:post-invoice`. | At-most-once per `(tenant, INVOICE_POST, invoiceId)` (Foundation `idempotencyClaim`). | +| `POST` | `/v1/ledger/journal-entries/{entryId}/reversals` | Post a strict line-negation reversal; calls `postBalancedEntry(negatedLines, flow=REVERSAL, businessKey=reverses=entryId)`. Body: `reason` (payload only). was `{entryId}:reverse` — colon custom methods don't route on axum 0.8 / matchit 0.8.4 (dynamic suffix unsupported). | At-most-once per `(tenant, REVERSAL, reverses=entryId)` — an entry is reversible **once total**. | +| `GET` | `/v1/ledger/balances` | Read balances (filter: `accountClass`, `currency`, `payerTenantId`, optional `invoiceId` for the AR-invoice grain). Warm read from the Foundation's balance cache. | Warm read from the balance cache. | +| `GET` | `/v1/ledger/journal-entries/{entryId}` | Retrieve a posted entry with its lines + source linkage + actor/correlation (AC #8). | — | +| `GET` | `/v1/ledger/journal-lines` | Paginated transaction history (cursor pagination; filter: payer, account class, period, source business id). | — | +| `GET` | `/v1/ledger/balances/ar-aging` | AR aging rollup per payer. Buckets derived **at read time** from `ar_invoice_balance` (`balance_minor > 0`, **days past due** = now − `due_date`); default buckets **current / 1–30 / 31–60 / 61–90 / 90+ days past due**, tenant-configurable; computed per `(payer, currency)`. A pure read over the existing cache — no new posted state, no new lock rank. | Warm read from the balance cache. | + +**Success / replay semantics (not errors):** a first successful post returns `201` with the posting reference. An idempotent replay (same key + identical payload) returns `200` with the **prior** posting reference (`entryId`, `postedAtUtc`, `status`), not an error body — resolved by the Foundation's `idempotencyClaim`. + +**As-of balance.** All balance reads return the **current** cached value. A point-in-time / as-of balance is **out of S1 scope**; when a reporting / reconciliation slice needs it, it is reconstructed from `journal_line` (the caller states the basis — `posted_at_utc` vs `effective_at`), never derived from the current cache. + +### 7.2 Problem Responses (RFC 9457) + +Error responses use `application/problem+json` (RFC 9457): `{ type, title, status, detail, instance, code, tenantId? }`. The universal-invariant codes (`LEDGER_ENTRY_UNBALANCED`, `LEDGER_ENTRY_EMPTY`, `MIXED_PAYER_TENANT`, `MISSING_PAYER`, `MIXED_LEGAL_ENTITY`, `IDEMPOTENCY_PAYLOAD_CONFLICT`, `NEGATIVE_BALANCE_VIOLATION`, `PERIOD_CLOSED`, `ACCOUNT_CLOSED`, `CURRENCY_SCALE_LOCKED`, `CLOCK_SKEW_QUARANTINE`, `TENANT_POSTING_LOCKED`, `LEDGER_ENTRY_TOO_LARGE`, `AMOUNT_OUT_OF_RANGE`) are raised by the **Foundation** on `postBalancedEntry` and surfaced unchanged through this endpoint. The codes below are **invoice-post-domain-specific** — raised by this feature: + +| `code` | HTTP | Trigger | +|--------|------|---------| +| `PAYER_CLOSED` | 422 | Invoice post against a payer with `payer_state.lifecycle_state = CLOSED` (AC #21); the Foundation asserts the payer-OPEN gate for payer-posting handlers, this handler is one. | +| `CANNOT_REVERSE_REVERSAL` | 422 | Reverse requested on an entry with `source_doc_type = REVERSAL` — no undo-the-undo chains. | +| `ACCOUNT_MAPPING_MISSING` | 422 | No Catalog/Contract mapping and tenant policy = hard-block (A5). | + +### 7.3 Events Surface + +The invoice-posting feature does **not** own an events surface of its own — success events and invariant alarms are emitted by the **Foundation** as a consequence of the posts this feature drives: + +- `billing.ledger.entry.posted` — emitted via the Foundation's transactional **outbox** after an invoice entry commits (`entryId`, `tenantId`, `sourceDocType`, `sourceBusinessId`, `postedAtUtc`, line summary). +- `billing.ledger.entry.reversed` — emitted via the outbox after a reversal commits (`entryId`, `reversesEntryId`, `reason`). +- `billing.ledger.invariant.alarm` — on zero-sum / negative-balance / idempotency-collision / clock-skew, on the Foundation's **separate committed transaction** so the alarm survives an aborting post. + +Payloads carry **internal identifiers only** (no PII). Relay ordering (per-tenant FIFO by `created_seq`) and the consumer registry are Foundation-owned. This feature adds **no** new event kinds and **no** new consumer-registry rows. + +### 7.4 Data Model (feature-owned tables) + +This feature defines **no** reference tables and **no** shared-table DDL — every table it reads or writes (the `journal_entry` / `journal_line` truth, the balance caches, `idempotency_dedup`, `tenant_account`, `fiscal_period`, `currency_scale_registry`, `tenant_posting_lock`, `payer_state`, `event_outbox`) is owned and DDL-defined by the **Foundation** (see 01-repository-foundation.md §Component Model), and seeded by the Foundation's seller-provisioning endpoint. The feature **asserts** these exist at post time (account `OPEN`, period `OPEN`, currency scale, payer OPEN) via the Foundation's gates; it never creates them. + +Invoice-post-specific reference data: **none** beyond what the Foundation already owns. The invoice-post domain consumes its mapping inputs (Catalog `glCode` / tax-category snapshot, Contract PO / allocation-group ids, the `TaxBreakdown`) as **per-request facts** delivered on the post call, not as ledger-owned reference rows. + +### 7.5 Security & AuthZ + +- **Tenant isolation (C1).** RLS on all (Foundation-owned) tables; the app sets `app.tenant_id` per request. The **no-mixed-payer-tenant** invariant is enforced at the journal level by the Foundation's commit trigger (mapped to `MIXED_PAYER_TENANT`); **no-mixed-legal-entity** is structural — `legal_entity_id` lives on `journal_entry` only — so an invoice entry can never straddle tenants or legal entities. +- **Tenant-hierarchy payment delegation — payer-resolution rule (in scope for this feature's contract).** `payer_tenant_id` is the **nearest `self_managed` ancestor-or-self** of `resource_tenant` (`self_managed` = billing boundary — a managed child consolidates up, a self-managed tenant is its own payer and never consolidates upward); AR posts **directly** to the resolved payer; `resource_tenant` is attribution metadata. **Resolution runs upstream** (Rating/Invoice) over the platform tenant tree + `self_managed`; the handler **records** the resolved `payer_tenant_id` on the lines and relies on the Foundation to enforce the structural invariants (payer present, no-mixed-payer-tenant) — neither the handler nor the Foundation walks the tenant tree on the write path. **(Rev — PRD contract, ledger-side; pending Rating/Invoice confirmation)** The posting call carries, beyond `payer_tenant_id`: `resource_tenant_id` and a **resolution-provenance stamp** (`resolved_as_of` + `tenant_tree_version_ref`), stored as line audit metadata so the resolution is reproducible. Resolution is pinned **as-of the originating business event** and is **never** re-resolved later. A call with no resolvable `payer_tenant_id` is **rejected** `MISSING_PAYER` (422) — never defaulted. **Tenant-tree mutation:** a later `self_managed` flip / re-parent **does not** retroactively change already-posted AR (posted facts immutable); future postings use the payer upstream resolves at their own event time; the ledger does **no** automatic re-attribution. **Deferred (post-MVP):** a ledger-side guard re-validating the resolved payer against the tree (Variant C); inter-tenant settlement transfer / payout / `PARENT_SUMMARY` summary invoice. (PRD § Multi-tenant; AC #27.) +- **Audit retrieval (AC #8).** "Who/when/source/correlation" is retrievable per entry via the Foundation's `journal_entry` columns (`posted_by_actor_id`, `origin`, `posted_at_utc`, `source_doc_type` / `source_business_id` / `reverses_entry_id`, `correlation_id`); human-readable PII is **not** stored here. +- **AuthZ.** Posting and reversal require billing-poster / approver scopes; reversal and material-backdating follow dual-control per policy (detailed RBAC in implementation). + +### 7.6 Feature Metrics + +Prometheus scrape metrics specific to the invoice-post domain; the engine-level metrics (post duration, balance-read duration, bill-run throughput, negative-balance / unbalanced / idempotency counters, tie-out variance, lock-wait) are Foundation-owned. + +| Metric (`ledger_*`) | Vector | Target threshold | Purpose | +|---------------------|--------|------------------|---------| +| `ledger_suspense_pending_lines_total` / `ledger_suspense_pending_age_seconds` (by tenant) | Reliability | warn > N days / page > M days | Suspense-backlog volume + age — surface un-mapped lines before they block close. | +| `ledger_post_blocked_missing_po_total` (by tenant) | Reliability | alarm on sustained rate | Posts blocked by a missing PO / allocation-group tag — surfaces Catalog tagging gaps before they stall cash collection. | + +### 7.7 NFR Mapping + +The invoice-post path inherits the Foundation's v1 SLOs (read p95 ≤ 200 ms, write p95 ≤ 500 ms, ≥ 2,000 invoices/min, ≤ 60 min/100k), since every invoice post and balance read funnels through the Foundation's data-access API. This feature introduces no separate NFR; its contribution to the hot-row risk (the `ar_payer_balance` payer-aggregate row credited on every invoice) is analysed in §7.9 and load-tested as part of the Foundation's B3 gate. + +### 7.8 Testing Architecture + +Correctness is the value of the ledger; per `design-standards.md`, the testing section is concrete. These are the **invoice-post domain** tests; the engine-level correctness tests (append-only enforcement, the deferrable leaf-partition constraint trigger, conditional no-negative CHECK, RLS, idempotency PK, deadlock-free lock order, first-touch upsert race, tie-out recompute) are Foundation-owned. Mocking by level: Unit = in-memory fakes (including a fake Foundation API); Integration = real PostgreSQL via testcontainers behind the real Foundation; API = real DB + in-process HTTP, mock only the AuthZ enforcer + true external clients; E2E = nothing mocked. The concrete test matrix is captured as the checklist in [6. Acceptance Criteria](#6-acceptance-criteria) (Levels 1–4 + concurrency). + +### 7.9 Risks, Open Questions & Deferred Work + +**Open decisions needing PM / Finance** specific to the invoice-post domain (defaults applied; see §1.6): + +| Topic | Default | Needs | +|-------|---------|-------| +| Missing-mapping policy | route-to-suspense + close-block, tenant-configurable | Finance default + per-tenant override? | +| Backdating threshold | 5 business days, range [1..30] | Confirm default + whether tenants override (evaluated by the Foundation's `FiscalPeriodGuard`) | +| AR aging buckets | read-time derivation from `ar_invoice_balance.due_date` (days past due); default current/1–30/31–60/61–90/90+; tenant-configurable | Finance (bucket config) | + +The money-type / NFR / tie-out / hot-row / bill-run-scale decisions are Foundation-level. + +**Known risks (domain).** (1) **Hot-row contention on `ar_payer_balance`** — a single-tenant bill run credits the payer-aggregate AR row on every invoice; the upsert row lock serializes these within a tenant. In S1 this is the only active non-shardable hot row (hard no-negative at the payer-aggregate grain); its ceiling MUST be load-tested before commit as part of the Foundation's B3 gate (Mode S enabled). The mitigation / fallback (per-worker partial sums merged on a short interval, with the un-sharded `ar_invoice_balance` per-invoice CHECK as the interim guard) is Foundation-owned. (2) **Suspense backlog stalling close** — mitigated by the aging metric + alarm surfacing un-mapped lines on the first affected invoice. (3) **PO / allocation-group tagging gaps stalling cash** — mitigated by the `ledger_post_blocked_missing_po_total` alarm. + +**Deferred work.** Amount-correction in place (— out of scope, use reversal + new `invoiceId` or a Slice 3 note); the sibling feature slices listed in §1.5. The seams the invoice-post domain relies on (account classes, reversal shape, FX-ready money fields, success-event outbox) are provided by the Foundation, so the sibling features attach without reshaping the posted journal. + +### 7.10 Decision Log (Needs Discussion) + +Consolidated decision log for the invoice-post-domain blocker items. **None block this feature spec** — all are recorded as assumptions in §1.6 / §7.9. The engine-level decisions (B2/B3/B4/B5/B11–B17 — NFR, hot-row scale, tie-out window, money type, partition/trigger hardening) live in the Foundation. + +| Item | Decision | Status | Owner | +|------|----------|--------|-------| +| Tax presentation: net vs gross | Ledger posts **gross** AR + a separate Tax-payable line; net/tax-split is export/inquiry presentation only | ✅ Resolved | Product | +| Missing account-mapping policy | route-to-suspense + period-close block; tenant-configurable to hard-block | ✅ Accepted default | — | +| Material-backdating threshold | 5 business days, range [1..30], tenant-overridable (A6; evaluated by the Foundation's `FiscalPeriodGuard`) | ✅ Accepted default | — | +| Suspense remap re-post flow | additive flow `MAPPING_CORRECTION` keyed `(tenant, MAPPING_CORRECTION, invoice_id:correction_id)` | ✅ Proposed default | — | +| AR aging buckets | read-time derivation from `ar_invoice_balance.due_date` (**days past due**, — supersedes the posting-date basis); default current/1–30/31–60/61–90/90+; tenant-configurable | ✅ Proposed default | Finance (bucket config) | +| PO / allocation-group presence gate | Gate stays; residual is **Catalog data quality** (softened by); monitored via `ledger_post_blocked_missing_po_total` + alarm so Catalog gaps surface before stalling cash | ✅ Accepted 2026-06-16 (data-quality risk) | Catalog (data) | + +### 7.11 References + +- **Repository-foundation** (see 01-repository-foundation.md §Component Model) — the shared posting engine this feature posts through: schema/DDL, universal invariants, total lock order, data-access API (`postBalancedEntry` / `applyBalanceDeltas` / `idempotencyClaim` / `pinOpenPeriod` / `applyCounterDelta`; read-then-write ordering via `SERIALIZABLE`/SSI, not a locked-read op), and seller-provisioning endpoint. +- [PRD.md](../PRD.md) — upstream PRD (posting rules S1–S6, account classes, invariants, ACs, reconciliation, NFRs). +- Parent module scope (AR, ledger, financial posting) and program billing architecture — Billing Module / Billing System PRDs (legacy refs preserved in the source design). +- Contracts & Agreements, Metering & Pricing, Product Catalog & Marketplace, Subscriptions & Entitlements PRDs — interface sources (PO tags, billable items, glCode/tax category, recurring charges). +- Inbound API gateway ADR — API exposure pattern (C4). +- Azure billing domain model design — related BSS domain model (this design diverges to integer minor-unit money per AC #16). diff --git a/gears/bss/ledger/docs/design/02-audit-immutability-observability.md b/gears/bss/ledger/docs/design/02-audit-immutability-observability.md new file mode 100644 index 000000000..768b5b208 --- /dev/null +++ b/gears/bss/ledger/docs/design/02-audit-immutability-observability.md @@ -0,0 +1,821 @@ + + + + +# DESIGN — Audit, Immutability & Observability (Slice 6) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope Boundaries](#15-scope-boundaries) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Design-Introduced Names](#17-design-introduced-names) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Verify Tamper Evidence & Freeze Scope](#verify-tamper-evidence--freeze-scope) + - [Retrieve Audit Trail & Document History](#retrieve-audit-trail--document-history) + - [Export Audit Pack (Cross-Tenant Elevation)](#export-audit-pack-cross-tenant-elevation) + - [Apply GDPR Erasure](#apply-gdpr-erasure) + - [Record Re-Identification](#record-re-identification) + - [Annotate Entry Metadata (Controlled Non-Financial Change)](#annotate-entry-metadata-controlled-non-financial-change) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Chain Tip Advance (ChainWriter)](#chain-tip-advance-chainwriter) + - [Canonical Row Hash](#canonical-row-hash) + - [Chain Verification (Verifier)](#chain-verification-verifier) + - [Tamper Freeze Guard](#tamper-freeze-guard) + - [Cross-Tenant Access Gate](#cross-tenant-access-gate) + - [Policy Version Guard](#policy-version-guard) + - [Chain Checkpointing, Rotation & Restore Re-Anchor](#chain-checkpointing-rotation--restore-re-anchor) +- [4. States (CDSL)](#4-states-cdsl) + - [Scope Freeze State Machine](#scope-freeze-state-machine) + - [Payer PII Map State Machine](#payer-pii-map-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Tamper-Evidence Chain](#tamper-evidence-chain) + - [Write-Path Freeze](#write-path-freeze) + - [Secured Audit Store](#secured-audit-store) + - [Cross-Tenant Elevation Gate](#cross-tenant-elevation-gate) + - [PII Minimization & Erasure](#pii-minimization--erasure) + - [Controlled Metadata Annotation](#controlled-metadata-annotation) + - [Policy-Version Immutability](#policy-version-immutability) + - [Alarm Catalog Ownership](#alarm-catalog-ownership) + - [Retention, Archival & Checkpoints](#retention-archival--checkpoints) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Additional Context](#7-additional-context) + - [7.1 REST API Surface (feature-owned endpoints)](#71-rest-api-surface-feature-owned-endpoints) + - [7.2 Problem Responses (RFC 9457)](#72-problem-responses-rfc-9457) + - [7.3 Events Surface](#73-events-surface) + - [7.4 Data Model (feature-owned tables)](#74-data-model-feature-owned-tables) + - [7.5 Alarm Catalog (normative)](#75-alarm-catalog-normative) + - [7.6 Security & AuthZ](#76-security--authz) + - [7.7 Feature Metrics](#77-feature-metrics) + - [7.8 NFR Mapping](#78-nfr-mapping) + - [7.9 Testing Architecture](#79-testing-architecture) + - [7.10 Risks, Open Questions & Deferred Work](#710-risks-open-questions--deferred-work) + - [7.11 Decision Log (Needs Discussion)](#711-decision-log-needs-discussion) + - [7.12 References](#712-references) + + + +## 1. Context + +### 1.1 Overview + +Makes the Billing Ledger **tamper-evident, immutable, auditable, and privacy-compliant**: the posted-journal store carries a verifiable **per-tenant hash chain** with a serialized chain tip; financial facts are append-only with corrections via compensating entries; a hash-chained **secured audit store** holds investigation-grade forensics; PII is minimized on operational surfaces and reconcilable with GDPR erasure; policy/snapshot versions make history reproducible; a chain-verification failure **freezes** the affected write scope; and a single normative **alarm catalog** governs every slice's invariants (PRD § Immutable audit logs, § Cross-cutting requirements; AC #8/#15/#17/#19/#22; manifest §9). + +This feature reuses the PRD glossary and **inherits from the Foundation** (see 01-repository-foundation.md §Component Model): append-only journal (REVOKE UPDATE/DELETE + trigger), the reserved tamper-evidence seam (`created_seq` BIGSERIAL, `row_hash`, `prev_hash`), the index `journal_entry(tenant_id, created_seq)`, the **separate-committed** audit/alarm transaction, the total fixed lock order, and the idempotent-replay contract. **This slice owns the cross-cutting governance**: it **activates** the tamper-evidence chain, defines the **secured audit store**, **PII/erasure**, **policy-versioning immutability**, **audit retrieval**, **retention/archival**, and the **full alarm catalog**. Aligns with manifest **§9 Security, Compliance, and Audit**. + +**Partition vs chain scope (corrected).** Slice 1 physically **partitions** `journal_entry`/`journal_line` by `period_id` (YYYYMM). The tamper **chain scope is the tenant**, not the partition: the chain is ordered across all of a tenant's period partitions. `created_seq` is a single global BIGSERIAL assigned at INSERT (gaps on rollback) — it is **metadata only**; the authoritative previous-link comes from a serialized per-tenant **chain tip**, never from `created_seq` arithmetic. + +**Canonical slice numbering:** 1 posting-engine-core, 2 payments-allocation, 3 adjustments-notes-refunds, 4 asc606-recognition, 5 fx-multicurrency, **6 audit-immutability-observability (this feature)**, 7 reconciliation-export, 8 other. + +**Traces to**: `cpt-cf-bss-ledger-fr-immutable-audit-logs`, `cpt-cf-bss-ledger-fr-posting-immutability`, `cpt-cf-bss-ledger-fr-audit-retrieval`, `cpt-cf-bss-ledger-fr-right-to-erasure`, `cpt-cf-bss-ledger-fr-policy-versioning-immutability`, `cpt-cf-bss-ledger-fr-negative-balance-invariants`, `cpt-cf-bss-ledger-fr-idempotent-replay-contract`, `cpt-cf-bss-ledger-fr-manual-adjustment-governance`, `cpt-cf-bss-ledger-fr-tenant-isolation-posting`, `cpt-cf-bss-ledger-nfr-tamper-evidence-cadence`, `cpt-cf-bss-ledger-nfr-data-residency`, `cpt-cf-bss-ledger-nfr-rto-rpo`, `cpt-cf-bss-ledger-nfr-availability` + +### 1.2 Purpose + +Implements **Slice 6 (audit / immutability / observability)** of the Billing Ledger PRD — the cross-cutting governance layer over Slices 1–5/7: verifiable immutability of posted financial facts (hash chain + freeze), the investigation-grade secured audit store, GDPR-compatible PII handling under ≥7-year retention, reproducible history under policy versioning, and the normative alarm catalog every posting slice emits into. + +**Requirements**: `cpt-cf-bss-ledger-fr-immutable-audit-logs`, `cpt-cf-bss-ledger-fr-posting-immutability`, `cpt-cf-bss-ledger-fr-audit-retrieval`, `cpt-cf-bss-ledger-fr-right-to-erasure`, `cpt-cf-bss-ledger-fr-policy-versioning-immutability`, `cpt-cf-bss-ledger-nfr-tamper-evidence-cadence` + +**Use cases**: `cpt-cf-bss-ledger-usecase-ledger-inquiry`, `cpt-cf-bss-ledger-usecase-exception-resolution` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-bss-ledger-actor-auditor` | Tenant-scoped audit retrieval + document history; audit-pack export; tamper-status review; clears freezes with Architecture; DPO/investigator-scoped erasure and re-identification requests | +| `cpt-cf-bss-ledger-actor-finance-ops` | Controlled non-financial metadata changes (single-control MVP); consumes inquiry drill-downs | +| `cpt-cf-bss-ledger-actor-revenue-assurance` | Receives routed alarms per the catalog (negative-balance, suspense, attribution drift, dormant credit, …) | +| `cpt-cf-bss-ledger-actor-cfo` | Consumer of audit-pack / inquiry outputs for external audit and compliance sign-off | +| `cpt-cf-bss-ledger-actor-erp-gl` | Downstream consumer whose export keys must stay immutable under policy/GL-mapping version changes (AC #15; export generation owned by Slice 7) | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — § Immutable audit logs, § Cross-cutting requirements, § Edge cases (right-to-erasure), § Observability — invariant alarms, AC #8/#12/#15/#17/#19/#22; manifest §9 +- **Design**: [01-repository-foundation.md](./01-repository-foundation.md) — see 01-repository-foundation.md §Component Model (Foundation append-only journal, tamper seam, separate-committed alarm transaction, outbox relay, tamper chain components) +- **Dependencies**: `invoice-posting` and the other posting slices (they *emit* alarms + posted facts; this feature governs storage/verification/retrieval/freeze); Repository-foundation (engine mechanics) + +### 1.5 Scope Boundaries + +**In scope**: + +- immutable financial store + tamper-evident audit (HIGH) +- journal auditability (system vs user-initiated) + immutability of posted financial facts +- controlled non-financial-metadata change +- secured audit store +- no-direct-PII on operational surfaces +- right-to-erasure vs ≥7-year retention + policy-governed recorded re-identification (AC #22, RD) +- policy-versioning historical immutability incl. export-key immutability (AC #15) +- tenant-scoped audit retrieval + audited elevated cross-tenant access (AC #8) +- negative-balance + idempotent-replay alarms (AC #17/#19) + the full alarm catalog +- tamper-verification-failure alarm + **write-path freeze** +- retention/archival + queryability + chain rotation +- inquiry/audit-pack export with full linkage +- RTO/RPO + geo-redundancy/tenant-data-residency (incl. WORM archive) + tamper-verify cadence NFRs + +**Out of scope / Non-goals**: + +- **The posting flows themselves** (S1–S6) — Slices 1–5/7 (they *emit* alarms + posted facts; this feature governs storage/verification/retrieval/freeze). +- **Reconciliation / ERP export / export-key generation + period-close orchestration** — Slice 7 (this feature supplies the alarm catalog + audit pack + the export-key-immutability rule it must honor). +- **Platform-wide audit/observability infrastructure** (org SIEM, OTel collectors, central monitoring) — this feature defines the **contract**, not the internals. +- **Engine mechanics** — Slice 1 / Foundation. + +**Feature boundary & inputs.** **Consumed:** posted journal entries (to chain + verify); invariant alarms + conflicting-payload captures + manual-adjustment reason/actor (to the secured audit store). **Produced:** chain-verification results + freeze; tenant-scoped audit retrieval + audit pack; erasure tombstones; the normative alarm catalog. Downstream consumers: Finance / Audit (inquiry + audit-pack export), Platform SIEM / OTel / monitoring (contract only), CRM / DPO (PII resolution + erasure). + +**Component summary.** The chain tip is advanced **in the post transaction (Mode S — MVP mode, 2026-06-15)**; the async micro-batch `ChainWriter` (**Mode A**) is the **post-MVP** optimization that removes the post-txn chain lock (same seam, no migration); `Verifier` re-walks on cadence; `TamperFreezeGuard` blocks posts to a frozen scope; `SecuredAuditStore` is the RBAC + chained forensic store; `PiiMinimizer` + `ErasureHandler` handle PII; `PolicyVersionGuard` enforces historical immutability; `AlarmCatalog` + Router owns alarm routing; `InquiryService` + `AuditPackExporter` serve Finance/Audit. + +### 1.6 Constraints & Assumptions + +Inherits Slice 1 C1–C4. Slice-6-specific (defaults; open items → §7.11): + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| G1 | Tamper-evidence mechanism | **Hash-chained** posted journal (per-tenant, serialized chain tip) + **WORM / immutable-object** archival; production MUST be tamper-evident, sandboxes MAY relax. Pluggable per the seam. **✅ Ratified 2026-06-17 (decision 3.A): `H` = SHA-256; WORM = S3 Object Lock (or cloud-equivalent immutable object store).** | PRD | +| G2 | Tamper-verify cadence | **On-write incremental** `prev_hash` linkage check (near-free) **+** periodic full re-walk **+** on-read spot checks. Sampled-only is **non-compliant** as the sole production mechanism. **✅ Ratified 2026-06-16: full re-walk = daily over the entire live chain; ≤ 24 h detection window; global (not per-tenant); archive via signed checkpoints.** | PRD | +| G3 | Data residency / geo-redundancy | **🔮 Deferred post-MVP: tenant data residency is out of MVP scope.** v1 ships a **single installation** — all data lives where the platform is deployed; there is **no** region-pinning and **no** cells. Multi-AZ + RTO/RPO (G5) stay in scope. **Design retained for the post-MVP rollout:** region-pinning of posted journal + secured audit + PII map + WORM/cold archive (no copy crosses the boundary in primary/replica/DR/archive; cross-region replication disabled on the archive), via **regional deployment cells** (one PostgreSQL cluster + workers + archive per boundary, tenant pinned wholly to one cell at onboarding — `period_id` partitioning cannot pin a tenant inside a shared cluster). **Consequence:** a tenant with a hard residency requirement **cannot be onboarded in MVP** (Sales/Legal must know). `period_id` partitioning stays regardless (retention/tie-out, residency-independent). | PRD | +| G4 | Mutable non-financial attributes | A **fixed allow-list** (description/memo, dispute marker, export/recon flags) MAY change **only** via an append-only `metadata_change_log` (current-value view), **never** as updatable columns on journal tables; financial fields **never**. **Ratified 2026-06-10 — closed list of exactly three (§7.11 G4).** (See the `entry_annotation` Variant C remodel note in §7.4.) | PRD | +| G5 | RTO / RPO | RTO ≤ 60 min; RPO ≤ 5 min per region (baseline inherited). | PRD | +| G6 | Retention | ≥ legal/contractual minimum (**default ≥ 7 years**) for journal lines + internal references (not the bound human PII). | PRD | + +**Global config artifacts vs cells (G3 corollary).** Platform-level configuration artifacts (statutory registry, R1/R2 value tables, Tax-Engine view matrix, FX provider config) are **globally versioned** and distributed to every cell with a **version watermark**; postings **stamp the consumed version** (extends the Slice 4 `policy_ref` pattern). A cell lagging the watermark is detectable, never a silent behavior change. + +### 1.7 Design-Introduced Names + +| Name | Meaning | +|------|---------| +| `chain_state` | Per-tenant **chain tip** `(tenant_id, last_row_hash, last_entry_id, last_period_id, last_seq)` — advanced by the ChainWriter (Mode A) or serialized in the post txn (Mode S — MVP reads the tip lockless under **SERIALIZABLE**/SSI; the literal `FOR UPDATE` row lock is deferred until SecureORM exposes a locking read) so each new `row_hash` links the prior committed one linearly; `last_period_id` advances atomically with `last_entry_id`. | +| **Tamper-evidence chain** | `row_hash = H(domain_sep ‖ canonical-financial-fields ‖ prev_hash)`; `prev_hash` = the tenant's `chain_state.last_row_hash`. Default mechanism (pluggable). | +| `scope_freeze` | A flag the Verifier (or manual override) sets on a tenant/scope when chain verification fails; checked **on write** to reject further posts. | +| **Secured audit store** | RBAC-restricted, **hash-chained / WORM** investigation-grade store (manifest §9): forensics, conflicting-payload captures, cross-tenant + re-identification + erasure records, PII where policy requires. | +| **Payer-tenant-id ↔ PII map** | `payer_tenant_id` (the actual journal_line column) → human PII pointer (secured store / CRM); erasure **tombstones** the reverse-lookup. | + +## 2. Actor Flows (CDSL) + +### Verify Tamper Evidence & Freeze Scope + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-tamper-verify` + +**Actor**: `cpt-cf-bss-ledger-actor-auditor` (responds to the Verifier's finding; the re-walk itself runs on schedule — see `cpt-cf-bss-ledger-algo-chain-verify`) + +**Success Scenarios**: +- Daily full re-walk over the entire live chain completes with no mismatch; `billing.ledger.tamper.verified` emitted; tamper-status reflects the latest result +- A verification mismatch sets a `scope_freeze` row, raises the `tamper-verify-failed` alarm (Critical: page Audit + Architecture), and the next in-scope post is rejected + +**Error Scenarios**: +- Post attempted against a frozen scope → `TAMPER_VERIFICATION_FAILED` (409 on write; surfaced read-side too) +- Freeze cleared without Audit/Architecture sign-off — not possible: the freeze is **sticky** until cleared, and set/clear each write a `freeze-set-clear` secured-audit record + +**Steps**: +1. [ ] - `p1` - Scheduled Verifier: run chain verification per `cpt-cf-bss-ledger-algo-chain-verify` (on-write linkage + daily full re-walk + on-read spot checks; G2) - `inst-tv-run` +2. [ ] - `p1` - **IF** a verification mismatch is found: INSERT a `scope_freeze` row (scope = tenant or tenant+period_id; `set_by = Verifier` or manual) and raise the **tamper-evidence-verification-failure** alarm — **Critical: page Audit + Architecture**; emit `billing.ledger.tamper.failed` - `inst-tv-freeze` +3. [ ] - `p1` - `TamperFreezeGuard` runs inside every post transaction (a `FiscalPeriodGuard`-adjacent step): a post to a frozen scope is rejected with `TAMPER_VERIFICATION_FAILED` per `cpt-cf-bss-ledger-algo-tamper-freeze-guard` - `inst-tv-guard` +4. [ ] - `p1` - Auditor reviews via API: GET /v1/ledger/audit/tamper-status (latest chain-verification result + freeze state per scope; Audit scope, **cross-tenant** endpoint) - `inst-tv-status` +5. [ ] - `p1` - The freeze is **sticky** until Audit/Architecture clears it; clearing records `cleared_by` + `cleared_at` and writes a `freeze-set-clear` secured-audit record in the same transaction - `inst-tv-clear` +6. [ ] - `p1` - **RETURN** verification result; frozen scope remains write-blocked until cleared - `inst-tv-return` + +### Retrieve Audit Trail & Document History + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-audit-retrieval` + +**Actor**: `cpt-cf-bss-ledger-actor-auditor` + +**Success Scenarios**: +- For a posted entry: **who** (`posted_by_actor_id`/`origin`), **when** (`posted_at_utc`), **source linkage**, **correlation** — retrievable, **tenant-scoped by default** (AC #8) +- One source document's full posting history — linked entries, reversals, notes, allocations, refunds, schedules — aggregated via the existing linkage columns +- `InquiryService` filters by payer/period/account-class/legal-entity and drills **balance → journal entry → source document** (PRD UI § Ledger inquiry) + +**Error Scenarios**: +- Cross-tenant read without elevation → `CROSS_TENANT_ACCESS_DENIED` (403) + +**Steps**: +1. [ ] - `p1` - API: GET /v1/ledger/audit/journal-entries/{entryId} — audit retrieval: who/when/source/correlation (AC #8); tenant-scoped; takes **no** `tenantId` (tenant from the authenticated context → scope predicate) - `inst-ar-entry` +2. [ ] - `p1` - API: GET /v1/ledger/audit/documents/{sourceDocType}/{sourceBusinessId}/history — one document's posting history: linked entries, reversals, notes, allocations, refunds, schedules; tenant-scoped - `inst-ar-history` +3. [ ] - `p1` - **IF** the read must widen beyond the caller's home tenant: route through `cpt-cf-bss-ledger-algo-cross-tenant-gate` (forensic-gated); routine parent→sub-tenant reads instead use the Respect-subtree widening (no forensic record; §7.6) - `inst-ar-elevation` +4. [ ] - `p1` - **RETURN** 200 audit trail / document history - `inst-ar-return` + +### Export Audit Pack (Cross-Tenant Elevation) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-audit-pack-export` + +**Actor**: `cpt-cf-bss-ledger-actor-auditor` + +**Success Scenarios**: +- Audit pack (CSV/PDF) exported asynchronously with the **full linkage chain** for external auditors; `billing.ledger.audit_pack.exported` emitted +- Cross-tenant pack request with valid `target_scope` + `reason` writes the same-txn `cross-tenant-access` record before any foreign row is returned + +**Error Scenarios**: +- `target_scope` ≠ home tenant with no `reason` → `MISSING_INVESTIGATION_REASON` (400, pre-read) +- Role not Finance/Audit/DPO → `CROSS_TENANT_ACCESS_DENIED` (403) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/audit/packs (filter → full-linkage CSV/PDF; async; Finance/Audit scope; **cross-tenant** endpoint carrying the elevation contract) - `inst-ap-api` +2. [ ] - `p1` - **IF** `target_scope` ≠ home tenant: execute `cpt-cf-bss-ledger-algo-cross-tenant-gate` (authorize role → require `reason` + `target_scope` pre-read → same-txn `SECURED_AUDIT_RECORD(event_type=cross-tenant-access)` → only then read foreign rows) - `inst-ap-gate` +3. [ ] - `p1` - `AuditPackExporter` assembles the pack with the full linkage chain (balance → journal entry → source document) and exports asynchronously (≤ 15 min target, decision 7) - `inst-ap-assemble` +4. [ ] - `p1` - **RETURN** 202 Accepted (async export); emit `billing.ledger.audit_pack.exported` - `inst-ap-return` + +Example — audit pack for a non-home tenant under dispute (snake_case wire): + +```http +POST /v1/ledger/audit/packs +X-Investigation-Reason: Dispute #4821 - chargeback review +Content-Type: application/json + +{ "target_scope": "0192f7a0-acme-7c1d-9e2b-000000000001", + "reason_code": "DISPUTE_INVESTIGATION", + "filter": { "period_id": "202605", "account_class": "AR" } } +``` + +### Apply GDPR Erasure + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-pii-erasure` + +**Actor**: `cpt-cf-bss-ledger-actor-auditor` (DPO scope) + +**Success Scenarios**: +- Journal lines + internal references **remain intact and queryable**; `payer_pii_map` is **tombstoned** (`erased=true`) so `payer_tenant_id → PII` yields a tombstone; the erasure is recorded as a **chained** `SECURED_AUDIT_RECORD(event_type=erasure)` (non-repudiable); the chain **still verifies** + +**Error Scenarios**: +- Caller without DPO scope → `CROSS_TENANT_ACCESS_DENIED` (403) +- Missing `reason` / `target_scope` on cross-tenant call → `MISSING_INVESTIGATION_REASON` (400) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/audit/erasure (DPO scope; **cross-tenant** endpoint; journal lines untouched) - `inst-er-api` +2. [ ] - `p1` - DB: UPDATE `payer_pii_map` SET `erased = true` (tombstone) for `(tenant_id, payer_tenant_id)`; the reverse-lookup now yields a tombstone - `inst-er-tombstone` +3. [ ] - `p1` - INSERT chained `SECURED_AUDIT_RECORD(event_type=erasure)` in the same transaction - `inst-er-record` +4. [ ] - `p1` - The tamper chain hashes **only** financial fields + internal refs (PII excluded), so erasure never breaks it; the **≥7-year retention applies to journal lines + references, not the bound PII** (AC #22, RD) - `inst-er-chain-safe` +5. [ ] - `p1` - **RETURN** 200; emit `billing.ledger.erasure.applied` - `inst-er-return` + +**PII minimization rules (normative, AC #22).** **Operational surfaces** carry **no direct PII** — the PRD's concrete prohibited fields are **customer name, email, phone, payment-instrument details, street address** (PRD). Internal identifiers only (tenant, `payer_tenant_id`, business-doc ids, correlation id); actor as system/authenticated-user id **without profile attributes**. Posted journal lines reference the **immutable internal `payer_tenant_id`**, never raw PII. + +### Record Re-Identification + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-reidentification` + +**Actor**: `cpt-cf-bss-ledger-actor-auditor` (DPO/investigator scope) + +**Success Scenarios**: +- Resolving a tombstoned/active `payer_tenant_id` to human PII for an authorized investigation is **policy-governed and recorded** as a `SECURED_AUDIT_RECORD(event_type=re-identification, actor, reason, scope)` — same pattern as cross-tenant access (PRD) + +**Error Scenarios**: +- Unauthorized role or missing reason → rejected pre-read (403 / 400) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/audit/reidentify (DPO/investigator scope; **cross-tenant** endpoint carrying the elevation contract) - `inst-ri-api` +2. [ ] - `p1` - Execute `cpt-cf-bss-ledger-algo-cross-tenant-gate` with `event_type = re-identification` — the same-txn audit record is a precondition of the read - `inst-ri-gate` +3. [ ] - `p1` - **RETURN** 200 resolved PII pointer; emit `billing.ledger.reidentification.recorded` - `inst-ri-return` + +### Annotate Entry Metadata (Controlled Non-Financial Change) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-metadata-annotate` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-ops` + +**Success Scenarios**: +- An allow-listed non-financial attribute (G4) is changed via the annotation overlay; before/after + actor + reason recorded; journal tables untouched + +**Error Scenarios**: +- Field maps to a journal **financial** column → `IMMUTABLE_FINANCIAL_FIELD` (409, rejected **before any DB write**) +- Field not on the G4 allow-list → `ATTRIBUTE_NOT_MUTABLE` (400) + +**Steps**: +1. [ ] - `p1` - API: PATCH /v1/ledger/journal-entries/{entryId}/metadata (controlled change to an **allow-listed non-financial** attribute; never financial fields). (Post-remodel the action is renamed `metadata` → `annotate`, surface `PATCH …/annotation`; see §7.4 `entry_annotation`) - `inst-ma-api` +2. [ ] - `p1` - Validate at the **API layer**: **IF** the field maps to a journal **financial** column **RETURN** 409 `IMMUTABLE_FINANCIAL_FIELD` **before any DB write** - `inst-ma-financial` +3. [ ] - `p1` - **IF** the field is not on the G4 allow-list **RETURN** 400 `ATTRIBUTE_NOT_MUTABLE` - `inst-ma-allowlist` +4. [ ] - `p1` - Posted **financial** facts are append-only (Slice 1 REVOKE UPDATE/DELETE + trigger); corrections are compensating/reversal entries only. **Non-financial** metadata is **never** an updatable column on the journal tables — it lives in the annotation overlay (originally an append-only `metadata_change_log` + current-value view; remodeled to `entry_annotation`, §7.4), so the Slice 1 REVOKE stays intact and the **tamper chain does not cover** the mutable-metadata table - `inst-ma-overlay` +5. [ ] - `p1` - Record the change **history** (before/after + actor + reason) in the secured-audit chain as a `metadata-change` record - `inst-ma-history` +6. [ ] - `p1` - **(✅ 2026-06-24, @vstudzinskyi) MVP is single-control:** one actor authorized by the `(entry, metadata)` PEP gate, with the change recorded (actor + reason + before/after); a two-person **dual-control** (requester ≠ approver) is **not enforced in MVP** — the G4 set is a closed, narrow, non-financial allow-list, the log is append-only and reversible by a further edit, and the actor+reason record carries accountability; dual-control is deferred post-MVP pending a Finance requirement (§7.11) - `inst-ma-single-control` +7. [ ] - `p1` - Every posted entry records **system-generated vs user-initiated** (Slice 1 `origin` + `posted_by_actor_id`) - `inst-ma-origin` +8. [ ] - `p1` - **RETURN** 200 updated annotation - `inst-ma-return` + +## 3. Processes / Business Logic (CDSL) + +### Chain Tip Advance (ChainWriter) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-chain-writer` + +**Input**: A committing posted journal entry (Mode S) or the set of committed-but-unchained entries (`row_hash IS NULL`, Mode A) + +**Output**: Entry sealed into the per-tenant tamper chain (`row_hash`, `prev_hash`, `prev_entry_id`, `prev_period_id`); `chain_state` advanced + +**Scope = tenant.** The chain links a tenant's posted entries in **chain-tip acquisition order** (which may differ from `created_seq` order under concurrency / backdating), **across** period partitions (physical partition key = `period_id`; chain key = `tenant_id`). + +**(🔄 revised 2026-06-15) Chaining mode — MVP ships synchronous (Mode S); async (Mode A) is the post-MVP optimization.** The per-tenant chain tip can be advanced two ways; **the seam is identical** (`row_hash`/`prev_hash`/`prev_entry_id` + `chain_state`), so Mode A is addable later **without migration**. The 2026-06-15 decision: **MVP = Mode S only**; **Mode A deferred post-MVP**. + +**Steps**: +1. [ ] - `p1` - **Mode S — synchronous in-txn tip (MVP mode — 2026-06-15):** serialize `chain_state(tenant_id, last_row_hash, last_entry_id, last_period_id, last_seq)` inside the post transaction — **(impl note, 2026-06-24)** the MVP reads the tip **lockless** and relies on the post's **SERIALIZABLE** isolation (SSI aborts the loser of two concurrent seals, which retries from a fresh tip); the literal `FOR UPDATE` row lock is **deferred** until SecureORM exposes a locking read (`bss-ledger` `ChainStateRepo`) - `inst-cw-mode-s` +2. [ ] - `p1` - Record on the entry: `prev_hash` = the read `last_row_hash`; `(prev_period_id, prev_entry_id)` = the read `(last_period_id, last_entry_id)` (resolvable composite back-pointer — `last_period_id` lives on the tip, so filling the pointer never scans partitions); compute `row_hash` per `cpt-cf-bss-ledger-algo-canonical-hash`; `chain_state` advances on commit. **Zero unchained window** (the chain is complete at commit) - `inst-cw-seal` +3. [ ] - `p1` - **Known cost (accepted for MVP):** the chain-tip row is a **per-tenant hot row** — a per-tenant serializer on top of the Slice 1 credit-side singletons — acquired **last** in the deterministic lock order (after idempotency_dedup → fiscal_period → balance rows → recognition/exposure). **B3 load-test MUST run with Mode S enabled** (with sync-replication lock-hold inflation), load-tested like `ar_payer_balance`; if throughput is insufficient, Mode A is the planned post-MVP remedy - `inst-cw-hot-row` +4. [ ] - `p2` - **Mode A — async micro-batch chain writer (🔮 deferred post-MVP):** removes the per-tenant post-txn serialization: a single per-tenant worker (advisory-locked singleton in `ledger-workers`) reads committed-but-unchained entries (`row_hash IS NULL`) on an MVCC snapshot in the deterministic total order the Foundation defines for cache rebuild (`posted_at_utc`, `created_seq` tiebreak), computes `prev_hash`/`row_hash`/`prev_entry_id` in batches via a narrow `SECURITY DEFINER` chain-writer function (REVOKE UPDATE intact; chain columns set **only from NULL**, never overwritten), and advances `chain_state` atomically. **No lock in the post txn**; trade-off = a bounded committed-but-unchained window with `ledger_chain_lag_seconds` alarming above a threshold. Acceptable because the **primary** immutability control is the Slice 1 REVOKE + trigger (the chain is *evidence*) and `scope_freeze` is unchanged. **Added post-MVP without migration** when Mode S throughput needs it - `inst-cw-mode-a` +5. [ ] - `p1` - In **both** modes the chain is strictly linear (no two entries share a `prev_entry_id`) and **gap-tolerant**: a rolled-back entry never enters the chain, so `created_seq` gaps do not break it (the chain follows the explicit back-pointer, never `created_seq` arithmetic). Tip and back-pointer resolution into the partitioned journal (Verifier restart, pointer fill) uses Slice 1's covering index `journal_entry(tenant_id, entry_id) INCLUDE (period_id)` — never an all-partition scan - `inst-cw-linear` +6. [ ] - `p1` - **RETURN** sealed entry + advanced tip - `inst-cw-return` + +### Canonical Row Hash + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-canonical-hash` + +**Input**: A posted journal entry with its lines, the tenant's current `prev_hash` + +**Output**: Byte-reproducible `row_hash` + +**Canonical hash (byte-reproducible, normative field list):** + +```text +row_hash = H(domain_sep ‖ tenant_id ‖ entry_id ‖ period_id ‖ legal_entity_id ‖ entry_currency + ‖ source_doc_type ‖ source_business_id ‖ reverses_entry_id ‖ reverses_period_id + ‖ effective_at ‖ posted_at_utc ‖ origin ‖ posted_by_actor_id + ‖ for-each-line in line_id order( + account_id, account_class, gl_code /* "account as posted" is financially binding */, + side, amount_minor, currency, currency_scale, functional_amount_minor, functional_currency, + payer_tenant_id, seller_tenant_id, resource_tenant_id, invoice_id, revenue_stream, + tax_jurisdiction, tax_filing_period, tax_rate_ref, ar_status, mapping_status, + rate_snapshot_ref, credit_grant_event_type, invoice_item_ref, sku_or_plan_ref, + price_id, pricing_snapshot_ref, po_allocation_group + /* financially binding tax/AR/mapping/FX-evidence/item-traceability fields */, + legal_entity_id /* reserved per-line override — always NULL in v1; + hashed now so the encoding never re-freezes */) + ‖ prev_hash) +``` + +**Steps**: +1. [ ] - `p1` - Encode all fields **length-prefixed, fixed integer widths, NULL-safe** (NULL encodes as a distinct marker, never as an empty value) - `inst-ch-encoding` +2. [ ] - `p1` - The field set covers the period assignment (`period_id`), the AC #8 "who" (`origin`, `posted_by_actor_id`), and **all three tenant axes** (payer/seller/resource) - `inst-ch-coverage` +3. [ ] - `p1` - Exclude free-form jsonb and **any PII** from the hashed set (so erasure never breaks the chain) - `inst-ch-pii-excluded` +4. [ ] - `p1` - `H` per G1 (**SHA-256** default, ratified decision 3.A) - `inst-ch-alg` +5. [ ] - `p1` - The byte-reproducibility **test vector MUST be regenerated** for this extended field list before any implementation freezes the encoding - `inst-ch-test-vector` +6. [ ] - `p1` - **RETURN** `row_hash` - `inst-ch-return` + +### Chain Verification (Verifier) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-chain-verify` + +**Input**: `chain_state` tip per tenant; live chain + cold WORM segments (via checkpoints) + +**Output**: Verification result; on mismatch → `scope_freeze` + `tamper-verify-failed` alarm + +**Steps**: +1. [ ] - `p1` - **On-write incremental check (G2):** in Mode A the continuous linkage check is performed by the chain writer itself (each batch verifies the prior tip before extending) plus the lag alarm; in Mode S it is the on-write `chain_state` read under lock (near-free) - `inst-cv-on-write` +2. [ ] - `p1` - **Periodic full re-walk** (default **daily**, ratified 2026-06-16: entire live chain, genesis → tip, **global** not per-tenant, ≤ 24 h detection window; tampering an old row is only caught by physically re-reading it): the Verifier **re-walks by following the explicit `prev_entry_id` back-pointer** from the `chain_state` tip; `journal_entry(tenant_id, created_seq)` is only a fetch/scan helper, **not** the link order - `inst-cv-rewalk` +3. [ ] - `p1` - **On-read spot checks** complete the cadence; **sampled-only is non-compliant** as the sole production mechanism - `inst-cv-spot` +4. [ ] - `p1` - Cold WORM archive is attested via **signed checkpoints** on demand / within the access SLA (per `cpt-cf-bss-ledger-algo-chain-checkpoint-rotation`) - `inst-cv-archive` +5. [ ] - `p1` - **IF** mismatch: set `scope_freeze`, raise `tamper-verify-failed` (Critical — freeze write path on scope; page Audit + Architecture) per `cpt-cf-bss-ledger-flow-tamper-verify` - `inst-cv-freeze` +6. [ ] - `p1` - **Sandboxes** MAY relax tamper evidence where documented and no customer financial truth is stored - `inst-cv-sandbox` +7. [ ] - `p1` - **RETURN** verification result (surfaced at `GET /v1/ledger/audit/tamper-status`) - `inst-cv-return` + +### Tamper Freeze Guard + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-tamper-freeze-guard` + +**Input**: A post transaction (tenant, period) + +**Output**: Pass, or rejection with `TAMPER_VERIFICATION_FAILED` + +**Steps**: +1. [ ] - `p1` - Inside every post transaction (a `FiscalPeriodGuard`-adjacent step): DB read `scope_freeze` for `(tenant_id, scope, period_id)` — `period_id = 'ALL'` sentinel covers tenant-wide freezes - `inst-fg-check` +2. [ ] - `p1` - **IF** a matching freeze row exists **RETURN** 409 `TAMPER_VERIFICATION_FAILED` (the freeze is sticky until Audit/Architecture clears it) - `inst-fg-reject` +3. [ ] - `p1` - **RETURN** pass (post proceeds) - `inst-fg-pass` + +### Cross-Tenant Access Gate + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-cross-tenant-gate` + +**Input**: Request with `target_scope` (bare tenant UUID), `reason` (`X-Investigation-Reason` header + machine-readable `reason_code`), caller role + +**Output**: Widened read scope after a same-transaction forensic record, or rejection + +**Cross-tenant elevation is gated by the audit write:** the only data-access path that widens the isolation scope MUST, in the **same transaction**, INSERT a `SECURED_AUDIT_RECORD(event_type=cross-tenant-access, actor, reason, scope)` **before** any cross-tenant row is returned; a failed audit write **fails the read**. Enforced in one guarded gateway function, not caller discipline. + +**Steps**: +1. [ ] - `p1` - Applies on any request whose `target_scope` ≠ home tenant (the four cross-tenant endpoints: audit packs, tamper-status, erasure, reidentify) - `inst-xt-applies` +2. [ ] - `p1` - Authorize the role (Finance/Audit/DPO) → **ELSE RETURN** 403 `CROSS_TENANT_ACCESS_DENIED` - `inst-xt-role` +3. [ ] - `p1` - Require `reason` + `target_scope` → **ELSE RETURN** 400 `MISSING_INVESTIGATION_REASON`, **before any read** - `inst-xt-reason` +4. [ ] - `p1` - In the **same transaction**, INSERT `SECURED_AUDIT_RECORD(event_type = cross-tenant-access | re-identification, actor, reason, scope)` — a failed insert fails the whole request - `inst-xt-record` +5. [ ] - `p1` - Only then return foreign rows. Mechanically, the gateway widens the scope by `SET LOCAL app.elevated_ids = ` **in the same transaction, after** the `cross-tenant-access` insert succeeds (a failed insert aborts the txn → `elevated_ids` is never set → no foreign row is visible); in the MVP SecureORM model the widening is carried in the compiled `AccessScope` (§7.4). Routine parent→sub-tenant reads instead use `app.subtree_ids` / the Respect-subtree (`BarrierMode::Respect`) and write **no** forensic record - `inst-xt-widen` +6. [ ] - `p1` - This read path acquires **no** `chain_state` lock (it posts no journal entry); the secured store's own chain-tip append (if serialized) is on the **audit-store** chain and creates no deadlock edge with the journal `chain_state` - `inst-xt-no-deadlock` +7. [ ] - `p1` - Increment `ledger_cross_tenant_access_total{reason_code}` - `inst-xt-metric` +8. [ ] - `p1` - **RETURN** the widened, audited read - `inst-xt-return` + +**Wire contract.** Each cross-tenant endpoint MUST carry: **`target_scope`** — the **tenant id** being opened (a bare tenant UUID; request-body field for `POST`, query param for `GET /tamper-status`). Distinct from any result `filter`: scope = *whose* ledger is opened; filter = *what* rows to return. The wire contract uses **`snake_case`** (the platform DE0203 override the gear applies), and `target_scope` is the **bare tenant id** — **not** a nested `{ payerTenantId }`. **`reason`** — human text in the **`X-Investigation-Reason`** header **and** a machine-readable **`reason_code`** (feeds `ledger_cross_tenant_access_total{reason_code}`). Ordinary tenant-scoped paths (`journal-entries/{entryId}`, `documents/.../history`, `metadata`) take **no** `tenantId`. + +### Policy Version Guard + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-policy-version-guard` + +**Input**: A posting that **references/corrects a prior posting** (`reverses_entry_id`, or a credit/debit note linking a closed-period originating invoice) + +**Output**: Enforcement of AC #15 historical-immutability rules + +Posted lines are the historical record; never recomputed in place. `PolicyVersionGuard` enforces the **substantive** rule (the pure in-place UPDATE block is already Slice 1's REVOKE). It intercepts the note/refund/recognition/reversal handlers (Slices 3/4). + +**Steps**: +1. [ ] - `p1` - **(— split by dimension, aligned with the PRD)** The correction MUST reuse the original posting's pinned **evidence** refs — pricing/SSP snapshots, PO/allocation-group refs - `inst-pv-evidence` +2. [ ] - `p2` - The correction MUST use the policy version **in effect at the note's own posting** for the note's **own processing** — rounding, GL mapping, period assignment - `inst-pv-note-time` +3. [ ] - `p2` - **Tax:** the note's fresh `TaxBreakdown` is computed for the **original** document's **tax date** (rate/jurisdiction actually charged), not the current rate — it reverses/extends the tax that was charged, so a later rate change never alters it - `inst-pv-tax-date` +4. [ ] - `p2` - The recognized-vs-unreleased **split basis and recognition-schedule state are evaluated at the note's own effective time** (Slice 3 §4.2, PRD S3/AC #24) — never the state pinned at the original post **(corrected 2026-06-11, PRD-review)** - `inst-pv-recognition` +5. [ ] - `p1` - Closed-period corrections take the **dual-control / backdating** exception path - `inst-pv-dual-control` +6. [ ] - `p1` - **(— deferred) MVP scope:** the `PolicyVersionGuard` enforces only the **evidence-ref-reuse** half (step 1). The remaining obligations — note-time policy for the note's own processing, tax computed at the **original** document's tax date, and recognition split/schedule state at the **note's** effective time — are owned by the Slice 3/4 note/refund/recognition handlers and are **deferred with them** (not present in MVP); recorded here so AC #15's tax-date and recognition-at-note-time rules are not lost - `inst-pv-mvp-scope` +7. [ ] - `p1` - **Idempotent export keys for completed periods are also immutable** under a policy/GL-mapping version change (AC #15) — a re-export of the same key yields identical business amounts (AC #12, PRD); export-key generation is owned by Slice 7 - `inst-pv-export-key` +8. [ ] - `p1` - **RETURN** pass/reject per the rules above - `inst-pv-return` + +### Chain Checkpointing, Rotation & Restore Re-Anchor + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-chain-checkpoint-rotation` + +**Input**: Rotating period partitions (Slice 1 DETACH/DROP for retention); restore events + +**Output**: Verifiable chain across rotation/archival/restore; `partition-detach-blocked` alarm on gate violation + +**Steps**: +1. [ ] - `p1` - **Retention (G6):** journal lines + internal references kept ≥ legal minimum (default ≥7y) - `inst-cr-retention` +2. [ ] - `p1` - **Archival + rotation:** posted journals remain **queryable**; cold storage allowed if integrity (chain still verifiable) + access SLA are met. To keep the chain verifiable across rotation, store **periodic signed chain checkpoints** over a **chain-contiguous range** — `(tenant_id, from_row_hash, to_row_hash, covered_entry_count)` every N links (`period_id` is a **label only**, since entries from one period can be non-contiguous in chain order under backdating/late posts) - `inst-cr-checkpoints` +3. [ ] - `p1` - A dropped/cold segment is attested via its checkpoint; on retention expiry the checkpoint anchors the remaining live chain so verification does not fail for a missing back-pointer target. Cold segments are reachable by the Verifier within the access SLA - `inst-cr-attest` +4. [ ] - `p1` - **Detach gate (normative):** a period partition MAY be detached only when (a) **every** entry in it has `row_hash` set and (b) every chain range overlapping it is **fully covered by a signed checkpoint and retired**, or **fully live**; otherwise rotation halts and raises `partition-detach-blocked` (§7.5). Slice 1's rotation references this gate - `inst-cr-detach-gate` +5. [ ] - `p1` - **Restore & chain re-anchor:** the cell restore runbook is owned by Slice 7 (deterministic export `transaction_id` from `(tenant, sourceId, source document version)`; export re-enabled only after a mandatory Ledger↔ERP reconciliation). This feature owns the **chain re-anchor**: after any restore, the chain is **re-anchored at the last WORM checkpoint at or before the restore point** — the Verifier treats that checkpoint as the anchor for the restored range, the ChainWriter resumes from the re-anchored tip, and the re-anchor is recorded as a `SECURED_AUDIT_RECORD(event_type=restore-event)` - `inst-cr-restore` +6. [ ] - `p1` - **RETURN** rotation/restore completed with chain verifiability preserved - `inst-cr-return` + +## 4. States (CDSL) + +### Scope Freeze State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-scope-freeze` + +**States**: UNFROZEN, FROZEN, CLEARED + +**Initial State**: UNFROZEN (no `scope_freeze` row for the scope) + +**Transitions**: +1. [ ] - `p1` - **FROM** UNFROZEN **TO** FROZEN **WHEN** the Verifier detects a chain-verification mismatch (or a manual override is set): `scope_freeze(tenant_id, scope = tenant | tenant+period_id, period_id ('ALL' sentinel for tenant-wide), reason, frozen_at, set_by)` inserted; `tamper-verify-failed` raised; a `freeze-set-clear` secured-audit record written in the same transaction - `inst-st-frz-set` +2. [ ] - `p1` - **WHILE** FROZEN: `TamperFreezeGuard` rejects every in-scope post with `TAMPER_VERIFICATION_FAILED` (409); the freeze is **sticky** - `inst-st-frz-block` +3. [ ] - `p1` - **FROM** FROZEN **TO** CLEARED **WHEN** Audit/Architecture clears it: `cleared_by` + `cleared_at` recorded; a `freeze-set-clear` secured-audit record written in the same transaction - `inst-st-frz-clear` + +### Payer PII Map State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-pii-map` + +**States**: ACTIVE, ERASED + +**Initial State**: ACTIVE (`payer_pii_map` row with `erased = false`, mapping `payer_tenant_id → pii_ref`) + +**Transitions**: +1. [ ] - `p1` - **FROM** ACTIVE **TO** ERASED **WHEN** a GDPR erasure request is applied (`POST /v1/ledger/audit/erasure`): tombstone `erased = true`; chained `SECURED_AUDIT_RECORD(event_type=erasure)`; journal lines + internal references remain intact and queryable; the chain still verifies - `inst-st-pii-erase` +2. [ ] - `p1` - **FROM** ERASED **TO** (re-identified read) **WHEN** an authorized, policy-governed re-identification resolves the tombstoned id — not a state change on the row; every resolution is recorded as `SECURED_AUDIT_RECORD(event_type=re-identification)` - `inst-st-pii-reid` + +## 5. Definitions of Done + +### Tamper-Evidence Chain + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-tamper-chain` + +The system **MUST** activate the Slice 1 tamper seam as a per-tenant hash chain: Mode S in-txn tip advance under SERIALIZABLE/SSI (MVP), byte-reproducible SHA-256 canonical hash over the normative field list (NULL-safe, length-prefixed, PII-excluded), strictly linear and gap-tolerant linkage via the `prev_entry_id` back-pointer, with Mode A (async micro-batch writer, only-from-NULL chain columns, `SECURITY DEFINER`) addable post-MVP without migration. + +**Implements**: +- `cpt-cf-bss-ledger-algo-chain-writer` +- `cpt-cf-bss-ledger-algo-canonical-hash` + +**Touches**: +- DB: `chain_state`, `journal_entry` (`row_hash`, `prev_hash`, `prev_entry_id`, `prev_period_id` — activated seam) +- Entities: `ChainState`, `ChainWriter` + +### Write-Path Freeze + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-tamper-freeze` + +The system **MUST** run chain verification per the G2 cadence (on-write linkage + daily global full re-walk + on-read spot checks), set a sticky `scope_freeze` on mismatch, reject in-scope posts with `TAMPER_VERIFICATION_FAILED`, and record freeze set/clear (with `cleared_by`/`cleared_at`) as same-transaction secured-audit records. + +**Implements**: +- `cpt-cf-bss-ledger-flow-tamper-verify` +- `cpt-cf-bss-ledger-algo-chain-verify` +- `cpt-cf-bss-ledger-algo-tamper-freeze-guard` +- `cpt-cf-bss-ledger-state-scope-freeze` + +**Touches**: +- API: `GET /v1/ledger/audit/tamper-status` +- DB: `scope_freeze`, `chain_state` +- Entities: `Verifier`, `TamperFreezeGuard` + +### Secured Audit Store + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-secured-audit-store` + +The system **MUST** provide an RBAC-restricted, itself tamper-evident (own per-tenant hash chain, same construction, or cryptographically signed append entries using platform secret-store keys; included in the G2 verify cadence; WORM/append-only) investigation-grade store holding: conflicting-payload captures (AC #19), manual-adjustment reason/actor, controlled-metadata before/after, cross-tenant access, re-identification, erasure events; plus `account-lifecycle-change` (payer closure incl. the AC #21 closed-with-open-balance marker + dual approval; payer lifecycle owned by Slice 1), `exception-resolution` (`APPROVED_EXCEPTION` transitions with reason + actor, Slice 7), `freeze-set-clear`, `config-change` (tenant policy/config version changes), and `restore-event`. Each such record MUST be written **in the same transaction as the guarded action**. Retention per G6; PII where policy requires. + +**Implements**: +- `cpt-cf-bss-ledger-algo-cross-tenant-gate` +- `cpt-cf-bss-ledger-flow-pii-erasure` +- `cpt-cf-bss-ledger-flow-metadata-annotate` + +**Touches**: +- DB: `secured_audit_record` +- Entities: `SecuredAuditStore` + +### Cross-Tenant Elevation Gate + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-cross-tenant-elevation` + +The system **MUST** keep audit retrieval tenant-scoped by default and gate every cross-tenant widening behind the single guarded gateway: role check (403 `CROSS_TENANT_ACCESS_DENIED`), mandatory `target_scope` + `reason` pre-read (400 `MISSING_INVESTIGATION_REASON`), same-transaction forensic record before any foreign row, snake_case wire contract with bare-tenant-id `target_scope` and `X-Investigation-Reason` + `reason_code`. + +**Implements**: +- `cpt-cf-bss-ledger-algo-cross-tenant-gate` +- `cpt-cf-bss-ledger-flow-audit-pack-export` +- `cpt-cf-bss-ledger-flow-reidentification` + +**Touches**: +- API: `POST /v1/ledger/audit/packs`, `GET /v1/ledger/audit/tamper-status`, `POST /v1/ledger/audit/erasure`, `POST /v1/ledger/audit/reidentify` +- DB: `secured_audit_record` +- Entities: `AccessScope`, guarded gateway + +### PII Minimization & Erasure + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-pii-erasure` + +The system **MUST** keep operational surfaces free of direct PII (prohibited: customer name, email, phone, payment-instrument details, street address; internal identifiers only), reference only the immutable internal `payer_tenant_id` on journal lines, tombstone `payer_pii_map` on GDPR erasure while journal lines + references stay intact, queryable, and chain-verifiable for ≥7 years, and record erasure and policy-governed re-identification as chained secured-audit records. + +**Implements**: +- `cpt-cf-bss-ledger-flow-pii-erasure` +- `cpt-cf-bss-ledger-flow-reidentification` +- `cpt-cf-bss-ledger-state-pii-map` + +**Touches**: +- API: `POST /v1/ledger/audit/erasure`, `POST /v1/ledger/audit/reidentify` +- DB: `payer_pii_map`, `secured_audit_record` +- Entities: `PiiMinimizer`, `ErasureHandler` + +### Controlled Metadata Annotation + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-metadata-annotation` + +The system **MUST** allow changes only to the closed G4 allow-list of non-financial attributes via the annotation overlay (never as updatable journal columns), rejecting financial-field writes pre-write (`IMMUTABLE_FINANCIAL_FIELD`) and non-allow-listed attributes (`ATTRIBUTE_NOT_MUTABLE`), recording before/after + actor + reason in the secured-audit chain, single-control in MVP. + +**Implements**: +- `cpt-cf-bss-ledger-flow-metadata-annotate` + +**Touches**: +- API: `PATCH /v1/ledger/journal-entries/{entryId}/metadata` (post-remodel: `…/annotation`) +- DB: `entry_annotation`, `secured_audit_record` +- Entities: `EntryAnnotation` + +### Policy-Version Immutability + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-policy-version-guard` + +The system **MUST** enforce, for postings that reference/correct prior postings, reuse of the original posting's pinned evidence refs (pricing/SSP snapshots, PO/allocation-group refs) — the MVP half of AC #15 — with note-time policy, original-tax-date tax, and recognition-state-at-note-time deferred to Slices 3/4; export keys for completed periods stay immutable under policy/GL-mapping version changes. + +**Implements**: +- `cpt-cf-bss-ledger-algo-policy-version-guard` + +**Touches**: +- DB: `journal_entry` / `journal_line` (read; pinned refs), policy/version refs +- Entities: `PolicyVersionGuard` + +### Alarm Catalog Ownership + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-alarm-catalog` + +The system **MUST** own the normative alarm catalog (§7.5) — canonical `alarmCategory` tokens, severities, and required behaviors for every slice's invariants — with alarms emitted by the posting slices via the Slice 1 separate-committed audit/alarm transaction and rolled up in `ledger_alarm_total{category,severity}`. + +**Implements**: +- `cpt-cf-bss-ledger-flow-tamper-verify` + +**Touches**: +- DB: `secured_audit_record` (alarm-linked forensics) +- Entities: `AlarmCatalog`, `AlarmRouter` + +### Retention, Archival & Checkpoints + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-retention-rotation` + +The system **MUST** keep journal lines + internal references ≥7 years, keep archived/cold journals queryable and chain-verifiable via periodic signed chain-contiguous checkpoints, enforce the partition-detach gate (raising `partition-detach-blocked` otherwise), and re-anchor the chain at the last WORM checkpoint after any restore, recording a `restore-event`. + +**Implements**: +- `cpt-cf-bss-ledger-algo-chain-checkpoint-rotation` + +**Touches**: +- DB: `chain_state`, signed chain checkpoints, WORM archive (S3 Object Lock or cloud-equivalent) +- Entities: `ChainCheckpoint`, `Verifier` + +## 6. Acceptance Criteria + +A **delta over the Foundation testing architecture** (levels + mocking inherited). + +- [ ] **Unit:** canonical hash serialization is byte-reproducible (NULL-safe length-prefixed, fixed widths, line order) — the byte-reproducibility test vector regenerated for the extended field list, incl. NULL-safe encoding of the new nullable fields; PII excluded from the hashed field set; G4 allow-list enforcement; PII-on-operational-surface detector against the concrete prohibited-field list +- [ ] **Integration (testcontainers):** **(Mode S — MVP)** posted entry links the tenant chain tip under `chain_state` lock (concurrent commits never share a `prev_hash`; a rolled-back entry leaves no chain gap); **(Mode A — post-MVP)** the micro-batch ChainWriter chains committed entries in the deterministic order, sets chain columns only from NULL, resumes after crash/restart with no gap or double-link, and a breached lag threshold raises `chain-lag`; Verifier detects a tampered row → sets `scope_freeze` → next in-scope post is rejected (`TAMPER_VERIFICATION_FAILED`); a financial-field PATCH is rejected pre-write (`IMMUTABLE_FINANCIAL_FIELD`); an allow-listed metadata change logs before/after without touching journal tables; cross-tenant read denied without elevation, allowed only with a same-txn audit record (and **fails the read if the audit write fails**); GDPR erasure tombstones the PII map while journal lines stay intact + queryable + the chain still verifies; re-identification is recorded; a closed-period correction reuses the original posting's pinned evidence refs (pricing/SSP, PO/allocation) while its own processing uses note-time policy and schedule state at the note's effective time (split, correction; AC #15) +- [ ] **API:** RFC 9457 mapping for each code; audit retrieval returns who/when/source/correlation; audit-pack full linkage; tamper-status + freeze state; a cross-tenant request with `targetScope` ≠ home tenant but no `reason` is rejected **pre-read** with `MISSING_INVESTIGATION_REASON` (400), and a valid elevation writes the same-txn `cross-tenant-access` record before any foreign row is returned +- [ ] **Audit & lineage (PRD obligation):** source-document linkage, tenant-scoped retrieval, erasure tombstone, tamper verification — covered +- [ ] **Concurrency:** **(Mode S — MVP)** chain-tip serialization (the hot row) under concurrent posts; deadlock-free with `chain_state` last in the lock order. **(Mode A — post-MVP)** the advisory-locked writer singleton: two workers never extend the same tenant chain concurrently +- [ ] **What must NOT be mocked:** the append-only REVOKE+trigger, the real hash-chain computation, the **SecureORM tenant-scope predicate** (the MVP isolation control), and the secured-store RBAC — the correctness guarantees +- [ ] **NFR verification (every §7.8 row):** (G1) Verifier detects a tampered row (Integration above); (G2) cadence re-walk + on-write linkage; (G3) residency-pinned storage never crosses the boundary incl. DR **and WORM archive** (post-MVP); (G5) RTO/RPO failover drill; (G6) a journal line + ref survives past `retain_until` and archival keeps the chain verifiable; (Audit-read availability) the API audit-retrieval test against the ratified targets (retrieval p95 ≤ 2 s, inquiry ≤ 5 s, export async ≤ 15 min — decision 7, 2026-06-17) + +## 7. Additional Context + +### 7.1 REST API Surface (feature-owned endpoints) + +REST per `rest-api-design`, behind the inbound API gateway; reads tenant-scoped (scope predicate); cross-tenant requires elevated, audited context. The four endpoints marked *cross-tenant* below carry the **elevation contract** (explicit `targetScope` + mandatory `reason`) defined in the Cross-Tenant Access Gate algo; ordinary tenant-scoped paths take **no** `tenantId` (tenant from the authenticated context → scope predicate). + +| Method | Path | Purpose | Notes | +|--------|------|---------|-------| +| `GET` | `/v1/ledger/audit/journal-entries/{entryId}` | Audit retrieval: who/when/source/correlation (AC #8). | tenant-scoped; | +| `GET` | `/v1/ledger/audit/documents/{sourceDocType}/{sourceBusinessId}/history` | One document's posting history: linked entries, reversals, notes, allocations, refunds, schedules. | tenant-scoped | +| `POST` | `/v1/ledger/audit/packs` | Export an audit pack (filter → full-linkage CSV/PDF). | async; Finance/Audit scope · **cross-tenant** | +| `GET` | `/v1/ledger/audit/tamper-status` | Latest chain-verification result + freeze state per scope. | Audit scope · **cross-tenant** | +| `POST` | `/v1/ledger/audit/erasure` | Apply a GDPR erasure (tombstone PII map; chained audit record). | DPO scope; journal lines untouched · **cross-tenant** | +| `POST` | `/v1/ledger/audit/reidentify` | Authorized re-identification (recorded). | DPO/investigator scope · **cross-tenant** | +| `PATCH` | `/v1/ledger/journal-entries/{entryId}/metadata` | Controlled change to an **allow-listed non-financial** attribute (append-only log). | single-control MVP (dual-control deferred); never financial fields; | + +### 7.2 Problem Responses (RFC 9457) + +`CROSS_TENANT_ACCESS_DENIED` (403), `MISSING_INVESTIGATION_REASON` (400 — cross-tenant elevation without a `reason` / `targetScope`, rejected pre-read), `IMMUTABLE_FINANCIAL_FIELD` (409 — financial field, rejected pre-write) **[NOT emitted in v1 — see note]**, `ATTRIBUTE_NOT_MUTABLE` (400 — not on G4 allow-list) **[NOT emitted in v1 — see note]**, `TAMPER_VERIFICATION_FAILED` (409 on write to a frozen scope; surfaced read-side too). + +> **⚠️ v1 status:** `IMMUTABLE_FINANCIAL_FIELD` and `ATTRIBUTE_NOT_MUTABLE` are **not emitted by v1 code** — the Variant-C annotation remodel (§7.4, 2026-06-26) narrowed the PATCH surface to a typed `EntryAnnotationRequestDto` whose sole field is `description`, so a financial-field or non-allow-listed PATCH is **structurally impossible** (it can't be expressed in the request body) rather than validated-and-rejected with these discriminators. A malformed body is a generic 400 body-rejection. These two codes are therefore **retired from the v1 contract**; re-introducing them would require re-widening the annotation DTO, which is not planned. **(✅ 2026-06-24)** `MISSING_INVESTIGATION_REASON` and `ATTRIBUTE_NOT_MUTABLE` are architecturally **422 Unprocessable Entity**, but the platform `CanonicalError` model has **no 422 category**, so both map to **400** (`FailedPrecondition`) carrying their wire code — the **code string is the discriminator** consumers match on, not the status. + +### 7.3 Events Surface + +This feature **consumes** the `billing.ledger.invariant.alarm` stream (all slices) and routes per the catalog (§7.5). It **emits**: `billing.ledger.tamper.verified` / `billing.ledger.tamper.failed` (→ freeze), `billing.ledger.erasure.applied`, `billing.ledger.reidentification.recorded`, `billing.ledger.audit_pack.exported`. All PII-free on the wire; investigation-grade detail stays in the secured audit store. + +**(✅ Ratified 2026-06-24) MVP = dormant (contract-only).** The five payloads (`LedgerTamperVerified`, `LedgerTamperFailed`, `LedgerErasureApplied`, `LedgerReidentificationRecorded`, `LedgerAuditPackExported`) are defined and serde-round-trip-tested so the wire shape is frozen, but **no broker producer is wired** — the gear ships `events_enabled = false` (`module.rs`) because the platform GTS event-type model is incomplete. **Source of truth is unaffected:** every fact already lives in the secured-audit / tamper-evidence chain; these structs are the future relay shape. Consumers MUST NOT subscribe for alerting in MVP — read the audit API. The producer wires when the platform GTS event-type model lands. + +### 7.4 Data Model (feature-owned tables) + +Activates `journal_entry.row_hash`/`prev_hash`; adds `chain_state`, `scope_freeze`, `secured_audit_record`, `payer_pii_map`, `entry_annotation` (the controlled-annotation overlay; **Variant C remodel** replaced the earlier `metadata_change_log` — see the dedicated bullet below). Tenant-scoped via the **SecureORM scope predicate** (C1); the secured audit store has stricter forensic RBAC. The posted `journal_entry`/`journal_line` stay financial-facts-only; the chain columns + `created_seq` were reserved by Slice 1. + +```mermaid +erDiagram + JOURNAL_ENTRY_CHAIN { + uuid entry_id "Slice 1 (FK)" + bigint created_seq "Slice 1; scan helper only" + bytea row_hash "ACTIVATED" + bytea prev_hash "ACTIVATED = prior tip row_hash" + uuid prev_entry_id "resolvable back-pointer" + string prev_period_id "composite pointer part" + } + CHAIN_STATE { + uuid tenant_id + bytea last_row_hash "chain tip; Mode S MVP: serialized via SERIALIZABLE/SSI (FOR UPDATE deferred)" + uuid last_entry_id "tip entry (back-pointer target)" + string last_period_id "tip entry period; advanced atomically with last_entry_id" + bigint last_seq + } + SCOPE_FREEZE { + uuid tenant_id + string scope "tenant | tenant+period_id" + string period_id "'ALL' sentinel for tenant-wide (NOT NULL)" + string reason + timestamptz frozen_at + string set_by "Verifier | manual" + string cleared_by + timestamptz cleared_at + } + SECURED_AUDIT_RECORD { + uuid audit_id + uuid tenant_id + string event_type "conflict-capture | metadata-change | cross-tenant-access | manual-adjustment | erasure | re-identification | account-lifecycle-change | exception-resolution | freeze-set-clear | config-change | restore-event | period-reopen" + string actor_ref + string reason_code + jsonb before_after + uuid correlation_id + bytea row_hash "own hash chain / signed" + bytea prev_hash + timestamptz at_utc + timestamptz retain_until + } + PAYER_PII_MAP { + uuid tenant_id + uuid payer_tenant_id "renamed from payer_id - the actual journal_line column" + string pii_ref + bool erased "tombstone" + } + METADATA_CHANGE_LOG { + uuid change_id "PK" + uuid tenant_id "RLS scope" + uuid target_id "entry/line id" + string target_period_id "resolves into the partitioned journal" + string target_kind "ENTRY | LINE" + string attribute "G4 allow-listed" + jsonb before_after + string actor_ref + timestamptz at_utc + } +``` + +Key constraints: + +- **Isolation mechanism — SecureORM scope predicate (✅ 2026-06-24, @vstudzinskyi):** MVP tenant isolation for **every** table here (posted journal, `chain_state`, `scope_freeze`, `secured_audit_record`, `payer_pii_map`, `entry_annotation`) is the **application-layer SecureORM scope predicate** — the compiled `AccessScope` bound to the `tenant_id` column (`WHERE tenant_id IN`), the same C1 control Slice 1 applies on every query. The hierarchy / elevation **widening semantics are unchanged** and are carried **in that compiled `AccessScope`**, not in DB session vars: own tenant always visible; the §7.6 resolver widens a routine read to the caller's Respect-subtree (decision 2.B, ACTIVE in MVP); the forensic-gated gateway widens to a cross-barrier target. **Fail-closed:** an unset / failed resolver narrows to own-tenant. Isolation axis = `tenant_id` (operating/owner tenant); `payer/seller/resource` are **descriptive** line axes, never the isolation key. Elsewhere in this doc "RLS" / "widens RLS scope" reads as this SecureORM predicate in MVP. +- **Postgres RLS Variant B — deferred post-MVP defense-in-depth backstop:** the database-kernel backstop is **deferred post-MVP**; when it ships it is **additive** to the SecureORM predicate above (no API/contract change) and takes this shape — an additive, fail-closed policy reading per-request `SET LOCAL` session vars: + + ```sql + USING ( + tenant_id = current_setting('app.tenant_id')::uuid + OR tenant_id = ANY (coalesce(string_to_array(current_setting('app.subtree_ids', true), ',')::uuid[], '{}')) + OR tenant_id = ANY (coalesce(string_to_array(current_setting('app.elevated_ids', true), ',')::uuid[], '{}')) + ) + ``` + + Own tenant is always visible from `app.tenant_id` (independent of the resolver — own-tenant reads never fail if AuthZ is down). `app.subtree_ids` (Respect subtree, routine, **no** forensic record) and `app.elevated_ids` (Ignore, forensic-gated) only **widen**. All three are **`SET LOCAL`** (transaction-scoped → pool-safe; auto-cleared at commit). Unset optional vars → empty array → no widening (fail-closed: a forgotten var narrows, never leaks). Guardrail: a resolved subtree above the §7.7 threshold raises `subtree-too-large` (switch that tenant to a closure-table predicate, Variant C). This backstop mirrors the MVP SecureORM widening exactly (subtree via `app.subtree_ids`, cross-barrier via `app.elevated_ids`), so activating it post-MVP changes no behavior — it only adds the kernel-level defense-in-depth. +- **Tamper chain:** per-tenant; **(🔄 revised 2026-06-15)** **MVP = Mode S** — `chain_state(tenant_id, last_row_hash, last_entry_id, last_period_id, last_seq)` serialized in the post txn (lock-order rank **last**, after all Slice 1–5 ranks) — **(impl note, 2026-06-24)** the MVP reads the tip **lockless** under **SERIALIZABLE** (SSI aborts the loser; the literal `FOR UPDATE` is **deferred** until SecureORM exposes a locking read); **Mode A** — `chain_state` advanced by an async micro-batch chain writer (no post-txn lock; chain columns written only-from-NULL via a `SECURITY DEFINER` function; `ledger_chain_lag_seconds` alarmed) — **deferred post-MVP** (same seam, no migration). In both: each entry stores `prev_hash` **and** a resolvable `prev_entry_id` (composite `(tenant_id, prev_period_id, prev_entry_id)`); `row_hash` over the canonical field list (NULL-safe length-prefixed, PII-excluded, incl. `gl_code` + the fields); **re-walk follows the `prev_entry_id` back-pointer from the tip** (`journal_entry(tenant_id, created_seq)` is only a scan helper — link order = chain order, not `created_seq`); `last_period_id` advances atomically with `last_entry_id`, and pointer/tip resolution uses Slice 1's covering index `journal_entry(tenant_id, entry_id) INCLUDE (period_id)`; WORM/immutable archival (G1); partition detach gated per the checkpoint/rotation algo. +- `scope_freeze` PK `(tenant_id, scope, period_id)` with `period_id = 'ALL'` (NOT-NULL sentinel) for tenant-wide scope; checked by `TamperFreezeGuard` inside every post txn; sticky until cleared; `cleared_by` + `cleared_at` recorded; set and clear each write a `freeze-set-clear` secured-audit record in the same transaction. +- `secured_audit_record` append-only + **own hash chain / signed** + WORM; PK `audit_id`; indexed `(tenant_id, correlation_id)`, `(tenant_id, event_type, at_utc)`; `retain_until` ≥ G6; event types per the ER diagram above, each written in the same transaction as the guarded action. +- `payer_pii_map` PK `(tenant_id, payer_tenant_id)`; `erased` tombstone; **no PII on `journal_line`**. +- `entry_annotation` — **(controlled-metadata Variant C remodel, 2026-06-26; supersedes the earlier append-only `metadata_change_log` of)** the typed controlled-annotation overlay. **MUTABLE current-state**, upserted in place — PK `(tenant_id, target_id, target_kind (ENTRY|LINE))`, columns `target_period_id`, typed `description` (the sole G4 attribute kept; `dispute_marker` / `export_recon_flag` removed — disputed stays owned by `ledger_dispute` via `dispute × write`, recon is Slice 7), `actor_ref`, `updated_at`. The change **HISTORY** (before/after + actor + reason) lives in the **secured-audit chain** as `metadata-change` records, so this overlay holds **no** append-only log of its own (the old duplicate is gone). `tenant_id` tenant-scoped (C1, SecureORM predicate); **separate** from journal tables (Slice 1 REVOKE intact); **not** covered by the tamper chain; **no** append-only trigger (current-state, unlike the journal / audit chains). Annotation write path acquires **no** balance/fiscal/idempotency lock → no new balance-lock rank. (Action renamed `metadata` → `annotate`; surface `PATCH …/annotation`. Detail: the gear's `…-metadata-remodel-variant-c-design.md`. The controlled-metadata / `metadata_change_log` / `IMMUTABLE_FINANCIAL_FIELD` / `ATTRIBUTE_NOT_MUTABLE` prose elsewhere predates this remodel and reads as this overlay.) +- **Residency (G3) — 🔮 deferred post-MVP:** v1 ships a single installation with **no** region-pinning. **In the post-MVP cell model:** posted journal + secured audit + PII map + **WORM/cold archive** for a residency-pinned tenant are region-pinned (cross-region replication **disabled** on the archive) in primary/replica/DR/archive. + +### 7.5 Alarm Catalog (normative) + +This feature owns the **normative alarm catalog**; alarms are **emitted by the posting slices** via the Slice 1 separate-committed audit/alarm transaction. Each row lists the canonical `alarmCategory` token(s) (→ the §7.7 `{category}` metric label): + +| Alarm (catalog) | `alarmCategory` token(s) | Severity | Required behavior | +|------|------|----------|-------------------| +| Zero-sum violation | `zero-sum` | Critical | Alert + block posts in scope | +| Negative-balance class violation (AC #17) — incl. Slice 2 chargeback negative-Cash (balanced loss line + RA) | `negative-balance`, `chargeback-cash-negative` | Critical (NO) / Warn (bounded) | Route to Revenue Assurance | +| Revenue-role sign violation — normal-side-negative `account_balance` on REVENUE / CONTRA_REVENUE / GOODWILL | `negative-balance` | Warn | Evaluated by the balance projector + TieOutJob (Slice 1); route to Revenue Assurance | +| Recognition double-credit / over-recognition | `recognition-double-credit`, `over-recognition` | Critical | Alert Finance Ops + block schedule | +| FX snapshot — three states | `fx-snapshot-missing` / `fx-snapshot-stale-allowed` / `fx-snapshot-stale-blocked` | Critical(block) / Warn(posts) / Critical(block,422) | Block on missing & stale-blocked; mark stale-allowed | +| Idempotency-key collision (AC #19) | `idempotency-collision` | Critical | Reject conflicting payload, capture both to secured audit, alert | +| Negative tax sub-balance beyond filing window | `negative-tax-subbalance` | Critical | Route to Revenue Assurance | +| Credit-note split blocked (ambiguous) | `credit-note-split-blocked` | Warn → Page | Block split, queue exception | +| Refund quarantined (refund-before-payment) | `refund-quarantined` | Warn → Page | Hold until matched | +| Recognition period queued (out-of-order) | `recognition-period-queued` | Warn → Page | Operator review | +| Reconciliation variance (Slice 7) | `reconciliation-variance` | Warn → Page | Block period close above tolerance | +| Failed export with age (Slice 7) | `export-failed-aged` | Warn → Page | Retry; never drop posted facts | +| Aged allocation / unallocated / refund-clearing / dispute-phase queue | `aged-allocation-queue`, `aged-unallocated`, `refund-clearing-aged`, `dispute-phase-queued` | Warn → Page | Operator review | +| Stage-1 refund without stage-2/reversal | `stage1-refund-orphan` | Warn → Page | Page Revenue Assurance | +| Bill-run partial-failure threshold exceeded | `billrun-partial-failure` | Warn → Page | Pause affected run; operator review | +| Tamper-evidence verification failure | `tamper-verify-failed` | Critical | Freeze write path on scope; page Audit + Architecture | +| Chain lag above threshold — Mode A unchained window **— post-MVP (fires once Mode A ships)** | `chain-lag` | Warn → Page | Investigate the chain writer; unchained > threshold behaves like a verification gap | +| Partition detach blocked by the rotation gate | `partition-detach-blocked` | Warn → Page | Hold rotation until chaining + checkpoint coverage complete | +| Outbox relay publication lag above threshold | `relay-lag` | Warn → Page | Investigate the relay worker; undispatched outbox rows age and downstream consumers starve | +| Clock skew outside window | `clock-skew` | Warn → Page | Apply clock-skew handling | +| Attempted write-off outside scope | `attempted-write-off` | Critical | Reject, capture actor + intended posting, alert RA + Finance Ops | +| Payer-attribution drift, cross-barrier — *detective; preventive write-path guard deferred (Variant C)* — **INACTIVE in MVP** | `payer-attribution-drift` | Warn → Page | **Out-of-band daily sweep** (not the write path): resolves each recent entry's `resource_tenant`'s nearest `self_managed` ancestor-or-self from the platform tenant tree and flags any entry whose recorded `payer_tenant_id` differs (cross-billing-boundary mis-attribution); route to Revenue Assurance. The detective complement to the deferred Variant C guard — closes the "no detective control" gap behind. | +| Open FX AR not revalued at close, Mode B | `fx-revaluation-incomplete` | Critical | Block period close (Slice 7; AC #30/#33); page Revenue Assurance + Finance when open foreign-currency AR has no completed revaluation run for the closing period | +| Dormant credit on an open account | `dormant-open-credit` | Warn → Page | Credit residual (`UNALLOCATED` / `REUSABLE_CREDIT`) with no owner activity beyond the tenant dormancy threshold; route to Finance for a disposition election (unclaimed-property surfacing; escheatment filing deferred post-MVP) | +| Subtree resolution too large **— post-MVP** | `subtree-too-large` | Warn → Page | Caller subtree exceeds the §7.7 GUC guardrail; switch that tenant to a closure-table predicate (Variant C) | +| Subtree resolution degraded **— post-MVP** | `subtree-resolution-degraded` | Warn | AuthZ resolver unavailable; caller fell back to own-tenant only; investigate the resolver | + +Exact metric names/thresholds/routing/burn-rate are Design/NFR (per-slice metrics feed this catalog). + +**(✅ Ratified 2026-06-24) `payer-attribution-drift` detective sweep — MVP = inactive no-op.** `AttributionSweepJob::run` logs and returns `Ok`; resolving `resource_tenant_id` → nearest `self_managed` ancestor needs the `TenantResolverClient`, not wired into the gear (hermetic-test constraint). No `serve` ticker is scheduled (so the gear never runs a job that can only no-op). **Preventive coverage stands:** `MixedPayer`/`MissingPayer` on the post hot path. Activate (sweep + ticker) when the resolver is wired. + +### 7.6 Security & AuthZ + +Inherits Slice 1 tenant isolation (C1) — the **SecureORM scope predicate**; the Postgres RLS Variant B backstop is deferred post-MVP (§7.4). The ledger acts as a platform **PEP**: it maps the logical scope property `owner_tenant_id` → `journal_entry.tenant_id`, and for hierarchy-aware reads resolves the caller's visible subtree via the **AuthZ Resolver Plugin** `HierarchyClient` (`BarrierMode::Respect`, cached) into the caller's compiled `AccessScope` (the SecureORM predicate; the `app.subtree_ids` session var is the deferred RLS-backstop carrier) — **no local `tenant_closure` projection**. If the resolver is unavailable, the subtree is left unresolved and the caller sees **only** its own tenant, flagged degraded (`subtree-resolution-degraded`, §7.7). **(decision 2.B)** This is **active in MVP** — the resolver **is** called per request; a parent reads its own Respect-subtree by default. **Role policy:** subtree reads use the caller's existing read/audit RBAC scope widened to the Respect-subtree (no new grant); cross-barrier reads of a self-managed child's ledger still require the forensic elevation (`BarrierMode::Ignore` + forensic record). (§7.11 G7.) The **secured audit store** is a separate forensic RBAC domain; cross-tenant access and re-identification require elevated context **recorded** with actor/reason/scope as a precondition of the read. Erasure is DPO-scoped; controlled-metadata changes are single-control + logged in MVP (dual-control deferred, §7.11); PII never on operational surfaces (concrete prohibited list in the erasure flow). Tamper-chain hash/signing material lives in the platform secret store (manifest §9), not in the ledger. + +### 7.7 Feature Metrics + +`ledger_tamper_verify_runs_total` / `_failures_total`, `ledger_tamper_chain_length`, `ledger_chain_tip_lock_wait_seconds` (hot-row, Mode S), `ledger_chain_lag_seconds` (Mode A unchained-window age; feeds the `chain-lag` alarm), `ledger_outbox_relay_lag_seconds` (oldest undispatched outbox row age; feeds the `relay-lag` alarm), `ledger_scope_freeze_active` (gauge), `ledger_audit_pack_export_duration_seconds`, `ledger_cross_tenant_access_total{reasonCode}` (— keyed by the elevation `reasonCode`), `ledger_reidentification_total`, `ledger_erasure_applied_total`, `ledger_metadata_change_total{attribute}`, `ledger_alarm_total{category,severity}` (catalog-wide rollup keyed by the §7.5 tokens). `ledger_subtree_size` (resolved subtree cardinality per request; feeds the `subtree-too-large` guardrail) and `ledger_subtree_resolution_degraded_total` (resolver-unavailable own-tenant fallbacks). Thresholds wire to §7.8 + the tamper-failure alarm. + +### 7.8 NFR Mapping + +NFR rows are keyed by the §1.6 G-IDs (whose PRD sources are listed in §1.6). + +| NFR (G-ID) | Mechanism | Status | +|------------|-----------|--------| +| Tamper-evidence in production (G1) | per-tenant hash chain (**SHA-256**) + chain_state tip + WORM archival (**S3 Object Lock** / cloud-equivalent) | ✅ Ratified 2026-06-17 (decision 3.A) | +| Tamper-verify cadence (G2) | on-write linkage + **daily** full re-walk (entire live chain, ≤ 24 h window) + on-read spot checks | ✅ Ratified 2026-06-16 | +| Data residency / geo-redundancy (G3) | region-pinned posted/audit/PII/**archive** (post-MVP, via cells); cross-region replication disabled on archive | 🔮 **Deferred post-MVP** — v1 single installation; multi-AZ + RTO/RPO (G5) still in scope | +| RTO / RPO (G5) | RTO ≤ 60 min; RPO ≤ 5 min per region | Baseline inherited | +| Retention (G6) | ≥ 7y journal lines + refs (not PII); signed rotation checkpoints | G6 default | +| Audit retrieval / availability | audit retrieval (entry / document-history) **p95 ≤ 2 s**; inquiry queries **p95 ≤ 5 s**; audit-pack export **async, ≤ 15 min** | ✅ Design default set 2026-06-17 (decision 7); PM to confirm before GA | + +### 7.9 Testing Architecture + +A **delta over the Foundation testing architecture** (levels + mocking inherited). The concrete test matrix — Unit / Integration / API / Audit & lineage / Concurrency / must-not-mock / NFR verification — is captured as the checklist in [6. Acceptance Criteria](#6-acceptance-criteria). + +### 7.10 Risks, Open Questions & Deferred Work + +- **G2 cadence — ✅ ratified 2026-06-16** (daily, global, ≤ 24 h). **G3 residency — 🔮 deferred post-MVP:** v1 is a single installation with no region-pinning; the mechanism (cells, region-pinning incl. archive) is designed and parked for the post-MVP rollout, so this is no longer a launch-blocking NFR. +- **(🔄 2026-06-15)** **Chain-tip hot row is the MVP contention point** — MVP ships **Mode S** (in-txn tip), a per-tenant serializer; **B3 load-test MUST run with Mode S enabled**, alongside `ar_payer_balance`, including sync-replication lock-hold inflation. **Mode A (post-MVP)** removes the chain lock and shifts the risk to bounded, alarmed **chain lag**; it is the planned remedy if Mode S throughput is insufficient. +- **Tamper mechanism (G1)** — **✅ ratified 2026-06-17 (decision 3.A): SHA-256 + S3 Object Lock** (cloud-equivalent immutable object store). The G4 mutable-attribute list is **ratified 2026-06-10** (closed list of three, §7.11 G4). +- **Audit-read/inquiry/export availability** — **✅ design default set 2026-06-17 (decision 7):** retrieval p95 ≤ 2 s, inquiry p95 ≤ 5 s, export async ≤ 15 min; PM to confirm before GA (§7.8/§7.11). +- **(decision 2.B) Tenant-hierarchy reads ACTIVE in MVP** — Variant B resolver called per request; adds a read-path dependency on the AuthZ Resolver Plugin; narrows PRD (Product to be notified). +- **Isolation mechanism ratified 2026-06-24:** MVP tenant isolation is the **application-layer SecureORM scope predicate** (the control the gear actually implements). **Postgres RLS Variant B** (the database-kernel defense-in-depth backstop) is **deferred post-MVP** — additive when it ships, no contract change. The ADR is unchanged; this records the MVP mechanism + the parked backstop as a tracked follow-up. +- **Controlled-metadata single-control ratified 2026-06-24:** MVP controlled-metadata PATCH is single-control (one PEP-authorized actor, recorded); two-person dual-control is deferred post-MVP pending a Finance requirement. Signs off the implementation's single-control behavior. +- **AC #15 note-time/tax/recognition obligations deferred 2026-06-24:** the `PolicyVersionGuard` enforces only the evidence-ref-reuse half; the note-time-policy, tax-at-original-tax-date, and recognition-state-at-note-effective-time rules are owned by the Slice 3/4 handlers and are **deferred with those slices** (not present in MVP). Recorded so AC #15 isn't lost. +- **Deferred:** platform SIEM/OTel/monitoring internals (contract only); reconciliation/export alarms catalogued here but owned by Slice 7; export-key generation Slice 7; **Postgres RLS Variant B** kernel backstop (above); **controlled-metadata dual-control** (above); **AC #15 note-time/tax/recognition obligations** (above, Slices 3/4). + +### 7.11 Decision Log (Needs Discussion) + +Inherits Slice 1 open items. Slice-6-specific: + +| Item | Decision (default) | Status | Owner | +|------|--------------------|--------|-------| +| Tamper-evidence mechanism | hash-chain (per-tenant tip) + WORM; pluggable; **SHA-256 + S3 Object Lock (or cloud-equivalent immutable object store)** | ✅ **Ratified 2026-06-17 (@vstudzinskyi, decision 3.A)** | PM + Architecture | +| Chaining mode | **MVP = Mode S only** (synchronous in-txn tip); **Mode A (async micro-batch writer) deferred post-MVP** — same seam (`row_hash`/`prev_hash`/`chain_state`), added later without migration | 🔄 **Revised 2026-06-15** — supersedes the 2026-06-10 "Mode A default". B3 load-test runs **with Mode S enabled** | Architecture + Audit | +| Residency physical model | regional deployment cells (one PG cluster per boundary, tenant pinned at onboarding) | 🔮 **Deferred post-MVP** — cells / per-region clusters / cross-region-archive ban are post-MVP; v1 = single installation. `period_id` partitioning stays (retention/tie-out, residency-independent) | Architecture + DPO | +| Tamper-verify cadence | on-write linkage + daily full re-walk + on-read spot checks; **per-run scope: the entire live chain** (genesis → tip; tampering an old row is only caught by physically re-reading it); cold WORM archive attested via signed checkpoints on demand/SLA | ✅ **Ratified 2026-06-16 (Architecture + Audit): daily, global (not per-tenant), ≤ 24 h detection window** | Architecture + Audit | +| Data residency / regions (incl. WORM archive) | region-pinned all copies; cross-region replication disabled on archive | 🔮 **Deferred post-MVP** — no region-pinning in v1; mechanism kept for the post-MVP cell rollout; a hard-residency tenant can't onboard in MVP | Architecture + DPO | +| Mutable non-financial attribute list | allow-list: description/memo, dispute marker, export/recon flags — **closed list of exactly three**; any extension is a separate Design + Finance decision | ✅ Ratified 2026-06-10 | Design + Finance | +| Audit-read / inquiry / export availability + latency | audit retrieval p95 ≤ 2 s; inquiry p95 ≤ 5 s; export async ≤ 15 min (decision 7) | ✅ Default set 2026-06-17; PM to confirm before GA | PM | +| RTO / RPO | RTO ≤ 60 min; RPO ≤ 5 min | ✅ Baseline inherited | — | +| Retention | default ≥ 7y journal lines + refs (not PII) | ✅ Accepted default | — | +| Tenant-hierarchy reads (subtree RLS) | **ACTIVE in MVP (decision 2.B, 2026-06-17)** — middleware resolves `app.subtree_ids` per request (AuthZ Resolver Plugin, `BarrierMode::Respect`); additive, fail-closed RLS; **no** local `tenant_closure`. A parent reads its own Respect-subtree; cross-barrier reads stay forensic-elevated. Role policy = caller's read/audit RBAC scope widened to the Respect-subtree. Escape hatch to Variant C (closure predicate) for very large subtrees via `subtree-too-large` | ✅ **Activated 2026-06-17** — narrows PRD; **Product to be notified** | Architecture (done) + Product (notify) | +| Tenant-isolation mechanism | **MVP = application-layer SecureORM scope predicate** (`AccessScope` → `WHERE tenant_id IN`) — the control the gear implements. Hierarchy/elevation widening is carried in the compiled `AccessScope` (subtree per §7.6, cross-barrier per the gate); fail-closed to own-tenant. **Postgres RLS Variant B** (kernel backstop) **deferred post-MVP** — additive when it ships, no contract change. ADR unchanged | ✅ **Ratified 2026-06-24** — source uses SecureORM | Architecture | +| Controlled-metadata dual-control | **MVP = single-control** — one actor via the `(entry, metadata)` PEP gate, recorded (actor + reason + before/after) on the append-only log. Two-person **dual-control (requester ≠ approver) deferred post-MVP** pending a Finance requirement. Rationale: closed non-financial G4 allow-list (3 attrs), append-only + edit-reversible, actor+reason accountability — a stateful two-person approval is disproportionate for the MVP surface | ✅ **Ratified 2026-06-24** — signs off the code's single-control deviation; Finance to confirm if dual-control is required for GA | Design + Finance | +| AC #15 note-time/tax/recognition obligations | **Deferred with Slices 3/4.** The `PolicyVersionGuard` enforces only the evidence-ref-reuse half; note-time policy for the note's own processing, tax at the **original** doc's tax date, and recognition split/schedule state at the **note's** effective time are owned by the Slice 3/4 note/refund/recognition handlers (not present in MVP) | 🔮 **Deferred post-MVP** — tracked so AC #15 isn't lost | Slices 3/4 | +| §7.3 event payloads (5) — `LedgerTamperVerified`, `LedgerTamperFailed`, `LedgerErasureApplied`, `LedgerReidentificationRecorded`, `LedgerAuditPackExported` | **MVP = dormant (contract-only).** The five payloads are defined and serde-round-trip-tested so the wire shape is frozen, but **no broker producer is wired** — the gear ships `events_enabled = false` (`module.rs`) because the platform GTS event-type model is incomplete. **Source of truth is unaffected:** every fact already lives in the secured-audit / tamper-evidence chain; these structs are the future relay shape. Consumers MUST NOT subscribe for alerting in MVP — read the audit API | ✅ **Ratified 2026-06-24** — signs off the dormant-seam deferral; producer wires when the platform GTS event-type model lands | Architecture | +| `payer-attribution-drift` detective sweep (§7.5) | **MVP = inactive no-op.** `AttributionSweepJob::run` logs and returns `Ok`; resolving `resource_tenant_id` → nearest `self_managed` ancestor needs the `TenantResolverClient`, not wired into the gear (hermetic-test constraint). No `serve` ticker is scheduled (so the gear never runs a job that can only no-op). **Preventive coverage stands:** `MixedPayer`/`MissingPayer` on the post hot path. The `payer-attribution-drift` alarm route is marked **INACTIVE in MVP** in the alarm catalog | ✅ **Ratified 2026-06-24** — signs off the no-op detective seam; activate (sweep + ticker) when the resolver is wired | Architecture | + +### 7.12 References + +- **Repository-foundation** (see 01-repository-foundation.md §Component Model) — append-only, tamper seam `created_seq`/`row_hash`/`prev_hash`, `journal_entry(tenant_id, created_seq)` index, separate-committed alarm txn, period_id partitioning, AC #8/#15/#17/#19/#22 origins. +- [PRD.md](../PRD.md) — § Immutable audit logs, § Cross-cutting requirements, § Edge cases (right-to-erasure), § Observability — invariant alarms, AC #8/#12/#15/#17/#19/#22; manifest §9. +- Billing Module / Billing System PRDs — module + program context (SOX, GDPR, audit trails); legacy refs preserved in the source design. diff --git a/gears/bss/ledger/docs/design/03-payments-allocation.md b/gears/bss/ledger/docs/design/03-payments-allocation.md new file mode 100644 index 000000000..db1d73f55 --- /dev/null +++ b/gears/bss/ledger/docs/design/03-payments-allocation.md @@ -0,0 +1,809 @@ + + +# DESIGN — Payments — Settlement, Allocation & Chargebacks (Slice 2) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Naming Conventions and Design-Introduced Names](#15-naming-conventions-and-design-introduced-names) + - [1.6 Scope and Constraints](#16-scope-and-constraints) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Record Payment Settlement](#record-payment-settlement) + - [Record Settlement Return](#record-settlement-return) + - [Allocate Payment to Invoices](#allocate-payment-to-invoices) + - [Record Chargeback Phase](#record-chargeback-phase) + - [Grant or Apply Reusable Credit](#grant-or-apply-reusable-credit) + - [Read Unallocated Balance and Allocations](#read-unallocated-balance-and-allocations) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Allocation Precedence (Mode A)](#allocation-precedence-mode-a) + - [Per-Payment Money-Out Caps](#per-payment-money-out-caps) + - [Out-of-Order Queueing](#out-of-order-queueing) + - [Extended Lock Order, No-Negative and Tie-Out](#extended-lock-order-no-negative-and-tie-out) +- [4. States (CDSL)](#4-states-cdsl) + - [Pending Event State Machine](#pending-event-state-machine) + - [Dispute Cycle State Machine](#dispute-cycle-state-machine) + - [AR Sub-Status State Machine](#ar-sub-status-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Settlement Posting (Pattern A)](#settlement-posting-pattern-a) + - [Settlement Return Posting](#settlement-return-posting) + - [Allocation Engine (Pattern B, Mode A)](#allocation-engine-pattern-b-mode-a) + - [Chargeback Posting](#chargeback-posting) + - [Credit Application (Wallet)](#credit-application-wallet) + - [Out-of-Order Queue Persistence](#out-of-order-queue-persistence) + - [Balance Caches, Lock Order and Tie-Out Extension](#balance-caches-lock-order-and-tie-out-extension) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Non-Functional Considerations](#7-non-functional-considerations) +- [8. REST API Surface](#8-rest-api-surface) + - [8.1 Endpoints](#81-endpoints) + - [8.2 Queued Semantics (202)](#82-queued-semantics-202) + - [8.3 Problem Responses (RFC 9457)](#83-problem-responses-rfc-9457) +- [9. Data Model (Slice-Owned Tables)](#9-data-model-slice-owned-tables) + - [9.1 payment_settlement](#91-payment_settlement) + - [9.2 payment_allocation](#92-payment_allocation) + - [9.3 payment_allocation_refund](#93-payment_allocation_refund) + - [9.4 unallocated_balance](#94-unallocated_balance) + - [9.5 reusable_credit_subbalance](#95-reusable_credit_subbalance) + - [9.6 pending_event_queue](#96-pending_event_queue) + - [9.7 Cross-Table Constraints and Enum Usage](#97-cross-table-constraints-and-enum-usage) +- [10. Events and Alarms](#10-events-and-alarms) +- [11. Decision Log and Open Items](#11-decision-log-and-open-items) + - [11.1 Risks and Deferred Work](#111-risks-and-deferred-work) + - [11.2 Needs Discussion (P1–P10)](#112-needs-discussion-p1p10) + + + +## 1. Context + +### 1.1 Overview + +Records the **money side** of the invoice lifecycle: a payment **settling** (funds confirmed) is distinct from a payment **allocating** to specific posted invoices, so AR stays correct for prepayments, partial pay, multi-invoice application, and overpayments (PRD § Posting rules S2). Also covers chargeback/dispute posting, bank/ACH/SEPA settlement returns, and the wallet CreditApplication path. This feature posts **under** the Foundation engine (see 01-repository-foundation.md § Component Model) and activates the account classes the Foundation reserved. + +**Traces to**: `cpt-cf-bss-ledger-fr-payment-settlement-vs-allocation`, `cpt-cf-bss-ledger-fr-allocation-precedence`, `cpt-cf-bss-ledger-fr-chargeback-dispute-posting`, `cpt-cf-bss-ledger-fr-negative-balance-invariants`, `cpt-cf-bss-ledger-fr-idempotency-per-flow`, `cpt-cf-bss-ledger-fr-idempotent-replay-contract`, `cpt-cf-bss-ledger-fr-out-of-order-event-handling`, `cpt-cf-bss-ledger-fr-money-rounding-scale`, `cpt-cf-bss-ledger-fr-ar-tie-out`, `cpt-cf-bss-ledger-fr-policy-versioning-immutability`, `cpt-cf-bss-ledger-fr-tenant-isolation-posting`, `cpt-cf-bss-ledger-fr-exception-suspense-handling`, `cpt-cf-bss-ledger-fr-posting-immutability`, `cpt-cf-bss-ledger-nfr-posting-performance`, `cpt-cf-bss-ledger-nfr-availability` + +### 1.2 Purpose + +Keep AR correct while cash moves independently of invoices: receipt alone MUST NOT move AR (Pattern A lands funds in Unallocated cash); allocation (Pattern B) applies cash to invoices under per-payment money-out caps; chargebacks and returns claw money back without ever editing original journal entries; the wallet path converts unallocated cash into reusable customer credit and applies it to AR with no Cash movement. + +Success criteria: every money-out path (allocation, refund, chargeback, return) is serialized and capped at the `payment_settlement` counter row; no balance ever goes negative silently; every flow is idempotent on its business key and replay returns the prior reference (Foundation AC #19); out-of-order events queue durably and never post partial outcomes. + +**Requirements**: `cpt-cf-bss-ledger-fr-payment-settlement-vs-allocation`, `cpt-cf-bss-ledger-fr-allocation-precedence`, `cpt-cf-bss-ledger-fr-chargeback-dispute-posting`, `cpt-cf-bss-ledger-fr-negative-balance-invariants`, `cpt-cf-bss-ledger-fr-out-of-order-event-handling`, `cpt-cf-bss-ledger-nfr-posting-performance` + +**Use cases**: `cpt-cf-bss-ledger-usecase-ledger-inquiry`, `cpt-cf-bss-ledger-usecase-exception-resolution`, `cpt-cf-bss-ledger-usecase-reconciliation-review` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-bss-ledger-actor-payments-psp` | Emits the upstream facts (PaymentSettled, SettlementReturned, allocation intent, dispute cycle/phase, CreditApplication) by **calling** the §8 REST endpoints (call-driven ingestion — no inbound bus on the post path); owns PSP webhook crypto/verification and chargeback case management | +| `cpt-cf-bss-ledger-actor-finance-ops` | Reads the unallocated pool and allocations; resolves exception-queue items (over-allocated returns, chargeback-on-refunded) | +| `cpt-cf-bss-ledger-actor-revenue-assurance` | Receives no-negative / chargeback-cash-negative / aged-queue alarms (same routing as AC #17 violations) | +| `cpt-cf-bss-ledger-actor-finance-approver` | Dual-control approval for high-value credit grants and chargeback-loss postings (effective-dated policy thresholds) | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — § Posting rules S2, § Balances, § Allocation precedence, § Chargebacks, § Edge cases (CreditApplication), § Money, AC #5/#7/#10/#25 +- **Design**: [01-repository-foundation.md](./01-repository-foundation.md) — Foundation engine (per-entry ACID PostingService, append-only journal + strict line-negation reversal, IdempotencyGate + 3-column `idempotency_dedup (tenant_id, flow, business_id)` PK, MoneyModule banker's rounding + residual-cent rules, BalanceProjector upsert + conditional no-negative CHECK, FiscalPeriodGuard, leaf-partition balance/zero-sum commit trigger, total fixed lock order, multi-tenant per-line scoping, daily TieOutJob, outbox relay). Not restated here; RFC 2119 keywords are normative. +- **Dependencies**: Foundation engine / posting-engine-core (slice 1) upstream; Payments module (PSP) upstream. Downstream: adjustments-notes-refunds (slice 3, reuses `payment_allocation` + the per-(payment,invoice) cap), reconciliation-export (slice 7, Payments↔PSP and unallocated visibility). + +**Canonical slice numbering** (decomposition order, used throughout): 1 posting-engine-core, **2 payments-allocation (this feature)**, 3 adjustments-notes-refunds, 4 asc606-recognition, 5 fx-multicurrency, 6 audit-immutability-observability, 7 reconciliation-export, 8 other. PRD posting-rule labels (S1…S6) are a **separate** axis from slice numbers. + +### 1.5 Naming Conventions and Design-Introduced Names + +**Account-class names** map to the **Foundation** `account_class` enum literals: Unallocated cash = `UNALLOCATED`, Cash/clearing = `CASH_CLEARING`, Reusable customer credit = `REUSABLE_CREDIT`, plus `AR`. The Foundation `account_class` enum (which declares every class any handler uses) includes `DISPUTE_HOLD` (debit-normal) — parking for the chargeback **cash-hold** variant (§2 Record Chargeback Phase; GL treatment pending Finance, §11.2 P9) — and `PSP_FEE_EXPENSE` (debit-normal expense) for PSP per-transaction / per-dispute fees. This feature **posts to** them, it does not alter the enum. Prose "suspense/unapplied pool" always means `UNALLOCATED`; the `SUSPENSE` class is mapping-exception parking only and is never posted by this feature. The `source_doc_type` / `flow` enums (Foundation-declared, incl. `INVOICE_POST | REVERSAL | MAPPING_CORRECTION` and the payment flows `PAYMENT_SETTLE | PAYMENT_ALLOCATE | CHARGEBACK | CREDIT_APPLY | SETTLEMENT_RETURN`) carry this feature's values; idempotency `flow` mirrors these. **Dispute vs chargeback:** "dispute" names the case/correlation from Payments; "chargeback" names the posting flow. + +Design-introduced names (this feature): + +| Name | Meaning | +|------|---------| +| `payment_settlement` | Per-payment guarded counter `(tenant, payment_id, settled_minor, allocated_minor, refunded_minor, refunded_unallocated_minor, clawed_back_minor)` — the **DB serialization point** for every per-payment money-out cap (allocations, refunds, clawbacks, returns); CHECKs in §9.1. | +| `payment_allocation` | Ledger record of an N:M Invoice↔Payment allocation the ledger **computed**; drives per-invoice AR reduction + the per-`(payment,invoice)` cap reused by refunds (slice 3). | +| `payment_allocation_refund` | Per-`(payment, invoice)` guarded counter `(tenant, payment_id, invoice_id, allocated_minor, refunded_minor)` — **created and incremented here at allocation time** (`allocated_minor += amount` in the same ACID txn, multiple allocations to one pair aggregate); slice 3 adds the `refunded_minor` side + its `CHECK (refunded_minor ≤ allocated_minor)` consumption. Migration owned by this feature. | +| `unallocated_balance` | Derived cache of **Unallocated cash** per `(tenant, payer, currency)`. | +| `reusable_credit_subbalance` | Derived cache of **Reusable customer credit** per `(tenant, payer, currency, credit_grant_event_type)` — the per-event-type sub-balance the PRD mandates. | +| `pending_event_queue` | Durable store of queued/quarantined work items — allocation-before-settlement, out-of-order dispute phases, dispute-held refund stage-2, slice 3 refund quarantine. **Owned by this feature; used by slices 2 and 3.** | +| AR sub-status (`ar_status` = `ACTIVE` \| `DISPUTED`) | An **as-posted snapshot** tag on `journal_line` and a **mutable** dimension on the `ar_invoice_balance` cache; chargeback "dispute opened" reclassification is **AR-class-neutral**. | + +### 1.6 Scope and Constraints + +**Non-goals / out of scope:** + +- **PSP card rails, webhook crypto/verification** — Payments module (PRD); the ledger **consumes** events. +- **Refunds (S5)**, credit/debit notes (S3/S4), contra-revenue, refund-clearing → slice 3. This feature never returns cash. +- **Chargeback case management** (alerts, evidence, UX) — Payments (PRD); this feature records only posting + reconciliation linkage. +- **Recognition (S6)**, FX, ERP export — later slices. Single-currency, FX-ready fields only. +- **Engine mechanics** — Foundation (01-repository-foundation.md); not restated. + +**In scope:** + +- S2 settlement vs allocation + N:M via `PaymentAllocation` (§ Posting rules S2; AC #5) +- partial multi-invoice allocation, overpayment-remainder-stays-unallocated, prepayment-Pattern-A-only +- unallocated/reusable-credit/overpayment semantics, split **not** optional (§ Balances) +- allocation precedence default + tenant overrides incl. customer-instructed (statutory registry deferred; AC #25) +- chargeback opened/won/lost/partial + idempotency-by-phase (AC #10) +- the dispute-opened sub-class move being **AR-class-neutral in the AR tie-out roll-up (AC #7)** +- CreditApplication two shapes + caps + both-grain serialization (§ Edge cases) +- out-of-order allocation-before-settlement & chargeback-phase queueing (§ Out-of-order) +- Payments↔PSP reconciliation visibility (§ Reconciliation flows) +- aged unallocated/clearing-queue alarm (§ Observability) +- money residual-cent for multi-invoice allocation + +**Idempotency flows:** settle (per PSP txn id), allocate (per allocation id), chargeback (per `(tenant, dispute_id:cycle:phase)`), settlement return (per PSP return id), credit-apply (per CreditApplication id). + +**Consumed upstream facts:** `PaymentSettled` (`pspTransactionId`, amount, payer, currency, **optional `feeMinor`** — PSP per-transaction fee withheld); **allocation intent** (`paymentId`, lump `amount`, payer, currency, **optional** customer-instructed `invoiceId` hint — **no candidate set from Payments**; the ledger derives the candidate open invoices from its **own** `ar_invoice_balance` (oldest-first default) and computes the per-invoice split, Mode A / P5 minimal form, 🔄 2026-06-15); dispute events (`disputeId`, `cycle` — dispute cycle number, starts at 1, increments on re-open, `phase ∈ {opened, won, lost, partial}`, amount); `SettlementReturned` (bank/ACH/SEPA return: `pspReturnId`, origin `pspTransactionId`, amount); `CreditApplication` (`creditApplicationId`, type, amount, payer/invoice). The ledger does **not** verify PSP webhooks (Payments owns that). **(Ingestion model — README):** all the above are **call-driven** — Payments (or its adapter) calls the §8 REST endpoints; the ledger consumes **no** inbound bus on the post path (C3). "Events" here name the upstream fact, not a subscription. **Settlement-event contract gate.** The field list above **is** the ratified **ledger-side** settlement consumer contract; the **other side is owned by Payments** and is a **pre-build gate** — Payments MUST confirm it emits exactly these facts (especially `feeMinor` for, the **PSP funds-movement fact** for the dispute-open variant, `cycle`, `pspReturnId`, and the refund `(pspRefundId, phase)` lifecycle) before this feature is built. Tracked in PRD § Deferred to future scope → Cross-team contract gates. + +**Constraints and assumptions** (inherits Foundation constraints C1–C4 and assumptions A1–A6, incl. B2/B3 open NFR items). Feature-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| P1 | Unallocated-pool modeling | **Single** Unallocated bucket + **per-event-type sub-balances** (`credit_grant_event_type`), preserving the unallocated-vs-reusable-credit split. PRD "suspense/unapplied pool" prose = `UNALLOCATED` only. | PRD | +| P2 | Dispute-opened segregation | **Variant driven by the PSP funds-movement fact, not tenant policy:** PSP **withholds funds at open** (card rails) → **cash-hold mandatory** (to the additive `DISPUTE_HOLD` class) so `CASH_CLEARING` ties to the PSP balance; PSP **does not move funds at open** (invoice/ACH) → **within-AR reclassification** (`ACTIVE→DISPUTED`), AR-class-neutral. won/lost/partial branch on which variant was posted. Original payment JEs never edited. | PRD | +| P3 | Atomic settle-and-apply shortcut | Single-entry `DR Cash / CR AR` shortcut allowed **only** with PSP/tenant-guaranteed atomic settle+apply, no residual unallocated. The shortcut MUST still **seed `payment_settlement`** in the same txn (`settled_minor = allocated_minor = amount`, other counters 0) — otherwise a later refund/chargeback/return on that payment has no `settled_minor` to cap against. Invariant: **no AR-reducing money movement without a `payment_settlement` row.** | PRD | +| P4 | Statutory allocation-rule registry | **Deferred — out of v1 scope.** The customer-instructed override (precedence step 2) is the B2B compliance path; a data-driven jurisdiction→rule registry is a post-MVP extension, added only if Legal names a market whose payers a statutory regime binds. | PRD | +| P5 | Allocation split ownership | **Mode A (minimal form, 🔄 2026-06-15)**: the ledger **derives the candidate open invoices from its own `ar_invoice_balance`** (oldest-first default) and **computes** the per-invoice split via precedence, writing `payment_allocation` rows. The consumed event carries the **settlement** (amount/payer/currency) **+ an optional customer-instructed hint** — **not** a candidate set from Payments and **not** pre-split rows. | PRD AC #25 | +| P6 | Tenant-hierarchy payment delegation | **In scope.** `payer_tenant_id` arrives **already resolved** on the consumed settlement event per the payer-resolution rule (payer = nearest `self_managed` ancestor-or-self of `resource_tenant`; `self_managed` = billing boundary); one payer per entry; allocation runs at that payer's grain. The ledger does **not** resolve payer from the tree — resolution is upstream. **Deferred:** ledger-side re-validation guard (Variant C); settlement transfer / payout / summary invoice. | PRD § Multi-tenant | + +All handlers post **through** the Foundation `PostingService` (one ACID txn per entry, upsert balance projection in the extended total lock order, leaf-partition commit trigger, idempotent-replay) — see 01-repository-foundation.md § Component Model. + +## 2. Actor Flows (CDSL) + +### Record Payment Settlement + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-payment-settlement` + +**Actor**: `cpt-cf-bss-ledger-actor-payments-psp` + +On `PaymentSettled`, post Pattern A — funds land in **Unallocated cash**, **not** AR; receipt alone MUST NOT move AR — and create/seed the `payment_settlement` counter (`settled_minor`; all other counters `= 0`). Posting legs: + +| Line | Side | Account class | +|------|------|---------------| +| Cash / bank clearing — **net** deposited | DR | `CASH_CLEARING` | +| PSP fee withheld (if any) | DR | `PSP_FEE_EXPENSE` | +| Unallocated cash — **gross** settled | CR | `UNALLOCATED` | + +**PSP fee.** When `PaymentSettled` carries a per-transaction `feeMinor`, the entry splits as above: DR `CASH_CLEARING` **net**, DR `PSP_FEE_EXPENSE` fee, CR `UNALLOCATED` **gross** — so `CASH_CLEARING` ties to the **net** bank deposit while the payer is relieved at **gross** (the fee never reduces AR). **(🔄 2026-06-15)** In v1 the fee is borne by the **merchant tenant — hardcoded, no config** (all lines share its payer-tenant scope, so zero-sum holds). "Platform absorbs the fee" is a **future feature** (not built in v1): it breaks per-tenant zero-sum → needs a separate platform-scope entry, and would only ship with a Finance/Product decision (and could then become a per-tenant/per-plan config). With **no** `feeMinor` (PSP settles gross / invoices fees monthly), the entry is the original two-line shape and the fee posts as its own entry when charged. The Payments↔PSP reconciliation (slice 7) ties at the **net** payout, the fee leg accounting for gross − net. **Note:** the ledger has **no "must not operate at a loss" invariant** — P&L is not a posting constraint; only **account balances** carry the no-negative rule. + +**Success Scenarios**: +- Payment settles; funds land in `UNALLOCATED` at gross; `payment_settlement` counter seeded; `unallocated_balance` upserted +- Settlement with `feeMinor` posts the three-leg split; `CASH_CLEARING` ties to net deposit + +**Error Scenarios**: +- Replay of the same `pspTransactionId` returns the prior posting reference (Foundation AC #19), no duplicate entry + +**Steps**: +1. [ ] - `p1` - Payments module calls API: POST /v1/ledger/payments (body: pspTransactionId, {amountMinor, currency, scale}, resolved payer_tenant_id per P6, optional feeMinor) - `inst-set-api` +2. [ ] - `p1` - Claim idempotency: `idempotency_dedup (tenant, PAYMENT_SETTLE, pspTransactionId)` via Foundation IdempotencyGate - `inst-set-idem` +3. [ ] - `p1` - **IF** replay: **RETURN** prior posting reference (AC #19) - `inst-set-replay` +4. [ ] - `p1` - **IF** feeMinor present: build three-leg entry (DR `CASH_CLEARING` net, DR `PSP_FEE_EXPENSE` fee, CR `UNALLOCATED` gross); **ELSE** two-leg (DR `CASH_CLEARING` / CR `UNALLOCATED` gross) - `inst-set-legs` +5. [ ] - `p1` - Post one balanced entry through the Foundation PostingService (ACID txn, extended lock order §3) - `inst-set-post` +6. [ ] - `p1` - DB: In the same txn, seed `payment_settlement` (`settled_minor` = gross; all other counters = 0) - `inst-set-seed` +7. [ ] - `p1` - DB: Upsert `unallocated_balance` += **gross** amount via BalanceProjector - `inst-set-upsert` +8. [ ] - `p1` - Emit `billing.ledger.payment.settled` via the Foundation outbox (§10) - `inst-set-event` +9. [ ] - `p1` - **RETURN** 201 posting reference - `inst-set-return` + +### Record Settlement Return + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-payment-settlement-return` + +**Actor**: `cpt-cf-bss-ledger-actor-payments-psp` + +**(normative).** A bank/ACH/SEPA return of a settled payment posts flow `SETTLEMENT_RETURN` (idempotent per `(tenant, SETTLEMENT_RETURN, pspReturnId)`; `source_doc_type = SETTLEMENT_RETURN`): DR `UNALLOCATED` / CR `CASH_CLEARING`, clawing back from the payer's Unallocated pool **first**, and decrements `payment_settlement.settled_minor` in the same txn under the rank-1 row lock. + +**Success Scenarios**: +- Return posts, unallocated pool clawed back, `settled_minor` decremented + +**Error Scenarios**: +- `allocated_minor` exceeds the remaining settled amount → MUST NOT auto-post; routes to the **exception queue** (slice 7; additive type `SETTLEMENT_RETURN_OVER_ALLOCATED`) for explicit de-allocation/chargeback handling +- Return would drive `CASH_CLEARING` negative (funds already swept) → reuses the chargeback-cash-negative pattern (balanced documented-loss line to `DISPUTE_LOSS_EXPENSE` + alarm) + +**Steps**: +1. [ ] - `p1` - Payments module calls API: POST /v1/ledger/payments/{paymentId}/returns (body: pspReturnId, origin pspTransactionId, amount) - `inst-ret-api` +2. [ ] - `p1` - Claim idempotency: `idempotency_dedup (tenant, SETTLEMENT_RETURN, pspReturnId)`; **IF** replay **RETURN** prior reference - `inst-ret-idem` +3. [ ] - `p1` - DB: Lock the `payment_settlement` row (rank-1 counter row within the extended lock order) - `inst-ret-lock` +4. [ ] - `p1` - **IF** `allocated_minor` > remaining settled amount after the return: **RETURN** route to exception queue (`SETTLEMENT_RETURN_OVER_ALLOCATED`) — never auto-post - `inst-ret-overalloc` +5. [ ] - `p1` - Post balanced entry DR `UNALLOCATED` / CR `CASH_CLEARING` (claw back from the payer's Unallocated pool first) - `inst-ret-post` +6. [ ] - `p1` - **IF** the entry would drive `CASH_CLEARING` negative: post the balanced documented-loss line (`DISPUTE_LOSS_EXPENSE`) and raise the chargeback-cash-negative alarm instead of a negative Cash balance - `inst-ret-negcash` +7. [ ] - `p1` - DB: Decrement `payment_settlement.settled_minor` in the same txn - `inst-ret-decrement` +8. [ ] - `p1` - Emit `billing.ledger.settlement.returned` (§10) - `inst-ret-event` +9. [ ] - `p1` - **RETURN** 201 posting reference - `inst-ret-return` + +### Allocate Payment to Invoices + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-payment-allocation` + +**Actor**: `cpt-cf-bss-ledger-actor-payments-psp` + +Computes the per-invoice split (Mode A, `cpt-cf-bss-ledger-algo-allocation-mode-a`) for a lump amount, then posts Pattern B: + +| Line | Side | Account class | +|------|------|---------------| +| Unallocated cash | DR | `UNALLOCATED` | +| AR (per invoice) | CR | `AR` | + +In the same ACID txn the handler increments `payment_settlement.allocated_minor` by the allocated total; the `CHECK (allocated_minor ≤ settled_minor)` row is the **serialization point** that makes concurrent allocates of the same payment safe even when the payer's pooled `unallocated_balance` is positive from **other** payments (over-cap → `ALLOCATION_EXCEEDS_SETTLED`). Spendable headroom additionally nets Pattern-A refunds: `CHECK (allocated_minor + refunded_unallocated_minor ≤ settled_minor)` — cash already refunded from the pool cannot be allocated (same error). Open AR per invoice reduces only by its allocated amount; **one allocate writes N `payment_allocation` rows — one per invoice** (per-row key `(tenant_id, allocation_id, invoice_id)`), request-idempotent per `allocationId` via `idempotency_dedup (tenant, PAYMENT_ALLOCATE, allocationId)` (replay returns the prior reference, no duplicate rows). The handler also upserts `payment_allocation_refund (tenant, payment_id, invoice_id).allocated_minor += ` in the same txn (lock rank per slice 3's unified order), so the slice 3 refund cap always reads an authoritative, race-free counter — never a seed-by-sum at first refund. + +**Currency match (normative).** MVP allocation requires `settlement.currency == invoice.currency` (and the entry stays single-transaction-currency); a mismatch rejects with `ALLOCATION_CURRENCY_MISMATCH` (400 — a malformed request, not a balance cap; Slice 2 review #1/#5) — the PRD's "lock on alloc to invoices in another currency" path is **not silently supported**. Cross-currency application requires a future designed conversion event (two same-currency balanced legs + an FX bridge, slice 5 extension; §11.2 P7). The **multi-invoice allocation residual cent attaches to the largest open invoice** (Foundation `MoneyModule`, PRD). Overpayment remainder stays in `unallocated_balance` — **no silent relabel** to reusable credit (needs the wallet grant flow). Narrow single-entry shortcut only under P3 — and even the shortcut seeds `payment_settlement` (`settled_minor = allocated_minor` in the same txn) so refund/chargeback/return caps still apply. + +**Bounds (working default, PM confirmed — §11.2 P10).** One allocation transaction **touches** at most **500 invoices** — the bound is on the invoices the split actually pays (each is a posted `AR` leg), NOT on the payer's open-invoice backlog, which is read-only and uncapped. So a payer with thousands of open invoices whose payment reaches only a few of them allocates in one transaction; the ceiling exists to keep the posted entry under the Foundation engine's 1,000-line limit. The cap is configurable (`payments.max_invoices_per_allocation`, default 500, bounded `1..=998` at boot — never past the line ceiling). A split that would touch more than the cap (a lump large enough to pay > cap invoices at once) is today rejected `ALLOCATION_TOO_LARGE`; processing it as **chunked continuations under the same `allocationId`** (deterministic precedence order, one ACID txn per chunk, idempotency row finalizing on the last chunk) is a tracked follow-up. Referenced by the §6/§7 load tests. + +**Success Scenarios**: +- Lump amount is split across open invoices oldest-first; AR reduces per invoice; remainder stays unallocated +- Large open-invoice backlog with a payment that touches ≤ cap invoices → allocated in one transaction (the backlog size is not the bound) + +**Error Scenarios**: +- Allocation total over the per-payment cap → `ALLOCATION_EXCEEDS_SETTLED` (409) +- Settlement currency ≠ invoice currency → `ALLOCATION_CURRENCY_MISMATCH` (400) +- Matching settlement not yet arrived → **202** `allocation-queued` (never rejected; queued per `cpt-cf-bss-ledger-algo-payment-out-of-order-queueing`) + +**Steps**: +1. [ ] - `p1` - Payments module calls API: POST /v1/ledger/payments/{paymentId}/allocations (body: allocationId, lump amount, payer, currency, optional customer-instructed invoiceId hint) - `inst-alloc-api` +2. [ ] - `p1` - Claim idempotency: `idempotency_dedup (tenant, PAYMENT_ALLOCATE, allocationId)`; **IF** replay **RETURN** prior reference (or continuation handle / queued reference) - `inst-alloc-idem` +3. [ ] - `p1` - **IF** no matching `payment_settlement` row (allocation before settlement): queue in `pending_event_queue` and **RETURN** 202 `allocation-queued` + correlation handle - `inst-alloc-queue` +4. [ ] - `p1` - **IF** settlement.currency != invoice.currency: **RETURN** 400 `ALLOCATION_CURRENCY_MISMATCH` - `inst-alloc-currency` +5. [ ] - `p1` - DB: Derive candidate open invoices from the ledger's **own** `ar_invoice_balance` (oldest-first default; Mode A / P5) - `inst-alloc-candidates` +6. [ ] - `p1` - Algorithm: compute per-invoice split using `cpt-cf-bss-ledger-algo-allocation-mode-a` (residual cent → largest open invoice) - `inst-alloc-split` +7. [ ] - `p1` - Enforce the touched-invoice bound on the COMPUTED split (invoices receiving a positive amount), not the candidate read: **IF** the split touches > `payments.max_invoices_per_allocation` (default 500) invoices **RETURN** `ALLOCATION_TOO_LARGE`. Processing such a split as chunked continuations under the same `allocationId` (one ACID txn per chunk, deterministic precedence order, idempotency row finalizing on the last chunk) is a tracked follow-up - `inst-alloc-chunk` +8. [ ] - `p1` - Post one balanced entry per txn: DR `UNALLOCATED` / CR `AR` per invoice, through the Foundation PostingService - `inst-alloc-post` +9. [ ] - `p1` - DB: In the same txn, increment `payment_settlement.allocated_minor`; **IF** `CHECK (allocated_minor + refunded_unallocated_minor ≤ settled_minor)` fails **RETURN** 409 `ALLOCATION_EXCEEDS_SETTLED` - `inst-alloc-cap` +10. [ ] - `p1` - DB: In the same txn, insert N `payment_allocation` rows (one per invoice, key `(tenant_id, allocation_id, invoice_id)`), stamping `precedence_policy_ref` - `inst-alloc-rows` +11. [ ] - `p1` - DB: In the same txn, upsert `payment_allocation_refund.allocated_minor += per-invoice amount` per `(tenant, payment_id, invoice_id)` - `inst-alloc-refundcap` +12. [ ] - `p1` - Emit `billing.ledger.payment.allocated` (§10) - `inst-alloc-event` +13. [ ] - `p1` - **RETURN** 201 posting reference (per-invoice split) - `inst-alloc-return` + +### Record Chargeback Phase + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-payment-chargeback-phase` + +**Actor**: `cpt-cf-bss-ledger-actor-payments-psp` + +Records posting + reconciliation linkage only; idempotent per `(tenant, dispute_id:cycle:phase)` — the `business_id` is the snake_case composite `dispute_id:cycle:phase` occupying the Foundation's single `business_id` column (PK shape `(tenant_id, flow, business_id)` unchanged); `cycle` is the dispute cycle number from the Payments contract (starts at 1, increments when a dispute re-opens, e.g. pre-arbitration/arbitration). The phase state machine is **re-entrant per cycle**: after `won` in cycle N, cycle N+1 posts `opened` again. For the **opened** phase, `dispute_id` is the stable correlation id from Payments (the PSP dispute id, or a `paymentId`-derived correlation where the PSP keys off the payment). Original payment JEs are never edited. + +**Variant selection at `opened`:** the `opened` posting picks cash-hold vs AR-reclass from the **PSP funds-movement fact** on the dispute event, **not** tenant policy — if the event reports the disputed funds **withheld** (card rails), `cash-hold` is **mandatory** (`DISPUTE_HOLD`) so `CASH_CLEARING` ties to the PSP balance during the dispute; if funds are **not** moved at open (invoice/ACH), `AR-reclass` (`ACTIVE→DISPUTED`). Outcomes then **branch on the recorded `opened` variant** (P2): + +| Phase | If opened = AR-reclass | If opened = cash-hold | +|-------|------------------------|------------------------| +| **opened** | Post a balanced reclassification `AR ACTIVE→DISPUTED` (AR-class-neutral; MUST NOT zero/negate `(payer,invoice)` AR or flip payer-aggregate sign) | Move settled cash `CASH_CLEARING → DISPUTE_HOLD` (additive class; §11.2 P9) | +| **won** | Reclassify `DISPUTED→ACTIVE` (no cash leg) | Release `DISPUTE_HOLD → CASH_CLEARING` | +| **lost** | `DR AR` (re-open) and net funds kept vs clawed back via a balanced Cash/loss line | Release `DISPUTE_HOLD` and `CR CASH_CLEARING` for the clawback | +| **partial / split** | Balanced JEs netting to the PSP outcome | same | + +Revenue is unchanged unless an S3 note applies (slice 3). **Negative-Cash interaction:** a clawback that would drive `CASH_CLEARING` below zero (funds already swept) MUST NOT silently post and MUST NOT be hard-rejected losing the dispute outcome — it posts a balanced **documented-loss** line to the named class **`DISPUTE_LOSS_EXPENSE`** (minor — debit-normal expense, additive per C2; **not** the `SUSPENSE` mapping class) and routes to the dedicated **chargeback-cash-negative** alarm (§10, same routing to Revenue Assurance as AC #17 violations) so the outcome is recorded without a negative Cash balance. The dispute-**opened** reclass is **excluded** from the AR tie-out roll-up delta (AR-class-neutral, AC #7). **(minor — partial dispute)** A **partial** chargeback disputes only part of an invoice, but `ar_status` on `ar_invoice_balance` is one enum per invoice. To avoid overstating the disputed amount: the reclassification moves **only the disputed sub-amount** between the invoice's AR `ACTIVE`/`DISPUTED` sub-class balances (same balanced AR-class-neutral move, scoped to the disputed minor amount); the invoice-level `ar_status` flag flips to `DISPUTED` only when the **full** open AR is disputed, otherwise it stays `ACTIVE` alongside a non-zero `DISPUTED` sub-balance. Disputed-amount reporting reads the sub-balance, not the flag. + +**Refund interaction (normative).** `lost`/cash-out outcomes increment `payment_settlement.clawed_back_minor` in the posting txn under the rank-1 lock; the total money-out cap `CHECK (refunded_minor + clawed_back_minor ≤ settled_minor)` blocks paying out the same settlement twice. An **open** dispute on a payment **holds that payment's refund stage-2** in `pending_event_queue` (slice 3 consults the dispute state before posting). A `lost` outcome on an **already-refunded** payment MUST NOT auto-post — it routes to the exception queue (slice 7; additive type `CHARGEBACK_ON_REFUNDED`) for manual disposition. + +**Success Scenarios**: +- `opened` posts the variant selected by the PSP funds-movement fact; `won`/`lost`/`partial` branch on the recorded variant +- Cycle N+1 posts `opened` again after cycle N `won` + +**Error Scenarios**: +- Phase out of order (won/lost before opened) → **202** `dispute-phase-queued` with alert; never a partial outcome +- `lost` on an already-refunded payment → exception queue `CHARGEBACK_ON_REFUNDED`, never auto-posted +- Clawback would drive `CASH_CLEARING` negative → documented-loss line + chargeback-cash-negative alarm + +**Steps**: +1. [ ] - `p1` - Payments module calls API: POST /v1/ledger/disputes/{disputeId}/phases (body: cycle, phase, amount, PSP funds-movement fact) - `inst-cb-api` +2. [ ] - `p1` - Claim idempotency: `idempotency_dedup (tenant, CHARGEBACK, dispute_id:cycle:phase)`; **IF** replay **RETURN** prior reference - `inst-cb-idem` +3. [ ] - `p1` - **IF** prerequisite phase missing (e.g. won/lost before opened): queue via `cpt-cf-bss-ledger-algo-payment-out-of-order-queueing` and **RETURN** 202 `dispute-phase-queued` + alert - `inst-cb-ooo` +4. [ ] - `p1` - **IF** phase == opened: select variant from the PSP funds-movement fact — funds withheld → cash-hold (`CASH_CLEARING → DISPUTE_HOLD`); funds not moved → AR-reclass (`ACTIVE→DISPUTED`, AR-class-neutral, scoped to the disputed sub-amount for partial disputes) - `inst-cb-opened` +5. [ ] - `p1` - **IF** phase ∈ {won, lost, partial}: post the balanced outcome entry per the phase/variant table above - `inst-cb-outcome` +6. [ ] - `p1` - **IF** phase == lost or cash-out: DB: increment `payment_settlement.clawed_back_minor` in the posting txn under the rank-1 lock; enforce `CHECK (refunded_minor + clawed_back_minor ≤ settled_minor)` - `inst-cb-clawback` +7. [ ] - `p1` - **IF** payment already refunded: **RETURN** route to exception queue (`CHARGEBACK_ON_REFUNDED`) — never auto-post - `inst-cb-refunded` +8. [ ] - `p1` - **IF** clawback would drive `CASH_CLEARING` negative: post balanced documented-loss line to `DISPUTE_LOSS_EXPENSE` + raise chargeback-cash-negative alarm - `inst-cb-negcash` +9. [ ] - `p1` - Emit `billing.ledger.dispute.recorded` (`cycle`, `phase`) (§10) - `inst-cb-event` +10. [ ] - `p1` - **RETURN** 201 posting reference - `inst-cb-return` + +### Grant or Apply Reusable Credit + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-payment-credit-application` + +**Actor**: `cpt-cf-bss-ledger-actor-payments-psp` + +Wallet path (distinct from S5 refunds — **no** Cash movement). Two shapes: + +| Event | Posting | Pre-commit cap(s) | +|-------|---------|-------------------| +| **Grant credit** (elect wallet over cash for an unallocated/overpayment balance) | DR `UNALLOCATED` · CR `REUSABLE_CREDIT` | grant ≤ payer's available `unallocated_balance` for the currency (over-cap → `GRANT_EXCEEDS_UNALLOCATED`) | +| **Apply credit to AR** | DR `REUSABLE_CREDIT` · CR `AR` | **(1)** per-invoice: ≤ invoice open AR (`CREDIT_EXCEEDS_OPEN_AR`); **(2)** per-sub-grain wallet: debit specific `reusable_credit_subbalance` rows in a **deterministic order (oldest `credit_grant_event_type` first)** and require Σ over the chosen sub-grains ≤ their summed available (`CREDIT_EXCEEDS_WALLET`) | + +Idempotent per `(tenant, CREDIT_APPLY, creditApplicationId)`. Every posted `REUSABLE_CREDIT` journal line MUST carry `credit_grant_event_type` (two-sided `CHECK`, Foundation): grants stamp the granting event's type, applications stamp the drawn sub-grain's type — keeping the wallet sub-grain rebuildable from line truth. Cap (2) is evaluated against the **specific sub-grain rows being debited**, not a notional aggregate, so a draw that overdraws one event-type sub-grain while the aggregate is positive is **blocked pre-commit** (the sub-grain NO-negative `CHECK` is defence-in-depth). Concurrent CreditApplications serialize at **both** grains via the extended lock order (§3). **Contract liability is never a wallet/refund target.** + +**Success Scenarios**: +- Grant converts unallocated cash into wallet credit tagged with the granting event type +- Apply draws wallet sub-grains oldest-event-type-first and reduces invoice AR + +**Error Scenarios**: +- Grant over the unallocated pool → `GRANT_EXCEEDS_UNALLOCATED` (409) +- Apply over the invoice open AR → `CREDIT_EXCEEDS_OPEN_AR` (409) +- Apply over the chosen sub-grains' available → `CREDIT_EXCEEDS_WALLET` (409) + +**Steps**: +1. [ ] - `p1` - Payments module calls API: POST /v1/ledger/credit-applications (body: creditApplicationId, type ∈ {grant, apply}, amount, payer / invoice) - `inst-cr-api` +2. [ ] - `p1` - Claim idempotency: `idempotency_dedup (tenant, CREDIT_APPLY, creditApplicationId)`; **IF** replay **RETURN** prior reference - `inst-cr-idem` +3. [ ] - `p1` - **IF** type == grant - `inst-cr-grant` + 1. [ ] - `p1` - **IF** grant > payer's available `unallocated_balance` for the currency: **RETURN** 409 `GRANT_EXCEEDS_UNALLOCATED` - `inst-cr-grant-cap` + 2. [ ] - `p1` - Post DR `UNALLOCATED` / CR `REUSABLE_CREDIT`, stamping `credit_grant_event_type` from the granting event on the `REUSABLE_CREDIT` line - `inst-cr-grant-post` +4. [ ] - `p1` - **IF** type == apply - `inst-cr-apply` + 1. [ ] - `p1` - **IF** amount > invoice open AR: **RETURN** 409 `CREDIT_EXCEEDS_OPEN_AR` - `inst-cr-apply-ar` + 2. [ ] - `p1` - DB: Select `reusable_credit_subbalance` rows to debit in deterministic order (oldest `credit_grant_event_type` first) - `inst-cr-apply-order` + 3. [ ] - `p1` - **IF** Σ over the chosen sub-grains > their summed available: **RETURN** 409 `CREDIT_EXCEEDS_WALLET` (evaluated against the specific rows being debited, not the aggregate) - `inst-cr-apply-cap` + 4. [ ] - `p1` - Post DR `REUSABLE_CREDIT` / CR `AR`, stamping each `REUSABLE_CREDIT` line with the drawn sub-grain's `credit_grant_event_type` - `inst-cr-apply-post` +5. [ ] - `p1` - Serialize at **both** grains (sub-grain + invoice) via the extended lock order - `inst-cr-serialize` +6. [ ] - `p1` - Emit `billing.ledger.credit.applied` (§10) - `inst-cr-event` +7. [ ] - `p1` - **RETURN** 201 posting reference - `inst-cr-return` + +### Read Unallocated Balance and Allocations + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-flow-payment-balance-inquiry` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-ops` + +**Success Scenarios**: +- Finance Ops reads the unallocated pool by payer/currency; lists a payment's allocations + +**Error Scenarios**: +- Cross-tenant read blocked by RLS (payer-tenant axis; Variant B subtree-RLS for parent reads — §7 Security) + +**Steps**: +1. [ ] - `p1` - API: GET /v1/ledger/balances/unallocated (query: payer, currency) — cache read of `unallocated_balance` - `inst-inq-unalloc` +2. [ ] - `p1` - API: GET /v1/ledger/payments/{paymentId}/allocations — DB: read `payment_allocation` by index `(tenant_id, payment_id)` - `inst-inq-allocs` +3. [ ] - `p1` - **RETURN** 200 (balances / allocation rows incl. `precedence_policy_ref`) - `inst-inq-return` + +## 3. Processes / Business Logic (CDSL) + +### Allocation Precedence (Mode A) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-allocation-mode-a` + +**Input**: lump settlement amount (+ optional customer-instructed hint), candidate open invoices from `ar_invoice_balance`, effective-dated tenant precedence policy +**Output**: deterministic per-invoice split (N rows) + the `precedence_policy_ref` that produced it + +The ledger computes the order/split (Mode A, P5; AC #25): + +1. **Default:** oldest posting date first; ties by smallest `invoice_id`. +2. **Tenant overrides:** customer-instructed hint, highest-amount-first, by tax-jurisdiction, or contract priority. +3. **Statutory appropriation rules** (e.g. UK CCA): **out of v1 scope** — the customer-instructed override (step 2) is the B2B compliance path. A data-driven jurisdiction→rule registry (overriding tenant config, rejecting a contradicting strategy with `STATUTORY_ALLOCATION_CONFLICT`) is a deferred post-MVP extension, added only if Legal names a market whose payers a statutory regime binds. + +**Policy versioning (normative).** The tenant precedence strategy is an **effective-dated version**; the version in effect at allocation time is stamped on every `payment_allocation` row as `precedence_policy_ref` (the slice 4 pinned-ref pattern). A policy change never rewrites past splits — re-runs reproduce them from the stamped version. (A future statutory registry, if added per, versions the same way.) + +**Policy/decision boundary (normative).** Allocation has two separable concerns: **(a) the decision** — given a lump + open invoices, which invoice gets how much; and **(b) the recording** — posting `DR Unallocated / CR AR` per invoice under the money-out caps / no-negative / residual-cent. **(b) is core ledger duty and stays here.** Mode A keeps the *default* decision (oldest-first + tenant overrides) inline as a ratified convenience (P5), but the decision is an **externalizable seam**: (1) **statutory appropriation rules MUST NEVER live in the ledger** — when the registry arrives it is an **external AR-policy / cash-application component** that reads open-AR from a ledger projection and issues an **explicit per-invoice split**, which the ledger only **validates** (caps/no-negative/`payment_settlement`) and records; (2) **Mode B** (the caller sends a pre-computed split, ledger validates) is the documented escape hatch the design weighed — `POST …/allocations` MAY accept a caller-computed split, validated identically, so jurisdiction-specific law never touches the append-only posting path. + +**Steps**: +1. [ ] - `p1` - Resolve the effective-dated tenant precedence policy version at allocation time - `inst-prec-policy` +2. [ ] - `p1` - **IF** customer-instructed hint present: apply it as the step-2 override; **ELSE IF** tenant override configured (highest-amount-first / tax-jurisdiction / contract priority): apply it; **ELSE** default oldest posting date first, ties by smallest `invoice_id` - `inst-prec-order` +3. [ ] - `p1` - **FOR EACH** invoice in precedence order: assign min(remaining lump, invoice open AR); stop at zero remainder - `inst-prec-assign` +4. [ ] - `p1` - Attach the multi-invoice residual cent to the **largest open invoice** (Foundation MoneyModule, PRD) - `inst-prec-residual` +5. [ ] - `p1` - Stamp `precedence_policy_ref` on every produced row - `inst-prec-stamp` +6. [ ] - `p1` - **RETURN** deterministic split (reproducible from the stamped policy version) - `inst-prec-return` + +### Per-Payment Money-Out Caps + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-payment-money-out-caps` + +**Input**: any money-out posting (allocation, refund stage-1, chargeback clawback, settlement return) referencing a payment +**Output**: capped, serialized counter update or a 409 cap error / exception-queue routing + +**(— normative).** `payment_settlement` is the single DB serialization point for **all** per-payment money-out: `allocated_minor` (allocation), `refunded_minor` + `refunded_unallocated_minor` (refund stage-1 increments — slice 3), `clawed_back_minor` (chargeback lost/cash-out), and the `settled_minor` decrement on settlement return. Refund rows (slice 3) carry **mandatory** `payment_id` + `currency` for **both** patterns, so every money-out finds its counter row. **Invariant:** **no AR-reducing money movement may post without a `payment_settlement` row** — every settlement path (Pattern A **and** the P3 atomic shortcut) seeds it, so the serialization point always exists for later refund/chargeback/return caps. + +**Steps**: +1. [ ] - `p1` - DB: Lock the `payment_settlement` row for the payment (rank-1 counter within the extended lock order; balance grains first — §3 lock order) - `inst-cap-lock` +2. [ ] - `p1` - Apply the counter delta for the flow (allocated_minor / refunded_minor / refunded_unallocated_minor / clawed_back_minor / settled_minor decrement) in the posting txn - `inst-cap-delta` +3. [ ] - `p1` - Enforce CHECKs post-delta: `allocated_minor ≤ settled_minor`; `allocated_minor + refunded_unallocated_minor ≤ settled_minor`; `refunded_minor + clawed_back_minor ≤ settled_minor`; `refunded_minor ≤ settled_minor`; `refunded_unallocated_minor ≥ 0`; `clawed_back_minor ≥ 0` - `inst-cap-checks` +4. [ ] - `p1` - **IF** a CHECK fails: **RETURN** the flow's cap error (409, e.g. `ALLOCATION_EXCEEDS_SETTLED`) or route to the exception queue where the flow mandates it (over-allocated return, chargeback-on-refunded) - `inst-cap-fail` +5. [ ] - `p1` - **RETURN** committed counter state (journal + counters commit or roll back together) - `inst-cap-return` + +### Out-of-Order Queueing + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-payment-out-of-order-queueing` + +**Input**: an event whose prerequisite is missing (allocation before settlement; chargeback phase out of order; dispute-held refund stage-2; slice 3 refund quarantine) +**Output**: durable queued item + stable replay reference; later apply or cancel + +- **Allocation before settlement:** the allocation **queues** (not rejected) and applies when the matching settlement arrives; aged orphan allocations alarm. +- **Chargeback phase out of order** (won/lost before opened): a missing prerequisite phase **queues** with an alert, never posts a partial outcome. +- **Queued-item lifecycle (normative):** on intake the `idempotency_dedup` row is claimed with status `QUEUED` (so a replay during the queued window returns that stable reference, honouring Foundation AC #19) and finalized to `POSTED` only on apply. For a `QUEUED` replay, `result_entry_id`/`posted_at_utc` are null and the returned reference is the queued correlation handle + status — a deliberate widening of the Foundation reference shape that still satisfies AC #19's prior-reference requirement. The per-payment cap, no-negative, and precedence are re-evaluated **at apply time** against then-current state — a queued item that has become over-cap by apply time is blocked/alarmed, never silently posted. +- **Persistence (normative):** every queued/quarantined item is a row in `pending_event_queue` — `(tenant_id, flow, business_id, payload jsonb (PII-free), queued_at, apply_after (nullable), status QUEUED|APPLIED|CANCELLED, attempts)`, RLS-scoped (C1), indexed `(tenant_id, flow, status, queued_at)`. **Owned by this feature; used by slices 2 and 3** — slice 3's refund quarantine and the dispute-held refund stage-2 persist here. Appliers claim rows `FOR UPDATE SKIP LOCKED` before entering the posting lock order; the aged-queue alarms (§10) read this table. A queued financial event survives restarts and never depends on upstream re-push. **(minor — single source of truth & recovery)** `pending_event_queue` is the **work-state SoT** for a queued item; `idempotency_dedup` only holds the **replay reference** (`QUEUED→POSTED`). The two writes happen in **one txn at intake** (claim dedup `QUEUED` + insert queue row), so a crash leaves either neither or both — never a half state. Apply is a **second txn** that posts and, atomically, flips `idempotency_dedup`→`POSTED` and `pending_event_queue.status`→`APPLIED`; a crash between intake and apply leaves a `QUEUED`/`QUEUED` pair that the applier safely re-drives (idempotent post), and a replay during the window returns the `QUEUED` reference. Reconciliation of the two is: `pending_event_queue.status` drives work, `idempotency_dedup` drives replay — they can lag by one txn but never diverge in outcome. +- *(Deferred: refund-before-payment (S5) and out-of-order recognition (S6) queue rules are owned by slices 3 and 4; slice 3's quarantined payloads persist in `pending_event_queue` above.)* + +**Steps**: +1. [ ] - `p1` - Intake txn: claim `idempotency_dedup` with status `QUEUED` **and** insert the `pending_event_queue` row (PII-free payload) atomically - `inst-ooo-intake` +2. [ ] - `p1` - **RETURN** 202 with kebab-case status token (`allocation-queued` / `dispute-phase-queued`) + correlation handle - `inst-ooo-202` +3. [ ] - `p1` - **IF** replay while queued: **RETURN** the stable `QUEUED` reference (null `result_entry_id`/`posted_at_utc`, queued correlation handle + status) - `inst-ooo-replay` +4. [ ] - `p1` - Applier: claim due rows `FOR UPDATE SKIP LOCKED` before entering the posting lock order - `inst-ooo-claim` +5. [ ] - `p1` - Apply txn: re-evaluate per-payment cap, no-negative, and precedence against **then-current** state - `inst-ooo-reeval` +6. [ ] - `p1` - **IF** over-cap at apply time: block + alarm, never silently post - `inst-ooo-blocked` +7. [ ] - `p1` - **ELSE** post via PostingService and atomically flip `idempotency_dedup`→`POSTED`, `pending_event_queue.status`→`APPLIED` - `inst-ooo-apply` +8. [ ] - `p1` - **RETURN** applied/blocked outcome; aged-queue alarms read `pending_event_queue.queued_at` - `inst-ooo-return` + +### Extended Lock Order, No-Negative and Tie-Out + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-payment-lock-order-tie-out` + +**Input**: every payment-feature post; the daily TieOutJob AR projection +**Output**: deadlock-free serialized posting; extended AR tie-out roll-up + +**Extended total lock order (over the Foundation's) — reconciled with the implementation (Slice 2 review #2 / #22).** Every post in this feature acquires its **balance-cache** row locks first, via the Foundation `BalanceProjector`, in one fixed `table_rank` order — `account_balance` → `ar_payer_balance` → `ar_invoice_balance` → `unallocated_balance` → `reusable_credit_subbalance` → `tax_subbalance`, then by `(tenant_id, …key…)` — and **only then** the in-transaction sidecar writes the counter rows (`payment_settlement`, `ledger_dispute`, `payment_allocation_refund`). So the balance grains are locked **before** the counters (the `PostingService` flow is project → sidecar), and within the AR grains **payer is locked before invoice** (`ar_payer_balance` rank < `ar_invoice_balance` rank), matching the projector sort key `(table_rank, tenant_id, account_id, currency, payer_tenant_id, invoice_id)` and the Foundation order. S1 and S2 share **one identical relative order** over the overlapping tables; an architecture test pins the grain ranks so a later slice cannot silently reorder them and open a deadlock cycle. *(The earlier wording — "`payment_settlement` first" and "invoice before payer" — did not match the code and is corrected here; slice 3 MUST follow the order above.)* + +**No-negative (NO negative, alarmed per AC #17):** `UNALLOCATED` and `CASH_CLEARING` via the **Foundation's** conditional `account_balance` `CHECK` (the Foundation declares the full guarded set incl. these; this feature posts to them, no cross-slice `ALTER`); **`REUSABLE_CREDIT` follows the `tax_subbalance` pattern** — **no** aggregate `account_balance` `CHECK`, guarded only at the `reusable_credit_subbalance` per-`(currency, event_type)` sub-grain (over-consumption against the credit-elected portion alarms even when the aggregate is positive). + +**TieOutJob extension (AC #7):** the AR projection now nets payment **allocations** (Pattern B), CreditApplication **apply-to-AR applications** (— slice 7 wording; wallet application is not a settlement), and chargeback **won/lost/partial** outcomes; the dispute-**opened** sub-class move is AR-class-neutral and MUST NOT enter the AR-delta roll-up. Payments↔PSP: settled cash ties to `CASH_CLEARING`; the unallocated pool is visible and reconciles to PSP/bank net of allocations. **(minor — completeness control)** `pending_event_queue` only covers events that **arrived**; a **lost** `PaymentSettled` (never delivered) is silently-missing cash that queue persistence cannot detect. The **completeness control is the slice 7 Payments↔PSP tie-out** (`CASH_CLEARING` vs PSP net settled): a gap surfaces there as a reconciliation variance, not in this feature. + +**Steps**: +1. [ ] - `p1` - Acquire balance-cache row locks first via BalanceProjector in ascending `table_rank` (`account_balance` → `ar_payer_balance` → `ar_invoice_balance` → `unallocated_balance` → `reusable_credit_subbalance` → `tax_subbalance`), then by `(tenant_id, …key…)` - `inst-lock-grains` +2. [ ] - `p1` - Then write the in-transaction sidecar counter rows (`payment_settlement`, `ledger_dispute`, `payment_allocation_refund`) - `inst-lock-counters` +3. [ ] - `p1` - Enforce no-negative: `UNALLOCATED`/`CASH_CLEARING` at the aggregate `account_balance` CHECK; `REUSABLE_CREDIT` only at the sub-grain CHECK - `inst-lock-noneg` +4. [ ] - `p1` - TieOutJob: net allocations + credit apply-to-AR + chargeback won/lost/partial into the AR roll-up; exclude the dispute-opened reclass - `inst-lock-tieout` +5. [ ] - `p1` - **RETURN** (architecture test pins the grain ranks) - `inst-lock-return` + +## 4. States (CDSL) + +### Pending Event State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-payment-pending-event` + +**States**: QUEUED, APPLIED, CANCELLED +**Initial State**: QUEUED (created atomically with the `idempotency_dedup` `QUEUED` claim at intake) + +**Transitions**: +1. [ ] - `p1` - **FROM** QUEUED **TO** APPLIED **WHEN** the applier posts the item successfully (second txn; flips `idempotency_dedup`→`POSTED` atomically) - `inst-st-pe-applied` +2. [ ] - `p1` - **FROM** QUEUED **TO** CANCELLED **WHEN** the item is explicitly cancelled/discarded during manual disposition - `inst-st-pe-cancelled` +3. [ ] - `p1` - **FROM** QUEUED **TO** QUEUED **WHEN** apply re-evaluation finds the item over-cap (blocked + alarmed, `attempts` incremented; never silently posted) - `inst-st-pe-blocked` + +### Dispute Cycle State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-payment-dispute-cycle` + +**States**: opened, won, lost, partial (per cycle; re-entrant per cycle) +**Initial State**: opened (cycle starts at 1; increments on re-open, e.g. pre-arbitration/arbitration) + +**Transitions**: +1. [ ] - `p1` - **FROM** opened **TO** won **WHEN** Payments reports phase=won for the cycle (release `DISPUTE_HOLD → CASH_CLEARING` or reclassify `DISPUTED→ACTIVE` per the recorded opened variant) - `inst-st-dc-won` +2. [ ] - `p1` - **FROM** opened **TO** lost **WHEN** Payments reports phase=lost (clawback + `clawed_back_minor` increment; documented-loss line if Cash would go negative) - `inst-st-dc-lost` +3. [ ] - `p1` - **FROM** opened **TO** partial **WHEN** Payments reports phase=partial (balanced JEs netting to the PSP outcome) - `inst-st-dc-partial` +4. [ ] - `p1` - **FROM** won (cycle N) **TO** opened (cycle N+1) **WHEN** the dispute re-opens — the phase machine is re-entrant per cycle; each `(dispute_id:cycle:phase)` is idempotent independently - `inst-st-dc-reopen` +5. [ ] - `p1` - Out-of-order phase arrival (won/lost before opened) does not transition — it queues (`cpt-cf-bss-ledger-state-payment-pending-event`) - `inst-st-dc-ooo` + +### AR Sub-Status State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-payment-ar-status` + +**States**: ACTIVE, DISPUTED (mutable on the `ar_invoice_balance` cache; **immutable as-posted snapshot** on `journal_line` — never flipped in place, Foundation append-only REVOKE UPDATE/DELETE; every reclassification is a **new balanced entry**) +**Initial State**: ACTIVE + +**Transitions**: +1. [ ] - `p1` - **FROM** ACTIVE **TO** DISPUTED **WHEN** dispute opened with the AR-reclass variant **and** the full open AR is disputed; a partial dispute moves only the disputed sub-amount between the ACTIVE/DISPUTED sub-class balances while the flag stays ACTIVE (disputed-amount reporting reads the sub-balance, not the flag) - `inst-st-ar-disputed` +2. [ ] - `p1` - **FROM** DISPUTED **TO** ACTIVE **WHEN** dispute won (reclassify back, no cash leg) - `inst-st-ar-active` +3. [ ] - `p1` - All moves are AR-class-neutral: MUST NOT zero/negate `(payer,invoice)` AR, flip the payer-aggregate sign, or enter the AR tie-out roll-up delta (AC #7) - `inst-st-ar-neutral` + +## 5. Definitions of Done + +### Settlement Posting (Pattern A) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-payments-allocation-settlement` + +The system **MUST** post Pattern A on `PaymentSettled` (funds to `UNALLOCATED`, never AR), split the PSP fee to `PSP_FEE_EXPENSE` when `feeMinor` is present (net Cash / gross Unallocated), seed the `payment_settlement` counter in the same transaction, and upsert `unallocated_balance` — idempotent per `(tenant, PAYMENT_SETTLE, pspTransactionId)`. + +**Implements**: +- `cpt-cf-bss-ledger-flow-payment-settlement` +- `cpt-cf-bss-ledger-algo-payment-money-out-caps` + +**Touches**: +- API: `POST /v1/ledger/payments` +- DB: `payment_settlement`, `unallocated_balance`, `journal`, `journal_line`, `idempotency_dedup` +- Entities: `PaymentSettlement` + +### Settlement Return Posting + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-payments-allocation-settlement-return` + +The system **MUST** post `SETTLEMENT_RETURN` (DR `UNALLOCATED` / CR `CASH_CLEARING`) idempotent per `(tenant, SETTLEMENT_RETURN, pspReturnId)`, decrement `settled_minor` under the rank-1 lock, route over-allocated returns to the exception queue (`SETTLEMENT_RETURN_OVER_ALLOCATED`) instead of auto-posting, and reuse the documented-loss pattern for negative-Cash returns. + +**Implements**: +- `cpt-cf-bss-ledger-flow-payment-settlement-return` +- `cpt-cf-bss-ledger-algo-payment-money-out-caps` + +**Touches**: +- API: `POST /v1/ledger/payments/{paymentId}/returns` +- DB: `payment_settlement`, `unallocated_balance`, `journal`, `journal_line` +- Entities: `PaymentSettlement` + +### Allocation Engine (Pattern B, Mode A) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-payments-allocation-allocation` + +The system **MUST** derive candidate open invoices from its own `ar_invoice_balance`, compute the split via the precedence algorithm (default oldest-first, tenant overrides, statutory registry deferred), post DR `UNALLOCATED` / CR `AR` per invoice, enforce the per-payment caps at the `payment_settlement` row, write N `payment_allocation` rows stamped with `precedence_policy_ref`, upsert `payment_allocation_refund`, reject cross-currency allocation (400), attach the residual cent to the largest open invoice, bound the number of invoices ONE allocation may touch by `payments.max_invoices_per_allocation` (default 500; a larger split → `ALLOCATION_TOO_LARGE`, with chunked continuations under one `allocationId` a tracked follow-up), and keep overpayment remainders unallocated. + +**Implements**: +- `cpt-cf-bss-ledger-flow-payment-allocation` +- `cpt-cf-bss-ledger-algo-allocation-mode-a` +- `cpt-cf-bss-ledger-algo-payment-money-out-caps` + +**Touches**: +- API: `POST /v1/ledger/payments/{paymentId}/allocations`, `GET /v1/ledger/payments/{paymentId}/allocations` +- DB: `payment_allocation`, `payment_allocation_refund`, `payment_settlement`, `ar_invoice_balance`, `unallocated_balance` +- Entities: `PaymentAllocation`, `PaymentAllocationRefund` + +### Chargeback Posting + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-payments-allocation-chargeback` + +The system **MUST** record chargeback phases idempotent per `(tenant, dispute_id:cycle:phase)`, select the opened variant from the PSP funds-movement fact (cash-hold to `DISPUTE_HOLD` vs AR-reclass `ACTIVE→DISPUTED`), branch outcomes on the recorded variant, keep the opened reclass AR-class-neutral and out of the tie-out roll-up, scope partial disputes to the disputed sub-amount, increment `clawed_back_minor` on lost/cash-out under the total money-out cap, hold open-dispute refund stage-2 in the queue, route lost-on-refunded to the exception queue, and post the `DISPUTE_LOSS_EXPENSE` documented-loss line + alarm instead of negative Cash. + +**Implements**: +- `cpt-cf-bss-ledger-flow-payment-chargeback-phase` +- `cpt-cf-bss-ledger-state-payment-dispute-cycle` +- `cpt-cf-bss-ledger-state-payment-ar-status` +- `cpt-cf-bss-ledger-algo-payment-money-out-caps` + +**Touches**: +- API: `POST /v1/ledger/disputes/{disputeId}/phases` +- DB: `payment_settlement`, `ledger_dispute`, `ar_invoice_balance`, `journal`, `journal_line`, `pending_event_queue` +- Entities: `LedgerDispute` + +### Credit Application (Wallet) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-payments-allocation-credit-application` + +The system **MUST** support both wallet shapes (grant: DR `UNALLOCATED` / CR `REUSABLE_CREDIT`; apply: DR `REUSABLE_CREDIT` / CR `AR`) with pre-commit caps (`GRANT_EXCEEDS_UNALLOCATED`, `CREDIT_EXCEEDS_OPEN_AR`, `CREDIT_EXCEEDS_WALLET`), stamp `credit_grant_event_type` on every `REUSABLE_CREDIT` line, draw sub-grains oldest-event-type-first against the specific rows debited, serialize at both grains, and never target Contract liability. + +**Implements**: +- `cpt-cf-bss-ledger-flow-payment-credit-application` +- `cpt-cf-bss-ledger-algo-payment-lock-order-tie-out` + +**Touches**: +- API: `POST /v1/ledger/credit-applications` +- DB: `reusable_credit_subbalance`, `unallocated_balance`, `ar_invoice_balance`, `journal_line` +- Entities: `CreditApplication` + +### Out-of-Order Queue Persistence + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-payments-allocation-out-of-order-queue` + +The system **MUST** persist every queued/quarantined item in `pending_event_queue` (owned here, used by slices 2 and 3), claim the `idempotency_dedup` row as `QUEUED` atomically at intake, return 202 with kebab-case status tokens, re-evaluate caps/no-negative/precedence at apply time, flip both rows atomically on apply, survive restarts without upstream re-push, and alarm on aged items. + +**Implements**: +- `cpt-cf-bss-ledger-algo-payment-out-of-order-queueing` +- `cpt-cf-bss-ledger-state-payment-pending-event` + +**Touches**: +- API: `POST /v1/ledger/payments/{paymentId}/allocations` (202 path), `POST /v1/ledger/disputes/{disputeId}/phases` (202 path) +- DB: `pending_event_queue`, `idempotency_dedup` +- Entities: `PendingEvent` + +### Balance Caches, Lock Order and Tie-Out Extension + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-payments-allocation-balances-lock-order` + +The system **MUST** extend the Foundation total lock order with the payment balance grains and sidecar counters (balance grains before counters; payer before invoice), pin the grain ranks with an architecture test, enforce no-negative per class (`UNALLOCATED`/`CASH_CLEARING` aggregate; `REUSABLE_CREDIT` sub-grain only), and extend the TieOutJob AR roll-up with allocations, credit applications, and chargeback outcomes while excluding the AR-class-neutral dispute-opened move. + +**Implements**: +- `cpt-cf-bss-ledger-algo-payment-lock-order-tie-out` + +**Touches**: +- DB: `account_balance`, `ar_payer_balance`, `ar_invoice_balance`, `unallocated_balance`, `reusable_credit_subbalance`, `payment_settlement`, `payment_allocation_refund` +- Entities: `BalanceProjector` extension, `TieOutJob` extension + +## 6. Acceptance Criteria + +Testing is a **delta over the Foundation testing architecture** (same level structure + mocking rules: Unit / Integration-testcontainers / API / E2E; never mock DB constraints, money type, idempotency PK; "what must NOT be mocked" and the concurrency-policy/barrier-start mechanics are inherited). + +**Unit:** + +- [ ] Allocation precedence: default + tie-break, tenant overrides incl. customer-instructed (statutory registry deferred) +- [ ] Multi-invoice residual cent → largest open invoice +- [ ] Wallet two-shape builders + sub-grain consumption order; cap math + +**Integration (testcontainers):** + +- [ ] Pattern A then B: settle → unallocated → allocate → AR reduces per invoice; receipt alone does NOT move AR +- [ ] `payment_settlement` CHECK blocks Σ-allocations > settled even when the payer's unallocated pool is positive from other payments +- [ ] Overpayment remainder stays unallocated; `UNALLOCATED`/`CASH_CLEARING` NO-negative CHECK holds +- [ ] `reusable_credit_subbalance` NO-negative at sub-grain: overdrawing one event-type while the aggregate is positive is blocked +- [ ] `payment_allocation` idempotent per `allocationId`; chargeback idempotent per `(dispute_id:cycle:phase)` +- [ ] Chargeback-opened is AR-class-neutral (tie-out roll-up unchanged); `ar_status` reclass posts new lines (no `journal_line` UPDATE) +- [ ] Chargeback-lost never drives `CASH_CLEARING` negative (routes to alarm + balanced loss line) +- [ ] Combined money-out cap: full refund stage-1 then chargeback `lost` on the same payment → second blocked/exception +- [ ] Settlement return decrements `settled_minor`; a return with `allocated_minor` over the remainder routes to the exception queue, never auto-posts +- [ ] Dispute cycle 2 posts `opened` after cycle-1 `won` +- [ ] A >500-invoice allocation chunks under one `allocationId` +- [ ] Queued items survive restart via `pending_event_queue` + +**API:** + +- [ ] RFC 9457 mapping for each new problem code; settle/allocate idempotent-replay returns the prior reference +- [ ] Queued allocation/phase return **202** with a non-problem body; replay of a `QUEUED` allocationId returns a stable reference + +**Ordering & exception:** + +- [ ] Allocation-before-settlement queues then applies; a queued allocation that becomes over-cap by apply time is blocked/alarmed +- [ ] Chargeback phase out-of-order queues, never posts a partial outcome; aged-queue/aged-unallocated alarms fire + +**Concurrency (Foundation lock-order + policy inherited):** + +- [ ] Concurrent allocates of the same payment whose sum exceeds settled → exactly one succeeds (on the `payment_settlement` row lock) +- [ ] Concurrent CreditApplications for the same payer wallet serialize at both grains (sub-grain + invoice) — no wallet over-draw, no AR over-pay +- [ ] Cross-table deadlock-freedom for credit apply interleaved with Pattern B allocation on the same payer/invoice under the extended lock order; barrier-synchronized N tasks then assert invariants + +**NFR verification:** + +- [ ] Write p95 ≤ 500 ms → load/integration +- [ ] Aged-unallocated + aged-queue + chargeback-cash-negative alarms → the ordering/exception alarm tests +- [ ] `payment_settlement` / `unallocated_balance` hot-row load test (same profile as the Foundation `ar_payer_balance`) + +## 7. Non-Functional Considerations + +- **Performance**: Inherits Foundation targets — B2 resolved 2026-06-10 via B11 (PRD draft committed as v1 SLOs, gated by the B3 load test); B3 remains open (§11.2). Feature-specific: settle/allocate/credit posts are single-balanced-entry writes (p95 ≤ 500 ms target); hot rows are `payment_settlement` (per payment) and `unallocated_balance` (per payer) — same contention profile + mitigation as the Foundation `ar_payer_balance`. Load tests run at the ≤ 500 invoices/allocation-txn bound. Aged-unallocated and aged-queue ages MUST alarm. +- **Security**: Inherits Foundation RLS. **Tenancy axis & delegation.** The RLS/owner axis is `tenant_id` (= the **resolved payer** per the rule, P6); `payer_tenant_id`/`resource_tenant_id` on `payment_allocation` are **descriptive** dimensions, not the RLS key. **Payment delegation is intra-payer-tenant:** because AR is consolidated onto the resolved payer **at posting time** (direct-to-payer), a parent settling/allocating a managed child's consumption operates entirely within `tenant_id = payer` — **flat RLS is correct for it**, no cross-tenant access. Cross-tenant *reads* (a parent reading a self-managed child's own ledger) are the separate path served by the Variant B subtree-RLS — **ACTIVE in MVP (Slice 6, decision 2.B):** middleware resolves `app.subtree_ids` per request (`BarrierMode::Respect`), so a parent reads its own Respect-subtree; cross-barrier reads stay elevated. No-mixed-payer/legal-entity per entry; append-only; PII-minimized events. Posting payments/disputes/credit requires the billing-poster scope; high-value credit grants and chargeback-loss postings follow dual-control per policy (thresholds are effective-dated policy versions). The ledger trusts Payments-module event provenance (PSP verification upstream, C4). +- **Observability**: Metrics: `ledger_payment_settle_total`, `ledger_settlement_return_total`, `ledger_allocation_total`, `ledger_unallocated_balance_minor` (gauge, by tenant), `ledger_aged_unallocated_seconds`, `ledger_dispute_recorded_total{phase}`, `ledger_chargeback_cash_negative_total`, `ledger_credit_apply_total` / `ledger_credit_apply_rejected_total{reason}`, `ledger_allocation_queue_depth`, `ledger_dispute_phase_queue_depth`. Thresholds wire to the NFR targets and the aged-queue + chargeback alarms. +- **Data**: All new tables tenant-scoped with RLS (C1); full schemas in §9. +- **Compliance**: Single-bucket unallocated pool is compliant only with per-event-type sub-balance tracking + the sub-grain guard (P1); statutory allocation compliance path is the customer-instructed override. + +## 8. REST API Surface + +### 8.1 Endpoints + +REST per `rest-api-design`, behind the inbound API gateway; money as `{amountMinor, currency, scale}`. Mutating posts idempotent on the listed business key. + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST` | `/v1/ledger/payments` | Record settlement (Pattern A) + seed counter. Sub-resource create, not `:settle`. | `(tenant, PAYMENT_SETTLE, pspTransactionId)` | +| `POST` | `/v1/ledger/payments/{paymentId}/returns` | Record a bank/ACH/SEPA settlement return. Was `:return`. | `(tenant, SETTLEMENT_RETURN, pspReturnId)` | +| `POST` | `/v1/ledger/payments/{paymentId}/allocations` | Allocate a **lump** amount (+ **optional** customer-instructed hint; ledger derives candidates from its own `ar_invoice_balance`, ≤ 500 invoices per txn) and computes the split. Was `:allocate`; pairs with the GET below. | per `allocationId` | +| `POST` | `/v1/ledger/disputes/{disputeId}/phases` | Record a chargeback phase outcome (`cycle` in body). Was `:record`. | `(tenant, dispute_id:cycle:phase)` | +| `POST` | `/v1/ledger/credit-applications` | Grant or apply reusable customer credit. | per `creditApplicationId` | +| `GET` | `/v1/ledger/balances/unallocated` | Read unallocated pool by payer/currency. | cache read | +| `GET` | `/v1/ledger/payments/{paymentId}/allocations` | List allocations of a payment. | — | + +### 8.2 Queued Semantics (202) + +**Queued (success-with-deferral, NOT problem+json):** allocation-before-settlement and out-of-order dispute phase return **`202 Accepted`** with a normal body carrying a **kebab-case status token** (`allocation-queued`, `dispute-phase-queued`) + correlation handle — never a SCREAMING_SNAKE error code (deferral convention shared with slices 3/4). + +### 8.3 Problem Responses (RFC 9457) + +True errors only, added to the Foundation's: + +| Code | HTTP status | Meaning | +|------|-------------|---------| +| `ALLOCATION_EXCEEDS_SETTLED` | 409 | Allocation total over the per-payment settled cap | +| `GRANT_EXCEEDS_UNALLOCATED` | 409 | Wallet grant over the payer's available unallocated pool | +| `CREDIT_EXCEEDS_WALLET` | 409 | Credit apply over the chosen sub-grains' available | +| `CREDIT_EXCEEDS_OPEN_AR` | 409 | Credit apply over the invoice open AR | +| `STATUTORY_ALLOCATION_CONFLICT` | 409 | Reserved for the deferred statutory registry | +| `ALLOCATION_CURRENCY_MISMATCH` | 400 | settlement currency ≠ invoice currency; cross-currency application not in MVP | + +**(Slice 2 review #1 / #5)** Balance / headroom caps are retriable conflicts on mutable state → **ABORTED (409)**, not 422 — the platform's canonical-error model (AIP-193) exposes no 422; a currency mismatch is a malformed request → **INVALID_ARGUMENT (400)**. Settlement/allocation replay returns the prior posting reference (Foundation AC #19). + +## 9. Data Model (Slice-Owned Tables) + +Adds `payment_settlement`, `payment_allocation`, `payment_allocation_refund`, `unallocated_balance`, `reusable_credit_subbalance`, `pending_event_queue`; all tenant-scoped with RLS (C1). Migrations owned by this feature. + +### 9.1 payment_settlement + +The per-payment money-out serialization point. + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | uuid | PK part | +| `payment_id` | string | PK part; PSP settlement id | +| `currency` | char | | +| `settled_minor` | bigint | decremented by `SETTLEMENT_RETURN` | +| `allocated_minor` | bigint | incremented by allocation | +| `refunded_minor` | bigint | refund stage-1, slice 3 | +| `refunded_unallocated_minor` | bigint | Pattern-A refund stage-1, slice 3 | +| `clawed_back_minor` | bigint | chargeback lost or cash-out, this feature | + +PK `(tenant_id, payment_id)`. CHECKs: `allocated_minor <= settled_minor` (existing, stays); `allocated_minor + refunded_unallocated_minor <= settled_minor` (Pattern-A spendable headroom); `refunded_minor + clawed_back_minor <= settled_minor` (total money-out cap); `refunded_minor <= settled_minor` (kept — incremented by slice 3 stage-1, decremented by its stage-1 reversal); `refunded_unallocated_minor >= 0`; `clawed_back_minor >= 0`. + +### 9.2 payment_allocation + +| Column | Type | Notes | +|--------|------|-------| +| `allocation_id` | uuid | request-level allocation id (one request → N rows) | +| `tenant_id` | uuid | RLS axis (resolved payer) | +| `payer_tenant_id` | uuid | descriptive dimension, not the RLS key | +| `payment_id` | string | | +| `invoice_id` | string | | +| `amount_minor` | bigint | | +| `currency` | char | | +| `precedence_policy_ref` | string | policy version that produced the split | +| `allocated_at_utc` | timestamptz | | + + Per-row key is **`UNIQUE (tenant_id, allocation_id, invoice_id)`** — one allocate request produces **N rows** (one per invoice), so the request-level `allocationId` is **not** unique by itself; request-level idempotency lives in `idempotency_dedup (tenant, PAYMENT_ALLOCATE, allocationId)`, and chunked continuations append rows for new invoices under the same `allocationId` without collision. Indexes `(tenant_id, payment_id)`, `(tenant_id, invoice_id)`. INSERT-only (unique `allocation_id`) — takes **no** lock rank. + +### 9.3 payment_allocation_refund + + Created by this feature's migration. + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | uuid | PK part | +| `payment_id` | string | PK part | +| `invoice_id` | string | PK part | +| `allocated_minor` | bigint | incremented by AllocationHandler in the allocation txn (first-touch upsert) | +| `refunded_minor` | bigint | defaults 0; consumed by slice 3, which adds the `CHECK (refunded_minor ≤ allocated_minor)` usage | + +PK `(tenant_id, payment_id, invoice_id)`. Its lock rank is the **last** rank in the unified order (after `invoice_exposure`, per slice 3) — this feature acquiring it last is consistent because it never locks the recognition/exposure tables. + +### 9.4 unallocated_balance + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | uuid | | +| `payer_tenant_id` | uuid | | +| `currency` | char | | +| `balance_minor` | bigint | credit-normal; `CHECK (balance_minor >= 0)` — NO negative | + +### 9.5 reusable_credit_subbalance + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | uuid | | +| `payer_tenant_id` | uuid | | +| `currency` | char | | +| `credit_grant_event_type` | string | per-event-type sub-grain the PRD mandates | +| `balance_minor` | bigint | `CHECK (balance_minor >= 0)` at the `(currency, event_type)` sub-grain — NO negative | + +### 9.6 pending_event_queue + + Owned by this feature; written by slices 2 and 3. + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | uuid | PK part | +| `flow` | string | PK part | +| `business_id` | string | PK part | +| `payload` | jsonb | PII-free | +| `queued_at` | timestamptz | read by the aged-queue alarms | +| `apply_after` | timestamptz | nullable | +| `status` | string | `QUEUED` \| `APPLIED` \| `CANCELLED` | +| `attempts` | int | | + +PK `(tenant_id, flow, business_id)`; RLS (C1); index `(tenant_id, flow, status, queued_at)`. + +### 9.7 Cross-Table Constraints and Enum Usage + +- `UNALLOCATED`, `CASH_CLEARING`, `DISPUTE_HOLD` are in the **Foundation's** `account_balance` no-negative guarded set (declared there from the start; not `REUSABLE_CREDIT` — sub-grain-guarded), and `DISPUTE_HOLD` (debit-normal, Finance GL-mapping pending — §11.2 P9) + `DISPUTE_LOSS_EXPENSE` (debit-normal expense; documented-loss line for chargeback/return negative-Cash) are in the **Foundation `account_class` enum**. This feature **posts to** them — no cross-slice `ALTER`. +- `ar_status` (`ACTIVE` | `DISPUTED`) — immutable snapshot on `journal_line`, mutable on `ar_invoice_balance`. +- The `source_doc_type` / `flow` values `PAYMENT_SETTLE | PAYMENT_ALLOCATE | CHARGEBACK | CREDIT_APPLY | SETTLEMENT_RETURN` are **Foundation-declared**; this feature uses them. Chargeback `business_id = dispute_id:cycle:phase` (snake_case composite). +- Lock order extended with `table_rank` slots for the **three contended** new tables (`payment_settlement`, `unallocated_balance`, `reusable_credit_subbalance`); `payment_allocation` is INSERT-only and takes **no** lock rank. + +## 10. Events and Alarms + +> **⚠️ v1 event-layer status (design ↔ code reconciliation).** There is **no event broker / outbox relay in v1** — the whole event layer is parked (publishers are logged no-ops). So **none** of the events below are actually published at runtime in v1; that parking is intentional and applies fleet-wide. Additionally, of the success events listed here, `billing.ledger.payment.settled`, `billing.ledger.payment.allocated`, and `billing.ledger.credit.applied` have **no payload/schema contract in v1 code at all** (the other events ship dormant payload structs + JSON schemas; these three do not) — building their contract is **deferred with the rest of the event layer**. The underlying flows (settle / allocate / credit-apply) are fully implemented and post correctly; only the event emission is deferred. + +Success events via the Foundation outbox: `billing.ledger.payment.settled`, `billing.ledger.settlement.returned`, `billing.ledger.payment.allocated`, `billing.ledger.dispute.recorded` (`cycle`, `phase`), `billing.ledger.credit.applied`. + +Aged-queue/aged-unallocated and the chargeback-negative-Cash alarm via the **separate committed** audit/alarm transaction (Foundation): `billing.ledger.invariant.alarm` with `alarmCategory ∈ {aged-allocation-queue, aged-unallocated, dispute-phase-queued, chargeback-cash-negative}`. Aged-queue ages read `pending_event_queue.queued_at`. PII-free. + +## 11. Decision Log and Open Items + +### 11.1 Risks and Deferred Work + +- **Hot rows** `payment_settlement` (per payment) and `unallocated_balance` (per payer) mirror the Foundation `ar_payer_balance` contention; same load-test obligation (B3, §11.2). +- **Single-bucket Unallocated pool** (P1; — pool-meaning "suspense" prose retired) is compliant only with per-event-type sub-balance tracking + the sub-grain guard. +- **Deferred:** refunds (S5), revenue/tax restatement (S3) → slice 3; FX on cross-currency allocation → slice 5. + +### 11.2 Needs Discussion (P1–P10) + +Inherits Foundation open items — B2 resolved 2026-06-10 via B11 (PRD draft committed as v1 SLOs, gated by the B3 load test); B3 bill-run scale remains open (→ PM Team). Feature-specific: + +| Item | Decision | Status | Owner | +|------|----------|--------|-------| +| Unallocated-pool modeling | single bucket + per-event-type sub-balances; "suspense" prose = `UNALLOCATED` only | ✅ Accepted default | — | +| Dispute-opened segregation | variant **driven by the PSP funds-movement fact**, **not** tenant policy: funds withheld at open (card rails) → cash-hold **mandatory** (to `DISPUTE_HOLD`, see P9); funds not moved at open (invoice/ACH) → AR reclassification (AR-class-neutral); won/lost branch on the recorded variant | ✅ Ratified 2026-06-10; superseded to PSP-fact-driven via | Finance | +| Atomic settle-and-apply shortcut | allowed only when atomic, no residual | ✅ Accepted default | — | +| Statutory allocation-rule registry | jurisdiction rules override tenant strategy; registry per Design | 🔄 **Amended 2026-06-11: statutory registry deferred — out of v1 scope.** Customer-instructed override is the B2B compliance path; registry added post-MVP only if Legal names a market whose payers a statutory regime binds. Platform default order = **oldest invoice first** stays. (Supersedes the 2026-06-10 "UK CCA only" ratification.) | Legal + PM | +| Allocation split ownership | **Mode A minimal (🔄 2026-06-15)** — ledger derives candidates from its **own** `ar_invoice_balance` (oldest-first) and computes the split via precedence; event carries **settlement + optional customer-instructed hint** (no candidate set from Payments) | ✅ Ratified 2026-06-10, refined 2026-06-15; remaining **action** (not a design blocker): confirm the settlement-event contract (lump + optional hint) with the Payments module at S2 start | PM + Architecture | +| `payment_allocation_refund` ownership | created + `allocated_minor` maintained **here** at allocation time; slice 3 consumes | ✅ Proposed default | — | +| Cross-currency allocation | **MVP rejects** (`ALLOCATION_CURRENCY_MISMATCH`); future: designed conversion event with FX bridge (slice 5 extension) — PRD "lock on alloc in another currency" deferred, not silently dropped | ✅ **Confirmed 2026-06-17 (@vstudzinskyi, decision 5.A): MVP rejects; cross-currency deferred to slice 5** | PM + Architecture | +| Origin payment reference on money-out events | refund/return rows carry **mandatory** `payment_id` + `currency` for both patterns — assumes every PSP/bank refund/return event references the origin payment | ⏳ Pending — confirm with Payments | Payments team | +| `DISPUTE_HOLD` + `DISPUTE_LOSS_EXPENSE` account classes | chargeback cash-hold parks in `DISPUTE_HOLD`; negative-Cash clawback documented-loss posts to `DISPUTE_LOSS_EXPENSE` | ✅ **Names accepted 2026-06-17 (@vstudzinskyi, decision 4)**; ⏳ Finance to confirm GL **treatment/mapping** | Finance | +| Allocation payload bound | ≤ **500 invoices per allocation txn**; chunked continuation under the same `allocationId` | ✅ **Confirmed 2026-06-17 (@vstudzinskyi, decision 6): 500 invoices/txn** (revisit after B3 load-test) | PM | diff --git a/gears/bss/ledger/docs/design/04-asc606-recognition.md b/gears/bss/ledger/docs/design/04-asc606-recognition.md new file mode 100644 index 000000000..018576eff --- /dev/null +++ b/gears/bss/ledger/docs/design/04-asc606-recognition.md @@ -0,0 +1,636 @@ + + +# DESIGN — ASC 606 Revenue Recognition (Slice 4) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Naming Conventions and Design-Introduced Names](#15-naming-conventions-and-design-introduced-names) + - [1.6 Scope and Constraints](#16-scope-and-constraints) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Trigger Recognition Run](#trigger-recognition-run) + - [Apply Schedule Change](#apply-schedule-change) + - [Read Recognition Schedules and Disaggregation](#read-recognition-schedules-and-disaggregation) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [ScheduleBuilder (Deferral and Timing Policy)](#schedulebuilder-deferral-and-timing-policy) + - [Segment Release (S6 Posting)](#segment-release-s6-posting) + - [PO Identification and SSP Allocation](#po-identification-and-ssp-allocation) + - [Disaggregation Derivation and Mixed-Invoice Split](#disaggregation-derivation-and-mixed-invoice-split) +- [4. States (CDSL)](#4-states-cdsl) + - [Recognition Schedule State Machine](#recognition-schedule-state-machine) + - [Recognition Segment State Machine](#recognition-segment-state-machine) + - [Recognition Run State Machine](#recognition-run-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Schedule Materialization](#schedule-materialization) + - [Recognition Run and Segment Release](#recognition-run-and-segment-release) + - [PO and SSP Gates](#po-and-ssp-gates) + - [Revenue-Stream Disaggregation](#revenue-stream-disaggregation) + - [Schedule Change Control](#schedule-change-control) + - [Audit Linkage and Invariants](#audit-linkage-and-invariants) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Non-Functional Considerations](#7-non-functional-considerations) +- [8. REST API Surface](#8-rest-api-surface) + - [8.1 Endpoints](#81-endpoints) + - [8.2 Queued Semantics (202)](#82-queued-semantics-202) + - [8.3 Problem Responses (RFC 9457)](#83-problem-responses-rfc-9457) +- [9. Data Model (Slice-Owned Tables)](#9-data-model-slice-owned-tables) + - [9.1 recognition_schedule](#91-recognition_schedule) + - [9.2 recognition_segment](#92-recognition_segment) + - [9.3 recognition_run](#93-recognition_run) + - [9.4 Cross-Table Constraints and Enum Usage](#94-cross-table-constraints-and-enum-usage) +- [10. Events and Alarms](#10-events-and-alarms) +- [11. Decision Log and Open Items](#11-decision-log-and-open-items) + - [11.1 Risks and Deferred Work](#111-risks-and-deferred-work) + - [11.2 Needs Discussion (R1–R6, NFR)](#112-needs-discussion-r1r6-nfr) + + + +## 1. Context + +### 1.1 Overview + +Turns **deferred revenue** (Contract liability posted at invoice by the Foundation S1 direct split) into **recognized Revenue** over time per ASC 606: documented recognition schedules, **atomic** idempotent recognition runs (`DR Contract liability / CR Revenue`), deferral/timing policy derivation, PO/allocation tagging + SSP allocation, revenue-stream disaggregation, and audit-grade linkage. ERP/GL export of recognized revenue is slice 7. + +**Traces to**: `cpt-cf-bss-ledger-fr-recognition-schedule-controls`, `cpt-cf-bss-ledger-fr-recognition-audit-linkage`, `cpt-cf-bss-ledger-fr-asc606-po-identification`, `cpt-cf-bss-ledger-fr-revenue-stream-disaggregation`, `cpt-cf-bss-ledger-fr-invoice-post-direct-split`, `cpt-cf-bss-ledger-fr-policy-versioning-immutability`, `cpt-cf-bss-ledger-fr-negative-balance-invariants`, `cpt-cf-bss-ledger-fr-idempotency-per-flow`, `cpt-cf-bss-ledger-fr-idempotent-replay-contract`, `cpt-cf-bss-ledger-fr-out-of-order-event-handling`, `cpt-cf-bss-ledger-fr-money-rounding-scale`, `cpt-cf-bss-ledger-fr-accounting-periods-close`, `cpt-cf-bss-ledger-nfr-posting-performance`, `cpt-cf-bss-ledger-nfr-availability` + +### 1.2 Purpose + +**Slice boundary with the Foundation.** The Foundation **posts the Contract-liability credit line at invoice post** (S1 direct split) and owns the `CONTRACT_LIABILITY` / `REVENUE` account classes and the `revenue_stream` NOT-NULL line invariant. **This feature owns the RELEASE** (S6 recognition runs) and the **policy** that decides deferral, timing, PO/allocation tagging, SSP allocation, and disaggregation. It does **not** redefine the S1 posting or those account classes — it references them. *(Confirmed in the Foundation review: S1 posts the credit, S6 releases it.)* + +Success criteria: no deferred balance ever exists without a schedule; each segment releases at most once per period; over-recognition is blocked at the per-schedule grain; every recognition entry carries full audit linkage; recognition is accrual-driven (independent of cash/collections) and gated only by obligation satisfaction. + +**Requirements**: `cpt-cf-bss-ledger-fr-recognition-schedule-controls`, `cpt-cf-bss-ledger-fr-recognition-audit-linkage`, `cpt-cf-bss-ledger-fr-asc606-po-identification`, `cpt-cf-bss-ledger-fr-revenue-stream-disaggregation`, `cpt-cf-bss-ledger-nfr-posting-performance` + +**Use cases**: `cpt-cf-bss-ledger-usecase-ledger-inquiry`, `cpt-cf-bss-ledger-usecase-exception-resolution`, `cpt-cf-bss-ledger-usecase-reconciliation-review` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-bss-ledger-actor-recognition-run` | Scheduled/event-triggered orchestration that releases due segments per period through the Foundation PostingService | +| `cpt-cf-bss-ledger-actor-catalog-contracts` | Contracts: deferral terms, recognition pattern, PO/allocation group, SSP override, VC estimate/method (authoritative). Catalog: SKU/Plan defaults, PO satisfaction pattern, SSP baseline, revenue-stream category. Consumed by **immutable version ref**, resolved locally | +| `cpt-cf-bss-ledger-actor-rating-subscriptions` | Subscriptions: obligation-satisfaction **state** that drives WHEN runs execute (eventually-consistent SoT) | +| `cpt-cf-bss-ledger-actor-finance-ops` | Resolves recognition exception-queue items (policy conflicts, SSP gaps, modification-treatment review); reads disaggregated revenue | +| `cpt-cf-bss-ledger-actor-finance-approver` | Approves controlled schedule changes (dual-control per policy, audit trail mandatory); owns R1/R2/R5/R6 value tables | +| `cpt-cf-bss-ledger-actor-revenue-assurance` | Receives recognition-double-credit / over-recognition alarms | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — § Posting rules S6, § ASC 606 compliance, § Recognition controls, § Out-of-order events, AC #4/#9/#17/#26, Example A +- **Design**: [01-repository-foundation.md](./01-repository-foundation.md) — Foundation engine (`PostingService` — one ACID txn per entry, READ COMMITTED; append-only journal + strict line-negation reversal; `IdempotencyGate` — 3-column `idempotency_dedup` PK, idempotent-replay; `MoneyModule` — banker's rounding + residual-cent rules; `BalanceProjector` — upsert + conditional no-negative; `FiscalPeriodGuard`; leaf-partition commit trigger; total fixed lock order; `TieOutJob`). Not restated here; RFC 2119 keywords are normative. +- **Dependencies**: Foundation / posting-engine-core (slice 1) upstream — posted Contract-liability credit (per stream) + invoice-item link; Contracts, Catalog, Subscriptions upstream (policy/state). Downstream: reconciliation-export (slice 7) — recognized-revenue export to ERP/GL + tie-out. +- Upstream module PRDs (contracts/catalog/subscriptions) are referenced from [PRD.md](../PRD.md) refs. + +**Canonical slice numbering:** 1 posting-engine-core, 2 payments-allocation, 3 adjustments-notes-refunds, **4 asc606-recognition (this feature)**, 5 fx-multicurrency, 6 audit-immutability-observability, 7 reconciliation-export, 8 other. The `source_doc_type` / idempotency `flow` value `RECOGNITION` is **Foundation-declared**; this feature uses it. + +### 1.5 Naming Conventions and Design-Introduced Names + +Design-introduced names (this feature): + +| Name | Meaning | +|------|---------| +| `recognition_schedule` | Documented release plan for **one single-revenue-stream** deferred balance: links to originating posted invoice item(s), PO/allocation group, currency, total deferred, recognized-to-date, policy/SSP/VC refs, status. | +| `recognition_segment` | One time- or milestone-slice of a schedule: period, amount, status (`PENDING`/`QUEUED`/`DONE`), recognized timestamp/run linkage. The **at-most-once unit** (one per `(schedule, period)`). | +| `recognition_run` | An orchestration wrapper that releases due segments for a period; **not** itself the dedup key. | + +### 1.6 Scope and Constraints + +**Non-goals / out of scope:** + +- **S1 invoice-post posting & the `CONTRACT_LIABILITY`/`REVENUE` account classes** — Foundation (referenced, not restated). +- **Unbilled receivable / contract asset** — out of scope for the whole MVP (PRD § Out of scope; § Resolved decisions). Recognition without a resolvable posted-invoice-item link MUST block. +- **Gross-Revenue-then-reclassification** posting — not the default BSS pattern (PRD); the Foundation uses direct split only. +- **ERP/GL export, reconciliation** — slice 7. **Payments / refunds / FX** — slices 2/3/5. CreditApplication and refunds **never** touch Revenue or Contract liability (PRD). +- **Cancellation/replacement *decisions*** — owned upstream (Contracts/Subscriptions); the ledger consumes the decision and applies it prospectively / via compensating entries. +- **Engine mechanics** — Foundation (01-repository-foundation.md). + +**In scope:** + +- S6 recognition runs + schedules (§ Posting rules S6; AC #9) +- **release** of the Foundation deferred credit (AC #4 is the post; S6 is the release) +- Identify-POs + immaterial-one-shot exemption +- SSP (multi-PO) vs documented-pricing (single-PO) + variable consideration +- deferral-policy + recognition-timing derivability/precedence +- recognition-vs-cash separation (accrual) +- schedule integrity (no double recognition) +- revenue-stream **disaggregation** derivation + mixed-invoice split (the line-tag invariant AC #26 is Foundation; derivation+split is this feature) +- audit minimum linkage + invoice-item-link-or-block +- **obligation-satisfaction (not collections) gating** of runs (AC #21 interaction) +- Contract>Catalog precedence +- recognition idempotency/dedup/rerun/residual-cent/ordering/schedule-change control +- recognition reversal/clawback shape +- per-schedule over-recognition guard (AC #17 interaction) +- recognition-double-credit alarm +- recognition run window NFR + +**Consumed:** posted Contract-liability balance (per revenue-stream line) + originating invoice-item link (Foundation); deferral/timing/SSP/VC **policy** + PO/allocation group (Contracts authoritative, Catalog default; consumed by **immutable version ref**); subscription obligation-satisfaction **state** (Subscriptions). **(Ingestion model — README):** policy/SSP refs resolve **locally** (immutable version refs) and recognition runs are **call/schedule-driven** — no inbound bus on the post path (C3); "consumed" names the local fact, not a subscription. **Produced:** recognition journal entries (`DR CONTRACT_LIABILITY / CR REVENUE`, same stream both legs), schedule state, double-credit/over-recognition alarms. + +**Constraints and assumptions** (inherits Foundation C1–C4 + A1–A6). Feature-specific (defaults; open items → §11.2): + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| R1 | Deferral-policy precedence | **Contract → Catalog SKU/Plan → PO type → billing model**; Contract overrides Catalog for the same dimension. **Skeleton ratified 2026-06-10** (same-dimension conflict → Contract wins; unresolvable ambiguity → block + exception queue); value tables are Finance-owned data. | PRD | +| R2 | Recognition-timing precedence | **Contract → Catalog + PO type → subscription state** (state drives *when*, not the *pattern* unless Contract ties it). **Skeleton ratified 2026-06-10**; value tables are Finance-owned data. | PRD | +| R3 | SSP source of truth | **Ratified 2026-06-10:** Finance approves SSP policy; **Contract override authoritative, Catalog baseline fallback**; versioned immutable snapshots **pinned at contract inception, referenced per post** (— reused across the contract's invoices, never re-snapshotted per invoice). The blanket multi-PO block is lifted; `SSP_SNAPSHOT_REQUIRED` remains the per-post **presence** guard. | PRD | +| R4 | Immaterial-one-shot exemption | Point-in-time, no deferral/schedule, ≤ **1% of invoice total or 100 USD-equiv (lower)**, Catalog-SKU-flagged; tenant-configurable — **ratified 2026-06-10**. | PRD | +| R5 | Variable-consideration vs multi-PO tie-break | **Ratified 2026-06-10:** multi-PO + VC → SSP path mandatory; single-PO VC → documented estimate + method only. | PRD | +| R6 | Disaggregation attributes | Streams **usage / recurring / one-time** as a distinct `account_id` per stream — **CoA pattern ratified 2026-06-10** (`tenant_account.revenue_stream` + widened CoA UNIQUE — Foundation); stream **names** remain Finance-confirmable. | PRD | + +`ScheduleBuilder` materializes a `recognition_schedule` **in the same transaction as the Foundation Contract-liability credit** (no outbox path), so a deferred balance never exists without a schedule. `RecognitionRunner` executes per period through the Foundation `PostingService`. + +## 2. Actor Flows (CDSL) + +### Trigger Recognition Run + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-recognition-run` + +**Actor**: `cpt-cf-bss-ledger-actor-recognition-run` + +A scheduled/event run releases due segments for a period. Each released **segment** produces **one balanced entry**, posted through the Foundation `PostingService` via `cpt-cf-bss-ledger-algo-recognition-segment-release`: + +| Line | Side | Account class | +|------|------|---------------| +| Recognize | DR | `CONTRACT_LIABILITY` (segment's stream) | +| Revenue | CR | `REVENUE` (same `revenue_stream`) | + +**At-most-once per segment per period.** Idempotent per `(tenant, RECOGNITION, schedule_id:segment_no)` (Foundation `IdempotencyGate`); the `recognition_segment` `status=DONE`/`run_id` + the `UNIQUE (schedule, period_id)` key prevent a second credit. The **run** is an orchestration wrapper (dedup `(tenant, period_id, runId)` at the orchestration layer, plus a per-`(tenant, period_id)` advisory lock / single-active-run guard so overlapping runs serialize); each segment is independently at-most-once via the Foundation gate — overlapping **different** runIds cannot double-credit a segment. + +**Run gating.** Whether to run keys on **obligation-satisfaction** state, **not** collections/dunning: a collections-suspended payer's schedule keeps recognizing (revenue is earned regardless of collection); only an upstream **cancellation** (obligation ceased) stops/changes the schedule (PRD). **(/ S4-minor) Freshness contract.** The consumed obligation-satisfaction state is **eventually-consistent** (Subscriptions is the SoT); run-gating reads the latest known state at run time. **Fail-safe default:** an **unknown or stale** state is treated as **"not satisfied"** — recognition is delayed (and surfaced at the slice 7 close gate via undone-due-segment blocking), never released early; a stale "satisfied" can only ever release up to `total_deferred_minor` (the per-schedule CHECK caps it). Staleness beyond a configured watermark raises an operational alarm rather than silently delaying revenue. + +**Ordering (feature-owned mechanism).** Before releasing the segment for period N, the runner asserts all lower-`period_id` segments of the **same schedule** are `DONE`; if not, the segment is marked **`QUEUED`** and the request returns **202** with body status token `recognition-period-queued` (kebab-case; no SCREAMING_SNAKE code on a 202 — deferral convention uniform across slices 2/3/4). A later run picks up `QUEUED` segments once the predecessor commits. (Queue-vs-commit behavior per PRD; the mechanism is defined here, not inherited.) + +**Success Scenarios**: +- Due segments release; per-stream Contract liability drains; segments stamped `DONE` atomically with the posting +- Run replay returns the prior run reference (Foundation AC #19) + +**Error Scenarios**: +- Out-of-order period → segment `QUEUED`, 202 `recognition-period-queued` +- Over-release attempt → 409 `OVER_RECOGNITION` (per-schedule CHECK) +- Unknown/stale obligation-satisfaction state → recognition delayed (fail-safe), staleness watermark alarm + +**Steps**: +1. [ ] - `p1` - Scheduler or client calls API: POST /v1/ledger/recognition-runs (body: period_id, runId) - `inst-run-api` +2. [ ] - `p1` - Dedup the run trigger on `(tenant, period_id, runId)` at the orchestration layer; **IF** replay **RETURN** prior run reference - `inst-run-dedup` +3. [ ] - `p1` - Acquire the per-`(tenant_id, period_id)` single-active-run advisory lock so overlapping runs serialize - `inst-run-lock` +4. [ ] - `p1` - DB: Select due `recognition_segment` rows (status `PENDING`/`QUEUED`, period ≤ run period) via partition-pruned schedule scan - `inst-run-select` +5. [ ] - `p1` - **FOR EACH** due segment - `inst-run-foreach` + 1. [ ] - `p1` - **IF** obligation-satisfaction state for the schedule is unknown/stale/not-satisfied: skip (fail-safe "not satisfied"; alarm beyond the staleness watermark) - `inst-run-gate` + 2. [ ] - `p1` - **IF** any lower-`period_id` segment of the same schedule is not `DONE`: mark segment `QUEUED`, **RETURN** 202 `recognition-period-queued` + correlation handle - `inst-run-order` + 3. [ ] - `p1` - **ELSE** release via `cpt-cf-bss-ledger-algo-recognition-segment-release` (one balanced entry per segment) - `inst-run-release` +6. [ ] - `p1` - Emit `billing.ledger.revenue.recognized` per released segment (§10) - `inst-run-event` +7. [ ] - `p1` - **RETURN** run reference (status RUNNING → DONE/FAILED) - `inst-run-return` + +### Apply Schedule Change + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-recognition-schedule-change` + +**Actor**: `cpt-cf-bss-ledger-actor-catalog-contracts` + +- **Controlled changes.** Rate/period/catch-up/cancellation/reallocation changes require approval where policy demands, an audit trail, and **either** a new schedule **version** (effective-dated) **or** **compensating** journal entries — **never** a silent rewrite of already-released amounts. **A new schedule version is minted with a new `schedule_id`**: the `version` column labels lineage, but the **`schedule_id` is what makes the release key `schedule_id:segment_no` version-distinct** — so a re-versioned schedule's segments can never collide with the prior version's `DONE` segments. A legitimate post-re-version catch-up release is therefore allowed, and a double release under a reused key is structurally impossible. An **S3 credit note** (slice 3) that reduces unreleased deferred revenue is an authorized **prospective-reduction** trigger: it decrements `total_deferred_minor` over the not-yet-released remainder under the shared lock order, never touching already-released segments. +- **Cancel/replace decision is upstream** (Contracts/Subscriptions). On a cancel/replace event the ledger marks the schedule `CANCELLED`/`REPLACED` and applies the new schedule **prospectively** or posts compensating entries as instructed (PRD). **Treatment marking — v1 control.** The cancel/replace (`/changes`) event MUST carry the intended **`treatment`** (`prospective` | `separate_contract` | `catch_up`). `prospective` / `separate_contract` apply directly (the usual SaaS series-of-distinct-services case, ASC 606-10-25-13(a)); a **`catch_up`** modification (ASC 606-10-25-13(b) — remaining goods/services not distinct) **and** any **unmarked / unknown** treatment **route to the exception queue** (`MODIFICATION_TREATMENT_REVIEW`) until the VC & contract-modifications successor PRD ships — never silently applied prospectively (PRD § Periodic recognition controls). + +**Success Scenarios**: +- Prospective / separate-contract change applies; old schedule marked `REPLACED`/`CANCELLED`; new version minted with a new `schedule_id` + +**Error Scenarios**: +- `catch_up` or unmarked treatment → exception queue `MODIFICATION_TREATMENT_REVIEW`, never silently applied +- Change replay per change id returns the prior reference + +**Steps**: +1. [ ] - `p1` - Upstream calls API: POST /v1/ledger/recognition-schedules/{scheduleId}/changes (body: change id, treatment, instructed effect) - `inst-chg-api` +2. [ ] - `p1` - Claim idempotency per change id; **IF** replay **RETURN** prior reference - `inst-chg-idem` +3. [ ] - `p1` - Enforce approval / dual-control where policy demands; record the audit trail - `inst-chg-approval` +4. [ ] - `p1` - **IF** treatment == catch_up **OR** treatment unmarked/unknown: **RETURN** route to exception queue (`MODIFICATION_TREATMENT_REVIEW`) - `inst-chg-treatment` +5. [ ] - `p1` - **IF** cancel: mark schedule `CANCELLED`; **IF** replace/re-version: mark old schedule `REPLACED` and mint the new version with a **new `schedule_id`** (lineage via `version`), recomputing residual only over the not-yet-released remainder - `inst-chg-version` +6. [ ] - `p1` - **IF** instructed: post compensating journal entries instead of / alongside the new version — never rewrite already-released amounts - `inst-chg-compensate` +7. [ ] - `p1` - Emit `billing.ledger.schedule.changed` (§10) - `inst-chg-event` +8. [ ] - `p1` - **RETURN** 200 change reference - `inst-chg-return` + +### Read Recognition Schedules and Disaggregation + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-flow-recognition-inquiry` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-ops` + +**Success Scenarios**: +- Finance reads a schedule with segments + recognized-to-date; reads recognized revenue by stream/period + +**Error Scenarios**: +- Unknown scheduleId → 404; cross-tenant read blocked by RLS + +**Steps**: +1. [ ] - `p1` - API: GET /v1/ledger/recognition-schedules/{scheduleId} — DB: read `recognition_schedule` + `recognition_segment` rows - `inst-rinq-schedule` +2. [ ] - `p1` - API: GET /v1/ledger/revenue/disaggregation — cache/query of recognized revenue by stream/period (per-stream `account_balance` grains) - `inst-rinq-disagg` +3. [ ] - `p1` - **RETURN** 200 (schedule + segments + recognized-to-date / disaggregated revenue) - `inst-rinq-return` + +## 3. Processes / Business Logic (CDSL) + +### ScheduleBuilder (Deferral and Timing Policy) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-schedule-builder` + +**Input**: a posting that creates deferred revenue (S1 invoice path or S4 correction path — debit notes, slice 3), item attributes, local materialized policy/SSP/VC snapshots +**Output**: a `recognition_schedule` + segments materialized in the **same transaction** as the Contract-liability credit, or a failed post (no orphan deferral) + +`ScheduleBuilder` runs **in the same transaction as the posting** — for both the S1 invoice path and the S4 correction path (debit notes, slice 3 D4) — so no deferred balance is ever left without a schedule; if schedule derivation cannot complete, **the post fails** (no orphan deferral). The outbox alternative is removed; `UNSCHEDULED_DEFERRAL` remains a defense-in-depth close predicate (slice 7) and additive `exception_queue` type emitted by this feature; unresolvable R1/R2/R5 ambiguity opens an `exception_queue` row of type `RECOGNITION_POLICY_CONFLICT` (slice 7). + +**Blast radius + recovery (normative).** Because derivation is in-txn, a recognition-config gap fails the **invoice** post — this is stated and accepted (the cost of the no-orphan-deferral invariant). But every such block MUST have an **operational recovery path**, not a silent 422: `SSP_SNAPSHOT_REQUIRED` (and any other recognition-derived block) **also opens an `exception_queue` row + Finance alert** (parity with `RECOGNITION_POLICY_CONFLICT`) with a clear **retry once the config/snapshot lands**. Derivation is **local** → a deterministic config gap, never a remote-availability failure. + +**Idempotency (🔄 — decoupled from status):** the builder claims a Foundation `idempotency_dedup (tenant, flow=SCHEDULE_BUILD, business_id=source_invoice_id:source_invoice_item_ref:revenue_stream)` row, so a replayed or raced build returns the prior reference, never a duplicate — **independent of the schedule's lifecycle `status`** (operation-key-vs-row-key split, per the slice 2 pattern). The table's partial `UNIQUE … WHERE status='ACTIVE'` remains as the at-most-one-live guard. A fully-recognized schedule transitions `ACTIVE → COMPLETED` (terminal); the dedup row persists, so the duplicate key holds permanently even after the `COMPLETED` schedule is archived/partitioned. `REPLACED` versioning keeps history. + +**Bound (default — §11.2; 🔄):** the **120-segment** default is a guardrail, not a hard wall — derivation beyond it **degrades** (coarser segmentation / chunked multi-record schedule, full amount over the full horizon) rather than failing a valid long-horizon / daily contract; 120 to be confirmed against real catalog terms and the degrade strategy confirmed with Finance. + +**Deferral** is decided by precedence R1, **timing** by R2; subscription obligation-satisfaction state (R2) drives *when* runs execute, not the pattern. The derivation reads **versioned, immutable** policy/SSP/VC refs and stamps them on the schedule (historical immutability — a later policy change never rewrites an existing schedule). These refs resolve from the **local** database (materialized policy/SSP snapshots plus the post request's item attributes) — the in-transaction derivation makes **no network call into Contracts or Catalog**, so Foundation C3 (no external blocking dependency on the post path) holds and a Contracts outage cannot take down posting; a stale or missing local snapshot fails the post deterministically rather than blocking on a remote service. R1/R2/R5 precedence skeleton + conflict rules **ratified 2026-06-10** (same-dimension → Contract wins; unresolvable ambiguity → block + exception queue); value tables are Finance-owned data. VC estimate/method evidence is referenced read-only via `vc_estimate_ref`/`vc_method_ref` (or the Contract artifact via `policy_ref`). + +**Steps**: +1. [ ] - `p1` - Claim `idempotency_dedup (tenant, SCHEDULE_BUILD, source_invoice_id:source_invoice_item_ref:revenue_stream)`; **IF** replay/race **RETURN** prior reference (independent of schedule `status`) - `inst-sb-idem` +2. [ ] - `p1` - Resolve deferral by R1 precedence (Contract → Catalog SKU/Plan → PO type → billing model) and timing by R2 (Contract → Catalog + PO type → subscription state) from **local** immutable snapshots — no network call on the post path - `inst-sb-policy` +3. [ ] - `p1` - **IF** same-dimension conflict: Contract wins; **IF** unresolvable ambiguity: fail the post + open `exception_queue` row `RECOGNITION_POLICY_CONFLICT` - `inst-sb-conflict` +4. [ ] - `p1` - Algorithm: apply PO/SSP gates via `cpt-cf-bss-ledger-algo-recognition-po-ssp-allocation` (may fail the post with 422 + exception-queue row + Finance alert) - `inst-sb-possp` +5. [ ] - `p1` - Derive segments (time- or milestone-sliced); **IF** derivation exceeds 120 segments: **degrade** (coarser segmentation / chunked multi-record schedule over the full horizon) rather than failing a valid long-horizon contract - `inst-sb-segments` +6. [ ] - `p1` - DB: Insert `recognition_schedule` (+segments) in the **same txn** as the Contract-liability credit, stamping immutable `policy_ref` / `ssp_snapshot_ref` / `vc_estimate_ref` / `vc_method_ref` - `inst-sb-insert` +7. [ ] - `p1` - **IF** derivation cannot complete: fail the post (no orphan deferral); `UNSCHEDULED_DEFERRAL` remains the defense-in-depth close predicate - `inst-sb-fail` +8. [ ] - `p1` - **RETURN** schedule reference - `inst-sb-return` + +### Segment Release (S6 Posting) + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-recognition-segment-release` + +**Input**: a due `recognition_segment` (predecessors `DONE`, gating satisfied), run context +**Output**: one balanced `DR CONTRACT_LIABILITY / CR REVENUE` entry + segment stamp + counter increment, atomic + +**Atomicity (critical).** Within the **same** Foundation `PostingService` transaction that posts the DR/CR, the runner also stamps the segment (`status=DONE`, `recognized_at`, `run_id`) and increments `recognition_schedule.recognized_minor` by an **in-place delta** — `SERIALIZABLE`/SSI-serialized, NOT a `FOR UPDATE` row lock — (`SET recognized_minor = recognized_minor + amount`, `CHECK` evaluated post-delta). Journal + segment stamp + counter commit or roll back together. `recognition_schedule` and `recognition_segment` are added to the **total fixed write/UPSERT order** (`table_rank` just below the balance caches, ordered by `(tenant_id, schedule_id)` then `segment_no`), so concurrent posts serialize under SSI — Postgres detects the write-write conflict and retries; there is no `FOR UPDATE` row locking. **Merged ordering:** acquire in ascending `table_rank` — the `CONTRACT_LIABILITY` + `REVENUE` `account_balance` rows in the Foundation's existing `(table_rank, tenant_id, account_id, currency, …)` order **first**, then the recognition tables by `(tenant_id, schedule_id, segment_no)`; one global order across all recognition posts. + +**Authoritative over-recognition guard.** The **per-schedule** `CHECK (recognized_minor ≤ total_deferred_minor)` — the only grain that maps 1:1 to an obligation — is the in-transaction, lock-ordered guard; failure → `OVER_RECOGNITION` (409). The aggregate `CONTRACT_LIABILITY` no-negative `CHECK` (Foundation) is **defense-in-depth** only (it would not catch over-release of one schedule while a sibling keeps the account aggregate positive). Cumulative releases ≤ posted Contract liability is reconciled by the tie-out. + +**Reversal/clawback.** A recognition reversal is a new `DR REVENUE / CR CONTRACT_LIABILITY` entry (its own key `schedule_id:segment_no:reversal`, flow `RECOGNITION`) that decrements `recognized_minor` in the same transaction; Revenue decreasing here is a legitimate reversal, not a sign violation. A reversed segment stays `status=DONE` (its release happened and was compensated); re-recognizing that period requires a **new schedule version**, never a silent re-release under the same key. + +**Accrual, not cash.** Recognition is independent of any payment/settlement (slice 2). **Residual cent → last segment** of the **schedule version** that owns it (a re-version recomputes residual only over the not-yet-released remainder; already-released segments are never recomputed) (Foundation `MoneyModule`, PRD). + +**Period assignment.** A release entry posts with the **segment's `period_id`** while that fiscal period is OPEN. The period-N close gate (slice 7) blocks while any segment due ≤ N is not `DONE`. A segment that nonetheless misses close posts into the **current open period**, with the original target period recorded as audit linkage — never into a closed period (Foundation `FiscalPeriodGuard`). + +**Steps**: +1. [ ] - `p1` - Claim idempotency: `idempotency_dedup (tenant, RECOGNITION, schedule_id:segment_no)`; **IF** replay **RETURN** prior reference - `inst-rel-idem` +2. [ ] - `p1` - Acquire writes in ascending `table_rank`: `CONTRACT_LIABILITY` + `REVENUE` `account_balance` rows first (Foundation order), then `recognition_schedule`/`recognition_segment` by `(tenant_id, schedule_id, segment_no)` - `inst-rel-order` +3. [ ] - `p1` - Post the balanced entry DR `CONTRACT_LIABILITY` / CR `REVENUE` — DR and CR carry the **same** `revenue_stream` - `inst-rel-post` +4. [ ] - `p1` - Assert (re-asserted at run time) `source_invoice_item_ref` resolves to a posted `CONTRACT_LIABILITY` `journal_line`; **IF** not **RETURN** 422 `RECOGNITION_WITHOUT_INVOICE_LINK` - `inst-rel-link` +5. [ ] - `p1` - **IF** the segment's `period_id` fiscal period is OPEN: post with that `period_id`; **ELSE** post into the current open period recording the original target period as audit linkage (never a closed period) - `inst-rel-period` +6. [ ] - `p1` - DB: Same txn — stamp segment `status=DONE`, `recognized_at`, `run_id`; `SET recognized_minor = recognized_minor + amount` (SSI-serialized in-place delta, CHECK post-delta) - `inst-rel-stamp` +7. [ ] - `p1` - **IF** `CHECK (recognized_minor ≤ total_deferred_minor)` fails: **RETURN** 409 `OVER_RECOGNITION` (whole txn rolls back) - `inst-rel-overrec` +8. [ ] - `p1` - Attach the residual cent to the **last segment** of the owning schedule version - `inst-rel-residual` +9. [ ] - `p1` - **RETURN** posting reference (journal + stamp + counter atomic) - `inst-rel-return` + +### PO Identification and SSP Allocation + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-recognition-po-ssp-allocation` + +**Input**: posted invoice item attributes, Contract/Catalog PO + SSP + VC refs (local immutable snapshots) +**Output**: PO/allocation-group tagging + SSP-allocated transaction price, or a blocking gate (422 + exception queue) + +- **Identify POs.** A posted invoice item MUST carry a PO/allocation-group id when (1) it has deferral/a schedule, (2) Contract/Catalog marks it multi-PO/ASC-tracked/SSP-required, or (3) variable consideration applies; otherwise a Catalog **default** allocation group. The only exemption is the **immaterial one-shot** (R4). Outside it, missing PO **blocks post** — the invoice-posting handler (Foundation) enforces the *presence* gate; **this feature owns the trigger conditions + exemption**. The `MISSING_PO_ALLOCATION_GROUP` rule is **registered by this feature as a Foundation post-hook** that the post pipeline invokes (same posture as `flow=RECOGNITION`); the handler surfaces the code on its endpoint. **Block scope.** The Catalog **default** allocation group is the **primary mitigation**: an ordinary point-in-time line is **auto-tagged** and **never blocks** the invoice; `MISSING_PO_ALLOCATION_GROUP` fires **only** on a genuinely **ambiguous** deferred / multi-PO / VC line whose PO/allocation group cannot be resolved **and** cannot be defaulted — audit hygiene, not a tag-everything-or-stop gate on routine billing. **(/ S4-minor) Ownership boundary (explicit).** The invoice-posting handler (Foundation) owns **only** the *presence* gate on its post endpoint; **this feature owns the trigger conditions + R4 exemption**, shipped as a **Foundation post-hook** the post pipeline invokes (same posture as `flow=RECOGNITION`). A change to the trigger is a **feature-local** change to its own hook — **no mutation of another slice's endpoint** (the Repository-foundation post-hook mechanism; the cross-slice pattern is dissolved). +- **Transaction price.** **SSP snapshots** required for **multi-PO** allocation; **documented pricing adjustments** suffice for a **single-PO** line backed by immutable evidence. **Variable consideration** always carries a documented estimate + method (immutable refs). **VC posting boundary.** For the MVP all VC is usage billed in arrears on actuals (invoice = earned), so **no VC estimate / true-up posting is in scope**; VC accrual + period re-estimation + modification accounting (catch-up vs prospective) is a **named successor PRD** (Revenue — VC & contract modifications) owned with Contracts/Subscriptions — never a silent Design default. +- **SSP gate (R3 — ratified 2026-06-10).** The SSP source/precedence is committed (Catalog baseline → Contract override → Finance-approved versioned snapshots), so the blanket multi-PO block is **lifted**. `SSP_SNAPSHOT_REQUIRED` (422) remains as the **per-post guard**: a multi-PO schedule still cannot materialize when the required SSP snapshot ref is missing or unresolvable for that posting. In addition to the 422, a missing/unresolvable SSP snapshot **opens an `exception_queue` row + Finance alert** with a retry-once-loaded path — a config gap is never a silent dead-end. **Inception pinning.** The SSP snapshot **value** is pinned at **contract inception** (the first post for the contract) and the **same** versioned ref is reused for every subsequent invoice of that contract — never re-snapshotted per invoice (ASC 606 allocates the transaction price at inception and forbids re-allocation on later SSP changes). The per-post guard only checks the ref is **present and resolvable**; it does **not** re-pick a fresh SSP. For a single-invoice / single-PO line, inception = that post (so per-post and per-inception coincide). **Inception ties to the SSP *requirement*, not the first ledger post.** When a contract's first post is a **point-in-time single-PO line that needs no SSP**, that post pins **no** SSP value; SSP is pinned at the **first post that triggers an SSP requirement** (the first multi-PO / allocation line). A later multi-PO line lacking a resolvable SSP ref hits `SSP_SNAPSHOT_REQUIRED` (422 + exception queue, above) — it never mis-allocates silently on a routine bill. + +**Steps**: +1. [ ] - `p1` - **IF** line is immaterial one-shot (R4: point-in-time, ≤ 1% invoice total or 100 USD-equiv (lower), SKU-flagged; tenant-configurable): exempt — no deferral/schedule/PO requirement - `inst-po-exempt` +2. [ ] - `p1` - **IF** line has deferral/schedule, is marked multi-PO/ASC-tracked/SSP-required, or carries VC: require a PO/allocation-group id; **ELSE** auto-tag the Catalog default allocation group (never blocks) - `inst-po-require` +3. [ ] - `p1` - **IF** PO/allocation group cannot be resolved and cannot be defaulted (genuinely ambiguous deferred/multi-PO/VC line): **RETURN** 422 `MISSING_PO_ALLOCATION_GROUP` via the Foundation post-hook - `inst-po-block` +4. [ ] - `p1` - **IF** multi-PO: require the contract-inception-pinned SSP snapshot ref (present + resolvable; pinned at the first SSP-requiring post, reused for all subsequent invoices, never re-picked); **IF** missing/unresolvable **RETURN** 422 `SSP_SNAPSHOT_REQUIRED` + `exception_queue` row + Finance alert (retry once the snapshot lands) - `inst-po-ssp` +5. [ ] - `p1` - **IF** single-PO: accept documented pricing adjustments backed by immutable evidence - `inst-po-single` +6. [ ] - `p1` - **IF** VC present: require documented estimate + method refs (`vc_estimate_ref`/`vc_method_ref`); multi-PO + VC → SSP path mandatory (R5); no VC true-up posting in MVP (successor PRD) - `inst-po-vc` +7. [ ] - `p1` - **RETURN** tagged + allocated line attributes for schedule materialization - `inst-po-return` + +### Disaggregation Derivation and Mixed-Invoice Split + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-recognition-disaggregation-split` + +**Input**: revenue-affecting lines of a posting (possibly a multi-stream bundle) +**Output**: per-stream Contract-liability lines + one schedule per stream; recognition entries stream-matched + +Every revenue-affecting line carries a **mandatory revenue-stream classification** (usage / recurring / one-time) — the line-tag **invariant** is enforced in the Foundation; **this feature owns the derivation + the mixed-invoice split**. For a multi-stream bundle, the **Foundation Contract-liability credit is split into one deferred line per stream**, so the `CONTRACT_LIABILITY` balance is tracked per stream; this feature creates **one schedule per stream**, and a recognition segment's `revenue_stream` **MUST equal** the stream of the Contract-liability line it draws down (DR and CR carry the **same** stream). Per-stream Contract liability drains to zero (not just the aggregate). Streams map to distinct natural accounts / sub-accounts / reporting dimensions, not free text. **R6 — ratified 2026-06-10:** streams `usage / recurring / one-time` resolve to a **distinct `account_id` per stream** for both `REVENUE` and `CONTRACT_LIABILITY` (one `tenant_account` row per (class, stream, currency)). Consequences: per-stream balances come free via `account_balance`; the per-stream `CONTRACT_LIABILITY` **no-negative `CHECK` on `account_balance` is now the authoritative per-stream drain guard** (the per-schedule `recognized_minor ≤ total_deferred_minor` `CHECK` remains the per-obligation guard); the per-tenant credit-side hot row splits three ways. Stream **names** remain Finance-confirmable without affecting the mechanism. + +**Steps**: +1. [ ] - `p1` - Derive the revenue-stream classification (usage / recurring / one-time) for each revenue-affecting line - `inst-dis-derive` +2. [ ] - `p1` - **IF** multi-stream bundle: split the Contract-liability credit into one deferred line per stream - `inst-dis-split` +3. [ ] - `p1` - Create **one `recognition_schedule` per stream** (a schedule is single-revenue-stream) - `inst-dis-schedule` +4. [ ] - `p1` - Resolve each stream to its distinct `account_id` (`tenant_account` row per (class, stream, currency)) for both `REVENUE` and `CONTRACT_LIABILITY` - `inst-dis-account` +5. [ ] - `p1` - Enforce: a recognition segment's `revenue_stream` equals the stream of the Contract-liability line it draws down; per-stream Contract liability drains to zero - `inst-dis-match` +6. [ ] - `p1` - **RETURN** per-stream lines + schedules (per-stream no-negative CHECK = authoritative drain guard) - `inst-dis-return` + +## 4. States (CDSL) + +### Recognition Schedule State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-recognition-schedule` + +**States**: ACTIVE, COMPLETED, REPLACED, CANCELLED +**Initial State**: ACTIVE (at-most-one-live per `(tenant_id, source_invoice_id, source_invoice_item_ref, revenue_stream)` via partial UNIQUE) + +**Transitions**: +1. [ ] - `p1` - **FROM** ACTIVE **TO** COMPLETED **WHEN** fully recognized (`recognized_minor == total_deferred_minor`, all segments `DONE`) — terminal; schedule + segments become archivable/partitionable while the `SCHEDULE_BUILD` dedup row persists (build idempotency decoupled from status, no duplicate-build hole) - `inst-st-rs-completed` +2. [ ] - `p1` - **FROM** ACTIVE **TO** REPLACED **WHEN** a controlled change mints a new version — new `schedule_id`, lineage via `version`; release keys can never collide with the prior version's `DONE` segments - `inst-st-rs-replaced` +3. [ ] - `p1` - **FROM** ACTIVE **TO** CANCELLED **WHEN** an upstream cancel decision arrives (obligation ceased); prospective effect / compensating entries as instructed - `inst-st-rs-cancelled` + +### Recognition Segment State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-recognition-segment` + +**States**: PENDING, QUEUED, DONE +**Initial State**: PENDING (`segment_no` immutable, 1:1 with `period_id`) + +**Transitions**: +1. [ ] - `p1` - **FROM** PENDING **TO** DONE **WHEN** the segment releases (atomic with the DR/CR posting; `recognized_at` + `run_id` stamped) - `inst-st-seg-done` +2. [ ] - `p1` - **FROM** PENDING **TO** QUEUED **WHEN** the run finds a lower-`period_id` segment of the same schedule not `DONE` (202 `recognition-period-queued`) - `inst-st-seg-queued` +3. [ ] - `p1` - **FROM** QUEUED **TO** DONE **WHEN** a later run releases it after the predecessor commits - `inst-st-seg-resume` +4. [ ] - `p1` - A reversed segment **stays DONE** (its release happened and was compensated); re-recognizing that period requires a new schedule version, never a re-release under the same key - `inst-st-seg-reversal` + +### Recognition Run State Machine + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-state-recognition-run` + +**States**: RUNNING, DONE, FAILED +**Initial State**: RUNNING (orchestration wrapper only — never the dedup key; segments are independently at-most-once) + +**Transitions**: +1. [ ] - `p1` - **FROM** RUNNING **TO** DONE **WHEN** all due segments for the period are released or queued - `inst-st-run-done` +2. [ ] - `p1` - **FROM** RUNNING **TO** FAILED **WHEN** the run aborts; a re-trigger with a new runId re-drives safely (per-segment idempotency prevents double credit) - `inst-st-run-failed` + +## 5. Definitions of Done + +### Schedule Materialization + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-asc606-recognition-schedule-build` + +The system **MUST** materialize a `recognition_schedule` in the same transaction as the Contract-liability credit (S1 invoice path and S4 correction path), fail the post when derivation cannot complete (no orphan deferral), open exception-queue rows + Finance alerts for every recognition-derived block, claim `SCHEDULE_BUILD` idempotency decoupled from schedule status, stamp immutable policy/SSP/VC refs resolved locally (no network call on the post path), and degrade rather than fail beyond the 120-segment guardrail. + +**Implements**: +- `cpt-cf-bss-ledger-algo-schedule-builder` +- `cpt-cf-bss-ledger-state-recognition-schedule` + +**Touches**: +- DB: `recognition_schedule`, `recognition_segment`, `idempotency_dedup` (flow `SCHEDULE_BUILD`), `exception_queue` +- Entities: `RecognitionSchedule`, `RecognitionSegment` + +### Recognition Run and Segment Release + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-asc606-recognition-run-release` + +The system **MUST** release due segments per period as one balanced entry each (`DR CONTRACT_LIABILITY / CR REVENUE`, same stream both legs), atomically with the segment stamp and the SSI-serialized `recognized_minor` in-place delta, at-most-once per `(tenant, RECOGNITION, schedule_id:segment_no)`, guarded by the per-schedule over-recognition CHECK, ordered per schedule (QUEUED + 202 on out-of-order periods), gated on obligation satisfaction with the fail-safe stale-state default, period-assigned per, with reversal as a new compensating entry under `schedule_id:segment_no:reversal` and residual cent to the last segment of the owning version. + +**Implements**: +- `cpt-cf-bss-ledger-flow-recognition-run` +- `cpt-cf-bss-ledger-algo-recognition-segment-release` +- `cpt-cf-bss-ledger-state-recognition-segment` +- `cpt-cf-bss-ledger-state-recognition-run` + +**Touches**: +- API: `POST /v1/ledger/recognition-runs` +- DB: `recognition_run`, `recognition_segment`, `recognition_schedule`, `account_balance`, `journal`, `journal_line`, `idempotency_dedup` +- Entities: `RecognitionRun`, `RecognitionSegment` + +### PO and SSP Gates + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-asc606-recognition-po-ssp` + +The system **MUST** enforce PO/allocation-group tagging via a Foundation post-hook owned by this feature (trigger conditions + R4 exemption here; presence gate on the Foundation endpoint), auto-tag the Catalog default so routine lines never block, block only genuinely ambiguous deferred/multi-PO/VC lines (`MISSING_PO_ALLOCATION_GROUP`), require inception-pinned SSP snapshots for multi-PO allocation (`SSP_SNAPSHOT_REQUIRED` + exception queue + Finance alert), accept documented pricing for single-PO lines, and require documented VC estimate + method refs (no VC true-up posting in MVP — successor PRD). + +**Implements**: +- `cpt-cf-bss-ledger-algo-recognition-po-ssp-allocation` + +**Touches**: +- API: Foundation invoice-post endpoint (additive post-hook problem codes) +- DB: `recognition_schedule` (`po_allocation_group`, `ssp_snapshot_ref`, `vc_estimate_ref`, `vc_method_ref`), `exception_queue` +- Entities: `RecognitionSchedule` + +### Revenue-Stream Disaggregation + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-asc606-recognition-disaggregation` + +The system **MUST** derive the usage/recurring/one-time stream per revenue-affecting line, split multi-stream bundles into one deferred Contract-liability line and one schedule per stream, resolve each stream to a distinct `account_id` for `REVENUE` and `CONTRACT_LIABILITY` (R6 CoA pattern), keep DR/CR streams equal on every recognition entry, and drain per-stream Contract liability to zero with the per-stream no-negative CHECK as the authoritative drain guard. + +**Implements**: +- `cpt-cf-bss-ledger-algo-recognition-disaggregation-split` + +**Touches**: +- API: `GET /v1/ledger/revenue/disaggregation` +- DB: `recognition_schedule` (`revenue_stream`), `account_balance`, `tenant_account` +- Entities: `RecognitionSchedule` + +### Schedule Change Control + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-asc606-recognition-schedule-change` + +The system **MUST** apply upstream-decided changes only via new schedule versions (new `schedule_id`, lineage `version`) or compensating entries — never rewriting released amounts — with approval/dual-control and audit trail, route `catch_up` and unmarked treatments to `MODIFICATION_TREATMENT_REVIEW`, apply `prospective`/`separate_contract` directly, support the S3 credit-note prospective reduction of `total_deferred_minor` over the unreleased remainder, and mark schedules `CANCELLED`/`REPLACED` per the upstream decision. + +**Implements**: +- `cpt-cf-bss-ledger-flow-recognition-schedule-change` +- `cpt-cf-bss-ledger-state-recognition-schedule` + +**Touches**: +- API: `POST /v1/ledger/recognition-schedules/{scheduleId}/changes` +- DB: `recognition_schedule`, `recognition_segment`, `exception_queue` +- Entities: `RecognitionSchedule` + +### Audit Linkage and Invariants + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-asc606-recognition-audit-linkage` + +Every recognition journal entry **MUST** carry: (1) the recognition **period/segment**, (2) the **PO/allocation group**, (3) the **deferral origin** — the posted invoice item(s) that posted the Contract liability, and (4) **subscription/entitlement context** when subscription-scoped. **Period alone is insufficient.** A missed-close release additionally records the **original** target period. At schedule-build time **and re-asserted at run time**, `source_invoice_item_ref` MUST **resolve** to an existing posted `journal_line` of class `CONTRACT_LIABILITY` for that schedule — resolution is against `journal_line.invoice_item_ref`, served by the Foundation index `(tenant_id, invoice_id, invoice_item_ref)`; NOT NULL is necessary but not sufficient; failure → `RECOGNITION_WITHOUT_INVOICE_LINK` (422) — a recognition entry without a posted-invoice-item link MUST block (it would imply contract-asset accounting, out of scope, PRD). Cumulative releases remain consistent with the posted Contract liability (within rounding), verified by the Foundation/slice 7 tie-out. + +**Implements**: +- `cpt-cf-bss-ledger-algo-recognition-segment-release` +- `cpt-cf-bss-ledger-algo-schedule-builder` + +**Touches**: +- DB: `recognition_schedule` (`source_invoice_item_ref`), `journal_line` (`invoice_item_ref` resolution) +- Entities: `RecognitionSchedule`, `JournalLine` + +## 6. Acceptance Criteria + +Testing is a **delta over the Foundation testing architecture** (same levels + mocking rules; "what must NOT be mocked" + concurrency policy + barrier-start mechanics inherited). + +**Unit:** + +- [ ] Deferral-policy precedence (Contract>Catalog>PO-type>billing-model); timing precedence +- [ ] Residual cent → last segment of the owning version +- [ ] Immaterial-exemption threshold; SSP-required decision (multi-PO vs single-PO) +- [ ] Disaggregation stream derivation + mixed-invoice split + +**Integration (testcontainers):** + +- [ ] S6 release `DR Contract liability / CR Revenue` with segment stamp + `recognized_minor` increment **atomic in one txn** +- [ ] **No double recognition**: same segment/period twice → one credit via UNIQUE + idempotency + `status=DONE` +- [ ] **Over-recognition blocked at the per-schedule CHECK even when a sibling schedule keeps the account aggregate positive** +- [ ] Recognition **without** a resolvable invoice-item link blocked +- [ ] Schedule change posts a new version / compensating entry, never rewrites released amounts +- [ ] Multi-stream bundle → per-stream Contract liability drains to zero +- [ ] Deferred-balance-without-schedule surfaced as an exception; recognition reversal decrements `recognized_minor` +- [ ] **Duplicate schedule build** (redelivered/raced) lands on the existing ACTIVE schedule via the partial UNIQUE — never a second `schedule_id` +- [ ] Release posts with the segment's `period_id` while OPEN; a missed-close segment posts to the current open period with original-period linkage +- [ ] Schedule build beyond 120 segments fails the post *(pre- baseline; the degrade strategy supersedes hard failure once confirmed — §11.2)* + +**API:** + +- [ ] RFC 9457 mapping for each problem code; recognition-run idempotent-replay +- [ ] Out-of-order period returns 202 with body token `recognition-period-queued` + +**Ordering & exception:** + +- [ ] Period N before → segment `QUEUED`, resumes after predecessor commits +- [ ] Collections-suspended payer still recognizes; cancelled obligation stops/changes per upstream + +**Concurrency (Foundation lock-order extended):** + +- [ ] Two runs releasing different segments of one schedule whose sum exceeds total → exactly one fails (per-schedule serialization + CHECK) +- [ ] Overlapping different runIds for the same period serialize via the single-active-run guard and never double-credit a segment + +**NFR verification:** + +- [ ] Recognition-run window → batch timing per tenant/period, sized at the 120-segment schedule bound +- [ ] Write p95 → inherited Foundation load test +- [ ] Recognition-double-credit + over-recognition alarms fire + +## 7. Non-Functional Considerations + +- **Performance / NFR mapping**: + +| NFR | Mechanism | Status | +|-----|-----------|--------| +| Recognition run window (S6, per ledger-owner per period) | Batched per-segment posts; partition-pruned schedule scan; ≤ 120 segments/schedule bound | **Committed 2026-06-18: ≤ 30 min per ledger-owner per period close** within the ≤ 120-segments/schedule bound, gated by the **B3** load test (same gate as the B11 read/write/throughput SLOs) | +| Single-entry write p95 ≤ 500 ms | Inherited Foundation (recognition post is one balanced entry) | Inherited (B2 resolved via B11; B3 load test remains open) | +| Availability ≥ 99.9% / immutability | Inherited Foundation | Inherited | + +- **Security**: Inherits Foundation: RLS, append-only, PII-minimized events. Triggering runs and applying controlled schedule changes require the billing-poster / finance scope; schedule changes follow dual-control per policy (audit trail mandatory). Policy/SSP/VC snapshots are read by versioned ref only. +- **Observability**: Metrics: `ledger_recognition_run_duration_seconds` (histogram), `ledger_revenue_recognized_minor{stream}`, `ledger_recognition_double_credit_total`, `ledger_over_recognition_total`, `ledger_recognition_period_queue_depth`, `ledger_schedule_active_total` **(— counts only in-flight `ACTIVE`, excludes terminal `COMPLETED`)**, `ledger_unscheduled_contract_liability_total` (deferred-without-schedule exception). Thresholds wire to the NFR targets + the double-credit/over-recognition alarms. +- **Data**: All tables tenant-scoped RLS (C1); full schemas in §9; `COMPLETED` schedules + segments archivable/partitionable. +- **Compliance**: ASC 606 — inception-pinned SSP allocation (no re-allocation on later SSP changes), modification treatments per ASC 606-10-25-13(a)/(b), accrual-based recognition independent of collections. + +## 8. REST API Surface + +### 8.1 Endpoints + +REST per `rest-api-design`, behind the inbound API gateway. + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST` | `/v1/ledger/recognition-runs` | Trigger a recognition run for a period (also schedulable). | per-run-trigger `(tenant, period_id, runId)` at the orchestration layer; each released segment is independently at-most-once via the Foundation gate | +| `GET` | `/v1/ledger/recognition-schedules/{scheduleId}` | Read a schedule + segments + recognized-to-date. | — | +| `POST` | `/v1/ledger/recognition-schedules/{scheduleId}/changes` | Apply an upstream-decided change/cancel/replace (controlled). Was `{scheduleId}:change` — colon custom methods don't route on axum 0.8 / matchit 0.8.4 (dynamic suffix unsupported). | per change id | +| `GET` | `/v1/ledger/revenue/disaggregation` | Recognized revenue by stream/period. | cache/query | + +### 8.2 Queued Semantics (202) + +**Success / queued semantics (NOT problem+json):** an out-of-order period returns **`202 Accepted`** with body status token `recognition-period-queued` (kebab-case; no SCREAMING_SNAKE error code — uniform across slices 2/3/4) plus segment `QUEUED` + correlation handle; recognition-run replay returns the prior run reference (Foundation AC #19). + +### 8.3 Problem Responses (RFC 9457) + +True errors only: + +| Code | HTTP status | Meaning | +|------|-------------|---------| +| `RECOGNITION_WITHOUT_INVOICE_LINK` | 422 | Recognition without a resolvable posted-invoice-item link (would imply contract-asset accounting, out of scope) | +| `OVER_RECOGNITION` | 409 | Per-schedule `recognized_minor` CHECK failure | +| `MISSING_PO_ALLOCATION_GROUP` | 422 | Additive on the Foundation post endpoint (post-hook); only genuinely ambiguous deferred/multi-PO/VC lines | +| `SSP_SNAPSHOT_REQUIRED` | 422 | Multi-PO without committed SSP snapshot (R3); also opens an exception-queue row + Finance alert | + +## 9. Data Model (Slice-Owned Tables) + +Adds `recognition_schedule`, `recognition_segment`, `recognition_run`; tenant-scoped RLS (C1). + +### 9.1 recognition_schedule + +| Column | Type | Notes | +|--------|------|-------| +| `schedule_id` | uuid | PK part; a re-version mints a **new** `schedule_id` | +| `tenant_id` | uuid | PK part | +| `payer_tenant_id` | uuid | | +| `source_invoice_id` | string | | +| `source_invoice_item_ref` | string | NOT NULL; MUST resolve to a posted `CONTRACT_LIABILITY` line via `journal_line.invoice_item_ref` | +| `po_allocation_group` | string | | +| `subscription_ref` | string | nullable | +| `revenue_stream` | enum | single stream per schedule | +| `currency` | char | | +| `total_deferred_minor` | bigint | | +| `recognized_minor` | bigint | `CHECK (recognized_minor <= total_deferred_minor)` — the authoritative over-recognition guard | +| `policy_ref` | string | deferral+timing policy version (immutable) | +| `ssp_snapshot_ref` | string | nullable; multi-PO only | +| `vc_estimate_ref` | string | nullable; variable consideration | +| `vc_method_ref` | string | nullable | +| `status` | enum | `ACTIVE` \| `COMPLETED` \| `REPLACED` \| `CANCELLED` | +| `version` | bigint | lineage label (release-key distinctness comes from `schedule_id`) | + +PK `(tenant_id, schedule_id)`; partial `UNIQUE (tenant_id, source_invoice_id, source_invoice_item_ref, revenue_stream) WHERE status='ACTIVE'` is the **at-most-one-live** guard (one current schedule per business key); `REPLACED` versioning keeps history. Build-idempotency is **decoupled from `status`** — it lives in `idempotency_dedup (tenant, flow=SCHEDULE_BUILD, business_id=source_invoice_id:source_invoice_item_ref:revenue_stream)` (operation-key-vs-row-key split), so a fully-recognized schedule moves to terminal **`COMPLETED`** without opening a duplicate-build hole; `COMPLETED` schedules + segments are **archivable/partitionable**, and the `(invoice_item, stream)` duplicate key stays enforceable **permanently** via `idempotency_dedup`. A schedule is **single-revenue-stream** — a multi-stream bundle yields **one schedule per stream**. The deferred **balance** is the Foundation `CONTRACT_LIABILITY` `account_balance` (per stream); `recognized_minor` tracks cumulative release at the schedule (obligation) grain, updated by atomic in-place delta under the lock order. + +### 9.2 recognition_segment + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | uuid | PK part | +| `schedule_id` | uuid | PK part | +| `segment_no` | int | PK part; **immutable**; 1:1 with `period_id` | +| `period_id` | string | or milestone ref; `UNIQUE (tenant_id, schedule_id, period_id)` | +| `amount_minor` | bigint | | +| `status` | enum | `PENDING` \| `QUEUED` \| `DONE` | +| `recognized_at` | timestamptz | null until `DONE` | +| `run_id` | uuid | null until `DONE` | + +PK `(tenant_id, schedule_id, segment_no)`; `UNIQUE (tenant_id, schedule_id, period_id)`; `segment_no` immutable, 1:1 with `period_id` — **dedup grain ≡ UNIQUE grain** (provably identical). Max **120 segments** per schedule (default, §11.2). + +### 9.3 recognition_run + +| Column | Type | Notes | +|--------|------|-------| +| `run_id` | uuid | PK part | +| `tenant_id` | uuid | PK part | +| `period_id` | string | PK part | +| `started_at_utc` | timestamptz | | +| `status` | enum | `RUNNING` \| `DONE` \| `FAILED` | + +PK `(tenant_id, period_id, run_id)` (tenant-first composite — the RLS/secure convention, uniform with `journal`/`payment`/`dispute`; `period_id` is folded into the key so a client reusing one `run_id` across two periods runs **both**); run-trigger dedup on the same `(tenant_id, period_id, run_id)` + a **single-active-run** advisory lock scoped per `(tenant_id, period_id)` at the orchestration layer. + +### 9.4 Cross-Table Constraints and Enum Usage + +- `CHECK (recognized_minor <= total_deferred_minor)` on `recognition_schedule` — the **authoritative** in-transaction over-recognition guard; the account-level `CONTRACT_LIABILITY` no-negative `CHECK` is defense-in-depth. +- `source_invoice_item_ref` NOT NULL **and** must resolve to a posted `CONTRACT_LIABILITY` line via `journal_line.invoice_item_ref` (Foundation index `(tenant_id, invoice_id, invoice_item_ref)`). +- `policy_ref` / `ssp_snapshot_ref` / `vc_estimate_ref` / `vc_method_ref` are immutable version refs (historical immutability). +- The `source_doc_type` / idempotency `flow` value `RECOGNITION` and the idempotency-only `SCHEDULE_BUILD` (posts no journal entry of its own, `business_id = source_invoice_id:source_invoice_item_ref:revenue_stream`, so the build dedup is independent of `recognition_schedule.status`) are **Foundation-declared**; this feature uses them. Recognition `business_id = schedule_id:segment_no`; reversal `schedule_id:segment_no:reversal`. +- **Lock order:** `recognition_schedule` + `recognition_segment` get a `table_rank` just below the Foundation balance caches (ordered by `(tenant_id, schedule_id)` then `segment_no`); the recognition post also locks the `CONTRACT_LIABILITY` + `REVENUE` `account_balance` rows in the existing Foundation order. Serialization is SSI (write-write conflict detection + retry), not `FOR UPDATE` row locking. + +## 10. Events and Alarms + +Success via the Foundation outbox: `billing.ledger.revenue.recognized` (`scheduleId`, `segment`, `period`, `amountMinor`, `revenueStream`), `billing.ledger.revenue.recognition_reversed`, `billing.ledger.schedule.changed`. + +Alarms via the separate committed audit/alarm txn: `billing.ledger.invariant.alarm` with `alarmCategory ∈ {recognition-double-credit, over-recognition, recognition-period-queued}` (distinct categories — over-recognition maps to the negative-balance/AC #17 guard, double-credit to the PRD dedup-failure alarm). PII-free. + +## 11. Decision Log and Open Items + +### 11.1 Risks and Deferred Work + +- **SSP source of truth (R3) — resolved 2026-06-10:** the blanket multi-PO gate is lifted; `SSP_SNAPSHOT_REQUIRED` remains the per-post guard for a missing/unresolvable snapshot. +- **Policy matrices (R1/R2/R5/R6) — skeleton ratified 2026-06-10** (precedences + conflict rule: same-dimension → Contract wins; unresolvable ambiguity → block + exception queue; R6 = account-per-stream). The **value tables** are Finance-owned data, filled iteratively without code change. +- **Deferred:** contract-asset / unbilled receivable (separate PRD); ERP export of recognized revenue (slice 7); FX on multi-currency recognition (slice 5 — recognition does not re-lock FX; schedule currency = as posted); VC accrual + period re-estimation + modification accounting (catch-up vs prospective) — named successor PRD "Revenue — VC & contract modifications". + +### 11.2 Needs Discussion (R1–R6, NFR) + +Inherits Foundation open items (B2 resolved 2026-06-10 via B11 — PRD draft committed as v1 SLOs, gated by the B3 load test; B3 remains open). Feature-specific: + +| Item | Decision (default) | Status | Owner | +|------|--------------------|--------|-------| +| **SSP source of truth** | Contract override authoritative, Catalog baseline fallback, Finance-approved versioned snapshots; multi-PO block lifted, `SSP_SNAPSHOT_REQUIRED` stays as per-post guard | ✅ Ratified 2026-06-10 | PM + Finance | +| Deferral-policy matrix + conflict rules | Contract→Catalog→PO-type→billing-model; same-dimension conflict → Contract wins; unresolvable ambiguity → block + exception queue; value tables = Finance-owned data | ✅ Ratified 2026-06-10 (skeleton) | PM + Finance | +| Recognition-timing authority/conflict | Contract→Catalog+PO-type→subscription-state (state drives *when*, never the pattern) | ✅ Ratified 2026-06-10 (skeleton) | PM + Finance | +| Variable-consideration vs multi-PO SSP tie-break | multi-PO **+** VC → SSP path mandatory; single-PO VC → documented estimate + method only | ✅ Ratified 2026-06-10 | Finance | +| Disaggregation attribute names + CoA pattern | streams usage/recurring/one-time as **distinct `account_id` per stream** (REVENUE + CONTRACT_LIABILITY); per-stream `CHECK` becomes the authoritative drain guard; stream names Finance-confirmable | ✅ Ratified 2026-06-10 | Design + Finance | +| Immaterial-one-shot exemption threshold | all three conditions: point-in-time, ≤ 1% invoice total or 100 USD-equiv (lower), SKU-flagged; tenant-configurable | ✅ Ratified 2026-06-10 | Finance | +| Recognition run window | **≤ 30 min per ledger-owner per period close**, within the ≤ 120-segments/schedule bound; gated by the B3 load test (rides the same gate as the B11 read/write/throughput SLOs) | ✅ Committed 2026-06-18 | PM | +| Schedule size bound | **120 segments** default guardrail; over-bound **degrades** (coarser/chunked) rather than failing the post; confirm 120 + degrade strategy vs catalog terms / Finance | ⏳ Needs Discussion — degrade direction set 2026-06-17, validation pending | PM + Finance | diff --git a/gears/bss/ledger/docs/design/05-adjustments-notes-refunds.md b/gears/bss/ledger/docs/design/05-adjustments-notes-refunds.md new file mode 100644 index 000000000..5ecf2e74e --- /dev/null +++ b/gears/bss/ledger/docs/design/05-adjustments-notes-refunds.md @@ -0,0 +1,761 @@ + + + +# DESIGN — Adjustments — Credit/Debit Notes & Refunds (Slice 3) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Post Credit Note](#post-credit-note) + - [Post Debit Note](#post-debit-note) + - [Record Refund Phase](#record-refund-phase) + - [Post Refund with Paired Credit Note](#post-refund-with-paired-credit-note) + - [Post Governed Manual Adjustment](#post-governed-manual-adjustment) + - [Read Exposure and Refund State](#read-exposure-and-refund-state) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Recognized vs Deferred Split and Schedule Reduction](#recognized-vs-deferred-split-and-schedule-reduction) + - [Credit-Note Headroom Cap](#credit-note-headroom-cap) + - [Refund Cap Lifecycle](#refund-cap-lifecycle) + - [Refund-of-Refund Direction](#refund-of-refund-direction) + - [Tax and Revenue-Adjustment Routing](#tax-and-revenue-adjustment-routing) + - [Manual-Adjustment Governance](#manual-adjustment-governance) + - [Refund-Before-Payment Quarantine](#refund-before-payment-quarantine) + - [Caps, Lock Order and Out-of-Order](#caps-lock-order-and-out-of-order) +- [4. States (CDSL)](#4-states-cdsl) + - [Refund Phase State Machine](#refund-phase-state-machine) + - [Refund Clearing State Machine](#refund-clearing-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Credit Note Posting](#credit-note-posting) + - [Debit Note Posting](#debit-note-posting) + - [Refund Lifecycle and Clearing](#refund-lifecycle-and-clearing) + - [Refund-with-Credit-Note Composite](#refund-with-credit-note-composite) + - [Governed Manual Adjustments](#governed-manual-adjustments) + - [Guarded Counters and Slice Tables](#guarded-counters-and-slice-tables) + - [Quarantine and Out-of-Order Handling](#quarantine-and-out-of-order-handling) + - [Adjustment Inquiry Endpoints](#adjustment-inquiry-endpoints) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +Post-invoice adjustments for the billing ledger: credit notes (S3), debit notes (S4), refunds (S5), and the manual-adjustment governance around them — **without ever mutating posted invoice line financials** (AC #3). Corrections are **new compensating entries** only, in the one canonical **strict line-negation** shape. Crucially, **every adjustment that touches a guarded balance also updates the authoritative counter/schedule that guards it** in the same ACID transaction — so a credited-back deferred amount can never be re-recognized, and refunds can never over-restore AR. + +**Traces to**: `cpt-cf-bss-ledger-fr-credit-note-adjustment`, `cpt-cf-bss-ledger-fr-credit-note-cumulative-cap`, `cpt-cf-bss-ledger-fr-debit-note-charge`, `cpt-cf-bss-ledger-fr-refund-balance-first`, `cpt-cf-bss-ledger-fr-manual-adjustment-governance`, `cpt-cf-bss-ledger-fr-reversal-canonical-pattern`, `cpt-cf-bss-ledger-fr-posting-immutability`, `cpt-cf-bss-ledger-fr-balanced-journal-entries`, `cpt-cf-bss-ledger-fr-idempotency-per-flow`, `cpt-cf-bss-ledger-fr-idempotent-replay-contract`, `cpt-cf-bss-ledger-fr-out-of-order-event-handling`, `cpt-cf-bss-ledger-fr-negative-balance-invariants`, `cpt-cf-bss-ledger-fr-exception-suspense-handling`, `cpt-cf-bss-ledger-fr-account-classes`, `cpt-cf-bss-ledger-nfr-posting-performance` + +### 1.2 Purpose + +Give Finance and upstream systems a safe, governed way to correct posted invoices and record cash returned to customers: credit notes reduce revenue/AR at the correct recognized/deferred split, debit notes add charges, refunds return cash balance-first through a two-stage clearing account, and every path is capped, idempotent, dual-controlled above threshold, and append-only. Free-form GL vouchers and bad-debt write-off stay out of scope by construction — attempts are rejected, captured, and paged. + +**Use cases**: `cpt-cf-bss-ledger-usecase-exception-resolution`, `cpt-cf-bss-ledger-usecase-ledger-inquiry` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-bss-ledger-actor-billing-orchestration` | Posts credit/debit notes against posted invoices (call-driven REST intake) | +| `cpt-cf-bss-ledger-actor-payments-psp` | Calls the refund endpoint with approved refund phase outcomes (`initiated`, `confirmed`, `rejected`, `voided`); source of refund-approval provenance | +| `cpt-cf-bss-ledger-actor-rating-subscriptions` | A Rating-side adapter maps `ChargeAdjustment` to `POST /credit-notes` (mapping owned upstream; never a re-rate in the ledger) | +| `cpt-cf-bss-ledger-actor-tax-engine` | Provides the authoritative `TaxBreakdown` carried inline on the posting request — never recomputed by the ledger | +| `cpt-cf-bss-ledger-actor-finance-ops` | Prepares governed manual adjustments and the `unknown_final` disposition; reads exposure/refund state | +| `cpt-cf-bss-ledger-actor-finance-approver` | Approves refunds/credit notes above the D2 dual-control threshold and all governed manual postings (preparer ≠ approver) | +| `cpt-cf-bss-ledger-actor-revenue-assurance` | Receives attempted-write-off, negative-tax-subbalance, stage-1-orphan, and refund-clearing-aged alarms | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — § Posting rules S3/S4/S5, § Reversal canonical pattern, § Manual adjustments, § Tax posting extensions, § Revenue adjustment routing, § Refund-of-refund/oversize credit notes, AC #3/#6/#14/#17/#24 +- **Design**: [01-repository-foundation.md](./01-repository-foundation.md) — see §Component Model for the Foundation engine (`postBalancedEntry`, `applyBalanceDeltas`, `applyCounterDelta`, `idempotencyClaim`, commit trigger, `BalanceProjector`, `TieOutJob`, outbox relay; read-then-write ordering via `SERIALIZABLE`/SSI) +- **Dependencies**: Foundation posting engine (Slice 1); payments-allocation (Slice 2: `payment_settlement`/`payment_allocation`, Cash/Unallocated/Reusable classes, `pending_event_queue`, ChargebackHandler); asc606-recognition (Slice 4: `recognition_schedule`/`recognition_segment`, `ScheduleBuilder`); downstream reconciliation-export (Slice 7: exception queue, close gate) + +### 1.5 Scope + +**In scope**: + +- S3 credit notes (contra-revenue + recognized/unreleased-deferred split with **recognition-schedule reduction** + split-basis recording + block-on-ambiguous + AR-only goodwill + cumulative/headroom cap + split credit leg on paid invoices, AC #3/#24) +- S4 debit notes (tax evidence + direct-split deferred **with schedule-build hook**, AC #3) +- S5 refunds (Pattern A unallocated/on-account, Pattern B restore-AR, two-stage Refund clearing, aggregate + per-invoice caps, dual-control, idempotency by `(tenant, PSP refund id, phase)`, PSP-rejected/voided reversal, refund-of-refund, stuck-clearing `unknown_final` disposition, refund-with-credit-note atomic composite, AC #6) +- canonical reversal +- manual-adjustment governance (segregation of duties, approval thresholds, dual-control, reason code, allow-listed account set, AC #14) +- tax routing + per-`(jurisdiction, filing-period)` tax sub-balance guard (AC #17) + per-`(rate,jurisdiction,filing-period)` disaggregation +- revenue-adjustment routing +- Rating ChargeAdjustment→CreditNote +- refund-before-payment quarantine +- refund-clearing aging + stage-1-orphan alarm + +**Out of scope**: + +- **Bad debt / write-off / recovery / collections case management** — out of scope (PRD, § Resolved decisions); not a policy-versioning default. An attempt to write off via CONTRA_REVENUE-without-revenue-reduction or to zero AR via a non-goodwill path MUST reject + page (see [Manual-Adjustment Governance](#manual-adjustment-governance)). +- **Free-form GL vouchers** — out of scope (PRD); only **governed** adjustments are in scope. +- **PSP refund rails / processing / webhook crypto** — Payments; the ledger **records** the approved refund **outcome** (PRD). +- **Recognition releases (S6)** — Slice 4 (time-based revenue adjustments route there); this feature **reduces** the recognition schedule when crediting deferred revenue but does not run releases. +- **FX**, **ERP export / jurisdiction net-presentation** — Slices 5/7 (see [fx-multicurrency](./06-fx-multicurrency.md)). +- **Engine mechanics** — Slice 1 (01-repository-foundation.md); **settlement/allocation/chargeback/wallet** — Slice 2. + +### 1.6 Constraints & Assumptions + +Inherits Slice 1 C1–C4 + A1–A6, Slice 2 P1–P5, Slice 4 R1–R6. Slice-3-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| D1 | Two-stage refund | Initiation ≠ PSP settlement is the **default** (two-stage via `REFUND_CLEARING`); single-step `…/Cash` only where PSP/tenant guarantees atomic, no clearing residual. | PRD | +| D2 | Dual-control thresholds | Refunds/credit notes above a tenant-configured amount require preparer/approver dual-control. Platform default **≥ 1,000 USD-equivalent**; tenant range **[100.. 1,000,000]**; out-of-range config **rejected** (no silent clamp). Ratified 2026-06-10. **(🔄 2026-06-15)** For a **non-USD** operation the USD-equivalent comparison uses the **same rate snapshot as the operation itself** (not a separate "fresh" rate) — deterministic + anti-circumvention. **(Impl note 2026-06-29 — gap:** the gate currently values the comparand at the **gate-time** rate; honoring the operation-snapshot rule needs the originating operation to expose its locked rate — the `payment_settlement` row carries **no** rate today, so a refund has no stored snapshot to value against. Storing the locked rate on settle (then valuing the threshold at it) is the open remediation; until then FX drift between settlement and gate time can mis-classify a near-threshold cross-currency refund.**)** | PRD; needs-discussion D2 | +| D3 | Goodwill/AR-only class | AR-only credits debit `GOODWILL` (non-revenue); the authoritative AR floor is the Slice 1 `ar_invoice_balance` NO-negative `CHECK`, **not** `invoice_exposure` (which caps contra/headroom only). **(Impl note 2026-06-29:** goodwill is **AR-only** and **cannot mint `REUSABLE_CREDIT`** — a goodwill credit that would exceed open AR (incl. any amount on a fully-paid invoice) is **rejected**, not walleted; and in impl goodwill is **additionally** subject to the `invoice_exposure` headroom cap — a deliberately **conservative** second ceiling that can only ever tighten, never over-relieve.**)** | PRD | +| D4 | S4 deferred → schedule | An S4 debit note that creates deferred Contract liability triggers the Slice 4 `ScheduleBuilder` in the **same atomic unit** (same rule Slice 1 applies to S1), so no deferred balance exists without a schedule. | PRD; Slice 4 §4.2 | + +### 1.7 Naming & Design-Introduced Names + +Reuses the PRD glossary and **inherits engine mechanics from the Foundation** (see 01-repository-foundation.md §Component Model): `PostingService`, append-only journal, **strict line-negation reversal** (the one canonical correction shape), `IdempotencyGate` (3-column `idempotency_dedup` PK + idempotent-replay incl. **conflicting-payload hard-error + secured-audit capture**, AC #19), `MoneyModule`, `BalanceProjector` (upsert + conditional no-negative), `FiscalPeriodGuard`, leaf-partition commit trigger, the **total fixed lock order**, `TieOutJob`. Builds on **Slice 2**'s `payment_settlement` / `payment_allocation` (this feature maintains the refund counters via the Foundation `applyCounterDelta` primitive — no DDL; columns owned by Slice 2) + the Cash/Unallocated/Reusable classes; on **Slice 4**'s `recognition_schedule`/`recognition_segment` (a credit note that reduces deferred revenue prospectively reduces them). Not restated. + +**Canonical slice numbering:** 1 posting-engine-core, 2 payments-allocation, **3 adjustments-notes-refunds (this feature)**, 4 asc606-recognition, 5 fx-multicurrency, 6 audit-immutability-observability, 7 reconciliation-export, 8 other. The `source_doc_type` / idempotency `flow` values `CREDIT_NOTE | DEBIT_NOTE | REFUND | MANUAL_ADJUSTMENT` (the last for governed manual postings, ·) and the `account_class` literals `CONTRA_REVENUE` (debit-normal), `REFUND_CLEARING` (**credit-normal** liability), `GOODWILL` (non-revenue debit) are **Foundation-declared** (Foundation data model); this feature **uses** them. Book: Contra/adjustments. + +Design-introduced names (Slice 3): + +| Name | Meaning | +|------|---------| +| `invoice_exposure` | Per-invoice guarded counter `(tenant, invoice_id, original_total_minor, debit_note_total_minor, credit_note_total_minor)` with `CHECK (credit_note_total_minor ≤ original_total_minor + debit_note_total_minor)` — the **headroom cap** serialization point (AC #24). | +| `payment_allocation_refund` | Per-`(payment, invoice)` guarded counter `(tenant, payment_id, invoice_id, allocated_minor, refunded_minor)` with `CHECK (refunded_minor ≤ allocated_minor)` — the **per-invoice refund cap** serialization point (AC #6, PRD). Created + `allocated_minor`-maintained by **Slice 2's AllocationHandler at allocation time** (migration moved to Slice 2); this feature adds the `refunded_minor` consumption + `CHECK`s. A refund against a `(payment, invoice)` pair with no counter row simply means "nothing allocated" → `REFUND_EXCEEDS_ALLOCATED`. | +| `credit_note` / `debit_note` | Records linking a note to its originating posted invoice + the recognized/deferred split basis. | +| `refund` | Ledger record of an approved refund: PSP refund id, **mandatory origin `payment_id` + `currency` — both patterns**, phase (incl. terminal `unknown_final`), pattern (A/B), amounts, clearing state, optional forward link. | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph upstream["Upstream"] + S1ENG["Slices 1/2/4
posted AR · payment_settlement/allocation · recognition_schedule lineage"] + PSP["Payments (PSP)
approved Refund (phase) · webhook"] + TAX["Tax Engine
TaxBreakdown (authoritative)"] + RAT["Rating
ChargeAdjustment → CreditNote"] + end + LEDGER["Billing Ledger — Adjustments (Slice 3)
credit notes (S3) · debit notes (S4) · refunds (S5) · governance"] + REC["reconciliation-export (Slice 7)"] + S1ENG --> LEDGER + PSP --> LEDGER + TAX --> LEDGER + RAT --> LEDGER + LEDGER --> REC +``` + +**Consumed (call-driven — delivered via REST calls, not a bus; C3 / README "Ingestion model"):** posted invoice + recognized/deferred state + owning `recognition_schedule` (Slices 1/4); `payment_settlement`/`payment_allocation` (Slice 2, for refund caps); approved `Refund` with `phase ∈ {initiated, confirmed, rejected, voided}` — **Payments calls the refund endpoint** (the fifth phase `unknown_final` is a ledger-side dual-control disposition, not a PSP event — see [Record Refund Phase](#record-refund-phase)); `TaxBreakdown` (authoritative — never recomputed) carried **inline on the posting request**; `ChargeAdjustment` — **a Rating-side adapter maps it to `POST /credit-notes`** (mapping owned upstream; never a re-rate in the ledger). **Produced:** compensating journal entries, refund-clearing / negative-tax / write-off-attempt alarms. + +```mermaid +flowchart TB + API["Adjustments API (REST, RFC 9457)"] + subgraph engine["Slices 1/2/4 (inherited)"] + S1["PostingService · IdempotencyGate · BalanceProjector · MoneyModule · ReversalService · ScheduleBuilder · total lock order"] + end + subgraph s3["Slice 3 handlers"] + CN["CreditNoteHandler (S3)"] + DN["DebitNoteHandler (S4)"] + RF["RefundHandler (S5, two-stage)"] + SPLIT["RecognizedDeferredSplitter"] + GOV["ManualAdjustmentGovernor"] + end + DB[("PostgreSQL
+ invoice_exposure · payment_allocation_refund · credit_note · debit_note · refund")] + API --> S1 + S1 --> CN & DN & RF + CN --> SPLIT + GOV --> S1 + CN & DN & RF --> DB + RF -. quarantine refund-before-payment .-> DB +``` + +All handlers (and governed manual postings via `GOV`) post **through** the inherited `PostingService` (atomic, lock-ordered, idempotent, append-only). + +## 2. Actor Flows (CDSL) + +### Post Credit Note + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-credit-note` + +**Actor**: `cpt-cf-bss-ledger-actor-billing-orchestration` (also `cpt-cf-bss-ledger-actor-rating-subscriptions` via the upstream ChargeAdjustment adapter) + +**Success Scenarios**: +- Credit note against a posted invoice posts a single balanced compensating entry (contra-revenue + contract-liability + tax + AR/wallet), reduces the recognition schedule, and records the split basis +- Credit note on a fully-paid invoice credits `AR` up to open AR and the remainder to `REUSABLE_CREDIT` in the same entry +- AR-only goodwill credit debits `GOODWILL` bounded by the `ar_invoice_balance` floor + +**Error Scenarios**: +- Originating posted invoice absent → 404 (generic resource not-found; **no distinct wire `code`** — the platform CanonicalError model has no code slot on a 404, see the Problem-responses note) +- Recognized/deferred split indeterminable → 422 `CREDIT_NOTE_SPLIT_AMBIGUOUS` + exception-queue row (no silent pro-rata) +- Over the headroom/cumulative cap → 422 `CREDIT_NOTE_EXCEEDS_HEADROOM` +- Above the D2 threshold without approval → 409 `DUAL_CONTROL_REQUIRED` +- Same idempotency key with conflicting payload → hard error + secured-audit capture (AC #19) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/credit-notes (body: `credit_note_id`, origin invoice + item ref, `revenue_stream`, amount incl. tax, inline `TaxBreakdown`, `reason_code`, approver ref where required) - `inst-cn-api` +2. [ ] - `p1` - Authorize billing-poster scope; **IF** amount incl. tax ≥ the D2 dual-control threshold **AND** no approver (preparer ≠ approver) **RETURN** 409 `DUAL_CONTROL_REQUIRED` - `inst-cn-dual-control` +3. [ ] - `p1` - Idempotency: claim `(tenant, CREDIT_NOTE, credit_note_id)` — identical replay returns the prior reference; conflicting payload hard-errors + secured-audit capture (AC #19) - `inst-cn-idem` +4. [ ] - `p1` - DB: resolve the originating posted invoice; **IF** absent **RETURN** 404 (generic resource not-found — **no distinct wire `code`**; MUST link the originating posted invoice; preserve PO/recognition-schedule lineage) - `inst-cn-origin` +5. [ ] - `p1` - Algorithm: derive the recognized/deferred split via `cpt-cf-bss-ledger-algo-credit-note-recognized-deferred-split`; **IF** ambiguous **RETURN** 422 `CREDIT_NOTE_SPLIT_AMBIGUOUS` + `exception_queue` row of type `SPLIT_AMBIGUOUS` (Slice 7) - `inst-cn-split` +6. [ ] - `p1` - Algorithm: enforce the headroom/cumulative cap via `cpt-cf-bss-ledger-algo-credit-note-headroom-cap`; **IF** over cap **RETURN** 422 `CREDIT_NOTE_EXCEEDS_HEADROOM` - `inst-cn-headroom` +7. [ ] - `p1` - Compose the compensating entry (strict posting shape; zero-amount Contract-liability placeholder lines are **rejected** at post-time validation, inherited S1 / AC #4): DR `CONTRA_REVENUE` (reduce recognized revenue, ex-tax) · DR `CONTRACT_LIABILITY` (reduce unreleased deferred, ex-tax) · DR `TAX_PAYABLE` (reverse tax) · CR `AR` (incl. tax, up to current open AR) · CR `REUSABLE_CREDIT` (remainder beyond open AR on a paid invoice, `credit_grant_event_type = CREDIT_NOTE`) - `inst-cn-compose` +8. [ ] - `p1` - **IF** AR-only goodwill (no revenue restatement): debit `GOODWILL` (D3) instead — MUST NOT use `CONTRA_REVENUE` when no recognized revenue is reduced; not bad-debt; bounded by the `ar_invoice_balance` NO-negative floor (a goodwill credit exceeding open AR is **rejected**, never walleted) - `inst-cn-goodwill` +9. [ ] - `p1` - Same ACID txn under the unified lock order: post via `PostingService`; reduce the owning `recognition_schedule` (deferred portion); update `invoice_exposure.credit_note_total_minor`; seed the wallet sub-grain (`reusable_credit_subbalance`, Slice 2) for any `REUSABLE_CREDIT` remainder - `inst-cn-txn` +10. [ ] - `p1` - DB: insert the `credit_note` row (amounts, recognized/deferred parts, `split_basis_ref`, `reason_code`) — **never** change posted invoice rows (compensating only) - `inst-cn-record` +11. [ ] - `p1` - Outbox: `billing.ledger.credit_note.posted` - `inst-cn-event` +12. [ ] - `p1` - **RETURN** 201 Created (credit note + entry reference) - `inst-cn-return` + +### Post Debit Note + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-debit-note` + +**Actor**: `cpt-cf-bss-ledger-actor-billing-orchestration` + +**Success Scenarios**: +- Additional charge after a posted invoice posts the S1-mirroring direct split; a deferred portion builds a recognition schedule in the same atomic unit (D4); the headroom cap is raised + +**Error Scenarios**: +- Originating posted invoice absent → 404 (generic resource not-found; **no distinct wire `code`** — the platform CanonicalError model has no code slot on a 404, see the Problem-responses note) +- Payer closed → 422 `PAYER_CLOSED` (debit notes inherit the Foundation payer-close gate) +- Conflicting idempotent replay → hard error + secured-audit capture (AC #19) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/debit-notes (body: `debit_note_id`, origin invoice ref, amount incl. tax, inline `TaxBreakdown`, PO/deferral context) - `inst-dn-api` +2. [ ] - `p1` - Idempotency: claim `(tenant, DEBIT_NOTE, debit_note_id)` (AC #19 replay contract) - `inst-dn-idem` +3. [ ] - `p1` - DB: resolve the originating posted invoice (MUST link business context); **IF** absent **RETURN** 404 (generic resource not-found — **no distinct wire `code`**); **IF** payer closed **RETURN** 422 `PAYER_CLOSED` - `inst-dn-origin` +4. [ ] - `p1` - Validate posted tax evidence: MUST carry `TaxBreakdown` → `TAX_PAYABLE`, never recomputed - `inst-dn-tax` +5. [ ] - `p1` - Compose the direct split (mirrors S1; no zero-placeholder CL line; if fully recognized, no CL line): DR `AR` (incl. tax) · CR `REVENUE` (recognized at post) · CR `CONTRACT_LIABILITY` (deferred per PO, if any) · CR `TAX_PAYABLE` - `inst-dn-compose` +6. [ ] - `p1` - **IF** a deferred CL credit exists: trigger the Slice 4 `ScheduleBuilder` **in the same atomic unit (D4)** so the new deferred balance is immediately recognizable (no stuck liability) - `inst-dn-schedule` +7. [ ] - `p1` - Same ACID txn: post via `PostingService` (balanced-or-rollback; **never** change posted rows); raise the headroom: `invoice_exposure.debit_note_total_minor += amount` - `inst-dn-txn` +8. [ ] - `p1` - DB: insert the `debit_note` row; Outbox: `billing.ledger.debit_note.posted` - `inst-dn-record` +9. [ ] - `p1` - **RETURN** 201 Created - `inst-dn-return` + +### Record Refund Phase + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-refund` + +**Actor**: `cpt-cf-bss-ledger-actor-payments-psp` (phases `initiated`/`confirmed`/`rejected`/`voided`); `cpt-cf-bss-ledger-actor-finance-ops` + `cpt-cf-bss-ledger-actor-finance-approver` (ledger-side dual-control `unknown_final` disposition) + +**Success Scenarios**: +- Stage-1 initiation posts Pattern A (unallocated/on-account) or Pattern B (restore AR) against `REFUND_CLEARING`, consuming the caps before money leaves +- Stage-2 PSP confirmation drains `REFUND_CLEARING` to `CASH_CLEARING` +- PSP `rejected`/`voided` line-negates stage-1 and frees the caps +- Refund-of-refund posts a new S5 entry linked via `relates_to_refund_id` with counter effect by economic direction + +**Error Scenarios**: +- Over the aggregate money-out cap → 422 `REFUND_EXCEEDS_SETTLED` +- Over the per-`(payment, invoice)` cap (or no allocation counter row) → 422 `REFUND_EXCEEDS_ALLOCATED` +- Above the D2 threshold without approval → 409 `DUAL_CONTROL_REQUIRED` +- Refund before the original payment → 202 Accepted with body status token `refund-quarantined` (never posted) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/refunds (body: `psp_refund_id`, `phase`, `pattern` (A_UNALLOCATED | B_RESTORE_AR), **mandatory** origin `payment_id` + `currency` — both patterns, `invoice_id` (required for Pattern B), `amount_minor`, optional `relates_to_refund_id`) - `inst-rf-api` +2. [ ] - `p1` - Idempotency: claim `(tenant, REFUND, psp_refund_id:phase)` — `business_id = psp_refund_id:phase`, single string in the Slice 1 `(tenant_id, flow, business_id)` PK (shape unchanged) - `inst-rf-idem` +3. [ ] - `p1` - **IF** the origin payment is unresolvable (refund before the original payment): quarantine via `cpt-cf-bss-ledger-algo-refund-quarantine`; **RETURN** 202 Accepted, body token `refund-quarantined` + correlation handle (no SCREAMING_SNAKE code on a 202) - `inst-rf-quarantine` +4. [ ] - `p1` - **IF** amount ≥ the D2 dual-control threshold **AND** no approval **RETURN** 409 `DUAL_CONTROL_REQUIRED` (USD-equivalent comparison per D2) - `inst-rf-dual-control` +5. [ ] - `p1` - **IF** `phase = initiated` (stage-1): consume caps via `cpt-cf-bss-ledger-algo-refund-cap-lifecycle` (caps enforced **before** money leaves); over-cap **RETURN** 422 `REFUND_EXCEEDS_SETTLED` / `REFUND_EXCEEDS_ALLOCATED` - `inst-rf-stage1-caps` + 1. [ ] - `p1` - **IF** Pattern A (unallocated/on-account): DR `UNALLOCATED` (or `REUSABLE_CREDIT`) · CR `REFUND_CLEARING`. **No P&L.** The mandatory `payment_id` + `currency` resolve the origin `payment_settlement` row and exactly one `(tenant, payer, currency)` pool row — no pooled ambiguity - `inst-rf-pattern-a` + 2. [ ] - `p1` - **IF** Pattern B (after allocation, restore AR): DR `AR` · CR `REFUND_CLEARING`. **No** Revenue/Contra. **Refund lines never debit `CONTRACT_LIABILITY`** — unreleased-deferred restatement on a refunded invoice is carried by a paired S3 credit note, not the refund journal (PRD) - `inst-rf-pattern-b` +6. [ ] - `p1` - **IF** `phase = confirmed` (stage-2): **IF** an OPEN dispute exists on the origin payment, hold stage-2 in `pending_event_queue` until the dispute resolves; **ELSE** post DR `REFUND_CLEARING` · CR `CASH_CLEARING` (single-step `…/Cash` only under D1) - `inst-rf-stage2` +7. [ ] - `p1` - **IF** `phase ∈ {rejected, voided}` after stage-1: reverse stage-1 via **strict line-negation** (`reverses_entry_id`), restoring the pre-initiation state, clearing `REFUND_CLEARING`, and **decrementing** the cap counters in the same txn; idempotent per `(tenant, psp_refund_id, phase)` - `inst-rf-reject` +8. [ ] - `p1` - **IF** `phase = unknown_final` (— terminal, **ledger-side dual-control disposition**, not a PSP event): clear the open `REFUND_CLEARING` amount to a **documented loss line** + write a `secured_audit_record` (Slice 6). **(Impl note 2026-06-29 — deferred:** the interim implementation **parks the stuck clearing to `SUSPENSE`** with the secured-audit record and leaves the money-out cap consumed; **terminal loss attribution to a documented loss line is a deferred governed step** (a Slice-7+ exception-queue disposition), not yet posted.**)** - `inst-rf-unknown-final` +9. [ ] - `p1` - **IF** `relates_to_refund_id` set (refund-of-refund): apply counter effect by economic direction via `cpt-cf-bss-ledger-algo-refund-of-refund-direction` (🔄) - `inst-rf-of-rf` +10. [ ] - `p1` - DB: upsert the `refund` row (phase, pattern, clearing state, links); partial/split refunds hit the correct balances, each balanced + idempotent - `inst-rf-record` +11. [ ] - `p1` - Outbox: `billing.ledger.refund.recorded` (`phase`, incl. `unknown_final`) - `inst-rf-event` +12. [ ] - `p1` - **RETURN** 200/201 (refund record + clearing state) - `inst-rf-return` + +### Post Refund with Paired Credit Note + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-refund-with-credit-note` + +**Actor**: `cpt-cf-bss-ledger-actor-billing-orchestration` / `cpt-cf-bss-ledger-actor-payments-psp` + +**Success Scenarios**: +- Where the refund pairs with a revenue/tax restatement, the atomic composite posts the S5 refund and the paired S3 credit note in **one transaction as two linked entries** — AR is never overstated between the two + +**Error Scenarios**: +- Any component failing its own validation (headroom, split, caps, dual-control) fails the whole composite — both entries commit or neither + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/refund-with-credit-note (body: full refund payload + full credit-note payload) - `inst-rwcn-api` +2. [ ] - `p1` - Idempotency: claim **both** component keys (`psp_refund_id:phase` + `credit_note_id`) **atomically** - `inst-rwcn-idem` +3. [ ] - `p1` - Run the credit-note validations (`cpt-cf-bss-ledger-flow-credit-note` steps) and the refund validations (`cpt-cf-bss-ledger-flow-refund` steps) - `inst-rwcn-validate` +4. [ ] - `p1` - Post both entries in **one transaction**, two linked entries — the refund carries the paired `credit_note_id`; **RETURN** 201 on commit, or the failing component's problem response with nothing committed - `inst-rwcn-commit` + +### Post Governed Manual Adjustment + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-manual-adjustment` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-ops` (preparer), `cpt-cf-bss-ledger-actor-finance-approver` (approver; preparer ≠ approver) + +**Success Scenarios**: +- A defined business action posts a governed manual adjustment constrained to the allow-listed account-class set, with reason code + actor recorded (AC #14), satisfying the Slice 1 invariants (balanced, append-only, idempotent) + +**Error Scenarios**: +- Account class outside the allow-list, or a write-off/revenue-touching shape → 422 `MANUAL_ADJUSTMENT_NOT_ALLOWED`; attempted-write-off shape additionally captures the actor + pages +- Missing dual-control on a high-risk/over-threshold action → 409 `DUAL_CONTROL_REQUIRED` + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/manual-adjustments (body: action type, lines, reason, approver ref) — - `inst-ma-api` +2. [ ] - `p1` - Idempotency: claim `(tenant, MANUAL_ADJUSTMENT, adjustmentId)` - `inst-ma-idem` +3. [ ] - `p1` - Algorithm: validate governance via `cpt-cf-bss-ledger-algo-manual-adjustment-governance` (named business action, allow-listed classes, segregation of duties, thresholds, mandatory reason code + actor, attempted-write-off structural guard) - `inst-ma-governance` +4. [ ] - `p1` - **IF** rejected as attempted write-off: **RETURN** 422 `MANUAL_ADJUSTMENT_NOT_ALLOWED`, capture actor, raise the `attempted-write-off` Critical alarm + page Revenue Assurance / Finance Ops (PRD) - `inst-ma-reject` +5. [ ] - `p1` - Post through the inherited `PostingService` (balanced, append-only, idempotent); Outbox: `billing.ledger.manual_adjustment.posted` (·) - `inst-ma-post` +6. [ ] - `p1` - **RETURN** 201 Created - `inst-ma-return` + +### Read Exposure and Refund State + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-flow-adjustment-inquiry` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-ops` + +**Success Scenarios**: +- Remaining credit-note headroom + true remaining AR read from the exposure/balance caches; refund + clearing state read by id + +**Steps**: +1. [ ] - `p2` - API: GET /v1/ledger/invoices/{invoiceId}/exposure — cache read of `invoice_exposure` + `ar_invoice_balance`; the headroom GET reflects **true remaining AR** (goodwill floor, D3) - `inst-inq-exposure` +2. [ ] - `p2` - API: GET /v1/ledger/refunds/{refundId} — read the `refund` row + clearing state - `inst-inq-refund` +3. [ ] - `p2` - **RETURN** 200 (exposure / refund read models) - `inst-inq-return` + +## 3. Processes / Business Logic (CDSL) + +### Recognized vs Deferred Split and Schedule Reduction + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-credit-note-recognized-deferred-split` + +**Input**: credit-note target (posted invoice item, PO/allocation group), effective time +**Output**: recognized vs unreleased-deferred split (per revenue stream) + recorded `split_basis_ref`, or block-on-ambiguous + +**Steps**: +1. [ ] - `p1` - Derive the split (`RecognizedDeferredSplitter`) from the targeted posted invoice item, PO/allocation group, and **recognition-schedule state** (Slice 4) at the credit-note effective time - `inst-split-derive` +2. [ ] - `p1` - **IF** the split is ambiguous: **RETURN** block — RFC 9457 code `CREDIT_NOTE_SPLIT_AMBIGUOUS` + an `exception_queue` row of type `SPLIT_AMBIGUOUS` (Slice 7), **no silent pro-rata** (PRD) - `inst-split-ambiguous` +3. [ ] - `p1` - **FOR EACH** revenue stream on a multi-stream invoice item: split the deferred reduction **per revenue_stream** so each `CONTRACT_LIABILITY` debit (and the schedule it reduces) keeps the **same stream** as the line it reduces (Slice 4 one-schedule-per-stream); **IF** the per-stream split is indeterminable, **block-on-ambiguous** - `inst-split-stream` +4. [ ] - `p1` - **Schedule reduction (critical).** When the credit note debits `CONTRACT_LIABILITY` for a deferred portion, in the **same ACID txn** **reduce the owning `recognition_schedule`**: decrement `total_deferred_minor` over the **not-yet-released remainder** (mirroring Slice 4 re-version semantics — already-released segments are never recomputed), or mark the schedule `REPLACED`/versioned. This guarantees a later S6 run cannot re-recognize the credited-back amount (remaining releasable = reduced Contract liability for that obligation) - `inst-split-schedule` +5. [ ] - `p1` - Bound the deferred-portion debit by the schedule's remaining releasable amount (`total_deferred_minor − recognized_minor`); Slice 4's `recognized_minor ≤ total_deferred_minor` `CHECK` — now also written by S3 under the lock order — is the authoritative guard against over-reducing an in-flight schedule. `recognition_schedule`/`recognition_segment` are acquired in the shared lock order. *(Slice 4 lists S3 credit notes as an authorized prospective-reduction trigger.)* - `inst-split-bound` +6. [ ] - `p1` - **RETURN** the split + record `split_basis_ref` (PO/allocation group + schedule state at effective time) on the `credit_note` row - `inst-split-return` + +### Credit-Note Headroom Cap + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-credit-note-headroom-cap` + +**Input**: tenant, invoice, credit-note amount (incl. tax) +**Output**: cap consumed under the lock order, or `CREDIT_NOTE_EXCEEDS_HEADROOM` + +**Steps**: +1. [ ] - `p1` - DB: seed `invoice_exposure.original_total_minor` **at first touch** = the invoice's posted AR (incl. tax) read from `journal_line`, via `INSERT … ON CONFLICT DO UPDATE` (Slice 1 first-touch upsert, so concurrent creators serialize, no duplicate-key) - `inst-cap-seed` +2. [ ] - `p1` - Enforce (AC #24): new credit note (incl. tax) ≤ `original_total + Σ S4 debit notes − Σ prior credit notes` — the DB `CHECK (credit_note_total_minor ≤ original_total_minor + debit_note_total_minor)` on the locked `invoice_exposure` row, under the lock order (never handler-only) - `inst-cap-check` +3. [ ] - `p1` - **IF** over cap **RETURN** `CREDIT_NOTE_EXCEEDS_HEADROOM` (422) — routes via goodwill/non-revenue or out-of-scope, never silently through S3 - `inst-cap-reject` +4. [ ] - `p1` - The cap is unchanged by the split credit leg — it bounds total credit-note exposure regardless of the AR/wallet leg split. **Ratified 2026-06-15:** remainder → wallet (`REUSABLE_CREDIT`); converting it to **cash** is a **separate S5 withdrawal on the customer's request, never automatic** (needs-discussion D9) - `inst-cap-k2` + +### Refund Cap Lifecycle + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-refund-cap-lifecycle` + +**Input**: refund stage-1 initiation or stage-1 reversal; origin `payment_id` + `currency` (both patterns) +**Output**: cap counters consumed/freed atomically with the posting + +**Steps**: +1. [ ] - `p1` - Resolve the origin `payment_settlement` row from the **mandatory** `payment_id` + `currency` (PSP refunds always reference an origin transaction; confirmation pending, needs-discussion D7) - `inst-rcap-resolve` +2. [ ] - `p1` - At **stage-1 initiation**, under the rank-1 `payment_settlement` row lock with `CHECK`s evaluated post-delta (caps enforced **before** money leaves): - `inst-rcap-stage1` + 1. [ ] - `p1` - **Both patterns**: increment `refunded_minor` — total money-out cap `CHECK (refunded_minor + clawed_back_minor ≤ settled_minor)` (`clawed_back_minor` maintained by Slice 2's ChargebackHandler) - `inst-rcap-both` + 2. [ ] - `p1` - **Pattern A**: additionally increment `refunded_unallocated_minor` — spendable headroom `CHECK (allocated_minor + refunded_unallocated_minor ≤ settled_minor)` (a refunded payment's cash can never also be allocated) - `inst-rcap-a` + 3. [ ] - `p1` - **Pattern B**: additionally increment the per-`(payment,invoice)` counter `payment_allocation_refund.refunded_minor` (`CHECK ≤ allocated_minor`); a missing counter row means "nothing allocated" → `REFUND_EXCEEDS_ALLOCATED` - `inst-rcap-b` +3. [ ] - `p1` - A **stage-1 reversal** (PSP `rejected`/`voided`) **decrements** the same counters in the same txn that line-negates stage-1 and clears `REFUND_CLEARING` - `inst-rcap-reversal` +4. [ ] - `p1` - Counters are maintained via the Foundation `applyCounterDelta` primitive — no DDL on the Slice-2-owned tables - `inst-rcap-primitive` + +### Refund-of-Refund Direction + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-refund-of-refund-direction` + +**Input**: a new S5 entry with its **own** `psp_refund_id` and full phase lifecycle, linked via `relates_to_refund_id` (records the relationship — **NOT** a strict line-negation; canonical key shape unchanged, PRD) +**Output**: money-out counter effect **set by economic direction, not the lifecycle template** (🔄) + +**Steps**: +1. [ ] - `p1` - **IF** claw-back (canonical, money returns to the merchant): **decrement** the origin `payment_settlement.refunded_minor` (and `refunded_unallocated_minor` for a Pattern-A origin; `payment_allocation_refund.refunded_minor` for Pattern-B) under the rank-1 lock, in the posting txn, so the total money-out cap (`refunded_minor + clawed_back_minor ≤ settled_minor`) reflects **net** refunded and a legitimate claw-back never spuriously trips it; `REFUND_CLEARING` drains in the reverse direction - `inst-rofr-clawback` +2. [ ] - `p1` - **IF** additional outbound refund (money leaves again): **increment** as a normal stage-1 refund, subject to the same cap - `inst-rofr-outbound` +3. [ ] - `p1` - **Out-of-order / excess claw-back (deferred, never hard-fail).** **IF** the decrement **would take a money-out counter below zero** — the PSP claw-back event arrives **before/without** its matching prior outbound-refund stage-1 (so `refunded_minor`/`refunded_unallocated_minor`/`payment_allocation_refund.refunded_minor` has not yet been incremented), or claws back **more than was refunded** — MUST **NOT** apply the decrement and MUST **NOT** hard-abort the post on the `CHECK (… ≥ 0)`. **Defer** via the existing out-of-order mechanism: persist the payload in Slice 2's `pending_event_queue` and **retry** once the matching outbound-refund stage-1 lands and the decrement no longer underflows - `inst-rofr-underflow` +4. [ ] - `p1` - **IF** a claw-back never reconciles past the aging threshold: **escalate to the `exception_queue`** (Slice 7; additive type `CLAWBACK_UNDERFLOW`) + Finance/Revenue-Assurance alert — never auto-posted, never silently dropped. This mirrors how Slice 2 defers out-of-order money-out events (e.g. `CHARGEBACK_ON_REFUNDED`) rather than aborting. The `CHECK (refunded_minor ≥ 0)` **remains as defense-in-depth** — it should never fire in normal flow precisely because an underflowing claw-back is **deferred, not applied** - `inst-rofr-escalate` +5. [ ] - `p1` - **Open (needs-discussion D8):** which of the two the PSP's refund-of-refund event represents is ⏳ to be confirmed with Payments; the canonical default is **claw-back/decrement** - `inst-rofr-open` + +### Tax and Revenue-Adjustment Routing + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-adjustment-tax-routing` + +**Input**: an adjustment scenario (commercial revenue, goodwill, cash return, time-based) +**Output**: the correct flow (S3/S4/S5/S6) + tax posting dimensions + +Routing table: + +| Scenario | Path | +|----------|------| +| Commercial revenue (pricing/usage) | **S3** — Contra-revenue (recognized) + Contract liability (unreleased) + AR + tax | +| Goodwill / AR-only (no P&L restate) | **non-revenue debit** (`GOODWILL`); never Contra-revenue/bad-debt | +| Cash return to customer | **S5** (A/B); P&L via paired **S3** as needed | +| Time-based (deferral release) | **S6** (Slice 4) | + +**Steps**: +1. [ ] - `p1` - Route tax: commercial revenue + its tax = S3/S4; cash+PSP-only = S5; **tax-only** adjustments via S3/S4 with `TaxBreakdown` (never an orphan suspense line) - `inst-tax-route` +2. [ ] - `p1` - Mid-period rate change decomposes to **mixed-rate lines**; S3/S4 tax lines carry **rate + jurisdiction + filing-period** dimensions so posted tax stays **disaggregable per `(rate, jurisdiction, filing-period)`** for filing reconciliation (PRD) - `inst-tax-dims` +3. [ ] - `p1` - Compute a note's `TaxBreakdown` for the **original** invoice's **tax date** (rate/jurisdiction actually charged), not the current rate — it reverses/extends the tax that was charged, so a later rate change never alters the note's tax (refines the policy-version split for the tax dimension). Per-jurisdiction rounding granularity is owned by the tax engine; the ledger accepts `TaxBreakdown` as authoritative - `inst-tax-date` +4. [ ] - `p1` - **Tax sub-balance guard (inherited from Slice 1, activated here).** S3/S4/S5 are the first flows that **debit** `TAX_PAYABLE`. Per Slice 1's deferred decision: in-window negatives are **permitted** (a legitimate reversal); a `tax_subbalance` negative **beyond** the `(jurisdiction, filing-period)` filing window (fallback tenant fiscal period) MUST **alarm Revenue Assurance** per AC #17 / PRD - `inst-tax-guard` +5. [ ] - `p1` - **S3+S5 pairing.** Where a refund accompanies a revenue/tax restatement, the **atomic composite** is the default shape and the S5 record carries the paired `credit_note_id`; the reconciliation control alarms on an S5-without-paired-S3 **in all jurisdictions whenever the refund's business reason is a product credit** — not only where tax-on-refund applies (Slice 7 recon) - `inst-tax-pairing` + +### Manual-Adjustment Governance + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-manual-adjustment-governance` + +**Input**: a governed manual posting request (action type, lines, reason, actor, approver) +**Output**: pass, or reject (+ capture + page on attempted write-off) + +**Steps**: +1. [ ] - `p1` - **Free-form GL is out of scope** (PRD). Only **governed** adjustments post outside the automated S1–S6 paths, constrained to an **allow-listed account-class set** (suspense, rounding, the named governed actions) - `inst-gov-allowlist` +2. [ ] - `p1` - A governed manual posting **MUST NOT** directly debit `REVENUE` or credit/debit `CONTRACT_LIABILITY` outside S3/S4/S6 (revenue-affecting changes route through the S3/S4 builders) - `inst-gov-revenue` +3. [ ] - `p1` - **(/ S3-minor) Allow-list ownership:** in MVP the allow-list (account-class set + named actions) is **code** — every new governed action is a deliberate, reviewed **deploy** (smallest, safest surface for a financial guard; the set changes rarely). A data/config-driven allow-list and its **meta-governance** (who may edit the allow-list, dual-control on that edit) is **deferred** - `inst-gov-ownership` +4. [ ] - `p1` - Enforce per defined business action: **segregation of duties** (preparer ≠ approver), amount/entity **approval thresholds**, **dual-control** for high-risk actions, and a **mandatory reason code + actor** (AC #14) - `inst-gov-sod` +5. [ ] - `p1` - **Attempted write-off guard:** any attempt to use `CONTRA_REVENUE` with no recognized-revenue reduction, or to zero AR via a non-goodwill path, MUST **reject + capture actor + page** Revenue Assurance / Finance Ops (PRD, Critical alarm) - `inst-gov-writeoff` +6. [ ] - `p1` - **(/ S3-minor) Detection (structural).** A `CONTRA_REVENUE` line is legitimate **only** when the **same entry** (or its linked original via the note's reference) also **reduces recognized `REVENUE` for the same `revenue_stream`** — i.e. the contra is paired with a real revenue reduction. A `CONTRA_REVENUE` that reduces **AR without a paired revenue reduction** (a disguised bad-debt write-off, out of scope) is the attempted-write-off shape → reject + capture + page. The check is on the **composition of the entry's legs** (contra-paired-with-revenue vs contra-against-AR-only), evaluated at post time — not a balance lookup - `inst-gov-structural` +7. [ ] - `p1` - Governed postings still satisfy the Slice 1 invariants (balanced, append-only, idempotent) - `inst-gov-invariants` + +### Refund-Before-Payment Quarantine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-refund-quarantine` + +**Input**: a refund whose original payment is unresolvable +**Output**: quarantined payload (never posted) + later de-quarantine re-validation + +**Steps**: +1. [ ] - `p1` - A **refund before the original payment** has no resolvable original → **quarantine**, **never post** (PRD) — distinct from Slice 2's *queue-and-apply* - `inst-q-quarantine` +2. [ ] - `p1` - DB: persist the quarantined payload in Slice 2's `pending_event_queue` (PII-free payload + PSP correlation id) — no in-memory-only holds - `inst-q-persist` +3. [ ] - `p1` - **De-quarantine re-validates**: all caps ([Caps, Lock Order and Out-of-Order](#caps-lock-order-and-out-of-order)), the **then-current** D2 dual-control threshold, and dispute state; over-threshold items route to approval — **never auto-post** - `inst-q-revalidate` +4. [ ] - `p1` - API response for the quarantined intake: **202 Accepted** with the kebab-case body status token **`refund-quarantined`** + correlation handle; **no SCREAMING_SNAKE code on a 202** — deferral-status convention shared with Slices 2/4 - `inst-q-response` + +### Caps, Lock Order and Out-of-Order + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-adjustment-caps-lock-order` + +**Input**: any Slice 3 posting touching guarded counters +**Output**: all guarded counters enforced by DB `CHECK` on a locked row (never handler-only), acquired in the single global lock order + +**Guarded counters**: +1. [ ] - `p1` - **Credit-note headroom:** `invoice_exposure.credit_note_total_minor ≤ original_total_minor + debit_note_total_minor` (AC #24); `original_total_minor` seeded at first touch via `INSERT … ON CONFLICT DO UPDATE` - `inst-lock-headroom` +2. [ ] - `p1` - **Aggregate money-out:** on `payment_settlement` — `allocated_minor + refunded_unallocated_minor ≤ settled_minor` (Pattern-A spendable headroom) **and** `refunded_minor + clawed_back_minor ≤ settled_minor` (total money-out; `clawed_back_minor` per Slice 2) - `inst-lock-moneyout` +3. [ ] - `p1` - **Per-`(payment,invoice)` refund:** `payment_allocation_refund.refunded_minor ≤ allocated_minor` (AC #6 — the dedicated counter; `payment_allocation` itself stays INSERT-only) - `inst-lock-perinvoice` +4. [ ] - `p1` - **AR floor for goodwill:** the Slice 1 `ar_invoice_balance` NO-negative `CHECK` (goodwill + S3 cannot jointly over-reduce AR); the headroom GET reflects true remaining AR - `inst-lock-arfloor` + +**Lock order & out-of-order**: +5. [ ] - `p1` - **Unified total lock order** (one global order across all slices; shared ranks unchanged — invoice before payer, recognition after balance caches): `payment_settlement` → `account_balance` → `ar_invoice_balance` → `ar_payer_balance` → `unallocated_balance` → `reusable_credit_subbalance` → `tax_subbalance` → `recognition_schedule` → `recognition_segment` → `invoice_exposure` → `payment_allocation_refund`, then by `(tenant_id, …key…)`. Slice 3's new ranks append **after** the shared/recognition tables, so a handler that locks both account-balance rows and recognition/exposure rows always acquires them in this single order (no inversion vs Slices 1/2/4) - `inst-lock-order` +6. [ ] - `p1` - **Out-of-order:** refund-before-payment quarantines per `cpt-cf-bss-ledger-algo-refund-quarantine`; underflowing claw-backs defer per `cpt-cf-bss-ledger-algo-refund-of-refund-direction` - `inst-lock-ooo` + +## 4. States (CDSL) + +### Refund Phase State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-refund-phase` + +**States**: initiated, confirmed, rejected, voided, unknown_final +**Initial State**: initiated (stage-1; caps consumed before money leaves) + +**Transitions**: +1. [ ] - `p1` - **FROM** initiated **TO** confirmed **WHEN** the PSP confirms settlement (stage-2 posts DR `REFUND_CLEARING` · CR `CASH_CLEARING`; held in `pending_event_queue` while an OPEN dispute exists on the origin payment) - `inst-rst-confirm` +2. [ ] - `p1` - **FROM** initiated **TO** rejected **WHEN** the PSP rejects — stage-1 is reversed via strict line-negation (`reverses_entry_id`), `REFUND_CLEARING` cleared, cap counters decremented - `inst-rst-reject` +3. [ ] - `p1` - **FROM** initiated **TO** voided **WHEN** the PSP voids — same reversal semantics as rejected - `inst-rst-void` +4. [ ] - `p1` - **FROM** initiated **TO** unknown_final **WHEN** the PSP can produce no final state — a terminal, ledger-side dual-control disposition, not a PSP event; clears open `REFUND_CLEARING` to a documented loss line + `secured_audit_record` (interim impl: parks to `SUSPENSE`, cap left consumed; loss attribution deferred) - `inst-rst-unknown` +5. [ ] - `p1` - Each phase transition is a distinct idempotency claim `(tenant, psp_refund_id, phase)`; `confirmed`, `rejected`, `voided`, `unknown_final` are terminal - `inst-rst-idem` + +### Refund Clearing State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-refund-clearing` + +**States**: PENDING, SETTLED, REVERSED +**Initial State**: PENDING (stage-1 initiation credits `REFUND_CLEARING`) + +**Transitions**: +1. [ ] - `p1` - **FROM** PENDING **TO** SETTLED **WHEN** stage-2 PSP confirmation debits `REFUND_CLEARING` and credits `CASH_CLEARING` — `REFUND_CLEARING` must drain to exactly zero, never negative (stage-1 `CR` → stage-2 `DR` keeps the cached balance ≥ 0 throughout) - `inst-rcs-settle` +2. [ ] - `p1` - **FROM** PENDING **TO** REVERSED **WHEN** the PSP rejects/voids stage-1 (line-negation) or the `unknown_final` disposition clears the stuck amount - `inst-rcs-reverse` +3. [ ] - `p1` - **WHILE** PENDING beyond the tenant aging threshold (default **7 days → Warn, 14 days → Page**, ratified 2026-06-10): raise the aging alarm; a stage-1 entry without a matching stage-2 or stage-1 reversal beyond the threshold **pages Revenue Assurance**, in addition to the aging alarm; the same thresholds raise the **close-blocking** exception `STUCK_REFUND_CLEARING` (additive `exception_queue.type`; close gate Slice 7); `REFUND_CLEARING` joins the Slice 7 Payments↔PSP reconciliation - `inst-rcs-aging` + +## 5. API Surface + +REST per `rest-api-design`, behind the inbound API gateway; money as `{amountMinor, currency, scale}`. The inherited AC #19 contract applies to all Slice 3 flows (`CREDIT_NOTE`/`DEBIT_NOTE`/`REFUND`/`MANUAL_ADJUSTMENT`): same key + identical payload → prior reference; same key + **conflicting** payload → hard-error + **secured-audit capture**. + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST` | `/v1/ledger/credit-notes` | Post a credit note against a posted invoice. | per `credit_note_id` | +| `POST` | `/v1/ledger/debit-notes` | Post a debit note (additional charge). | per `debit_note_id` | +| `POST` | `/v1/ledger/refunds` | Record a refund phase outcome (two-stage; incl. the dual-control `unknown_final` disposition). | `(tenant, psp_refund_id, phase)` | +| `POST` | `/v1/ledger/refund-with-credit-note` | Atomic composite: S5 refund + paired S3 credit note in **one transaction**, two linked entries. | both component keys (`psp_refund_id:phase` + `credit_note_id`) claimed atomically | +| `POST` | `/v1/ledger/manual-adjustments` | Post a **governed** manual adjustment: named business action, allow-listed account-class set only, preparer ≠ approver (dual-control above threshold), mandatory reason code + actor. Body: action type, lines, reason, approver ref. | per `(tenant, MANUAL_ADJUSTMENT, adjustmentId)` | +| `GET` | `/v1/ledger/invoices/{invoiceId}/exposure` | Read remaining credit-note headroom + true remaining AR. | cache read | +| `GET` | `/v1/ledger/refunds/{refundId}` | Read refund + clearing state. | — | + +**Problem responses (RFC 9457):** `CREDIT_NOTE_EXCEEDS_HEADROOM` (422), `CREDIT_NOTE_SPLIT_AMBIGUOUS` (422 → exception queue), `REFUND_EXCEEDS_SETTLED` (422), `REFUND_EXCEEDS_ALLOCATED` (422), a note whose originating invoice is absent → **404 generic resource not-found with NO distinct wire `code`** (the platform `CanonicalError` model has no `code` slot on a 404 — a machine code lives only on 400/409/429 category contexts; consumers distinguish this miss by the 404 on the note endpoint + `resource_type`, not a `NOTE_INVOICE_NOT_FOUND` string, which is therefore **retired** from this contract), `PAYER_CLOSED` (422 — debit notes inherit the Foundation payer-close gate), `DUAL_CONTROL_REQUIRED` (409), **`MANUAL_ADJUSTMENT_NOT_ALLOWED` (422 —: account class outside the allow-list, or a write-off/revenue-touching shape; rejection also triggers the capture + page where it matches the attempted-write-off guard)**. **Quarantine (non-problem):** refund-before-payment → **`202 Accepted`** with a kebab-case body status token **`refund-quarantined`** + correlation handle; **no SCREAMING_SNAKE code on a 202** — deferral-status convention shared with Slices 2/4. + +## 6. Data Model + +Adds `invoice_exposure`, `credit_note`, `debit_note`, `refund` (Slice-3-owned tables). The money-out counters it consumes — `refunded_minor` + `refunded_unallocated_minor` on `payment_settlement`, `refunded_minor` on `payment_allocation_refund` — are **declared by Slice 2** (Slice 2 owns those tables + `allocated_minor`/`clawed_back_minor` maintenance); Slice 3 **maintains** the refund counters via the Foundation `applyCounterDelta` primitive and adds **no DDL** to Slice 2's tables. Tenant-scoped RLS (C1). + +**`invoice_exposure`** (PK `(tenant_id, invoice_id)`): + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | `uuid` | PK part; RLS scope | +| `invoice_id` | `string` | PK part | +| `currency` | `char` | | +| `original_total_minor` | `bigint` | seeded at first touch = posted AR incl. tax (from `journal_line`, `INSERT … ON CONFLICT DO UPDATE`) | +| `debit_note_total_minor` | `bigint` | raised by each S4 debit note | +| `credit_note_total_minor` | `bigint` | `CHECK (credit_note_total_minor ≤ original_total_minor + debit_note_total_minor)` — the headroom cap (AC #24) | + +**`payment_allocation_refund`** (PK `(tenant_id, payment_id, invoice_id)`; created + `allocated_minor`-maintained by Slice 2's AllocationHandler): + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | `uuid` | PK part | +| `payment_id` | `string` | PK part | +| `invoice_id` | `string` | PK part | +| `allocated_minor` | `bigint` | maintained by Slice 2 at allocation time | +| `refunded_minor` | `bigint` | `CHECK (refunded_minor ≤ allocated_minor)` **and** `CHECK (refunded_minor ≥ 0)` (defense-in-depth against double / out-of-order decrement); incremented at refund stage-1, decremented on stage-1 reversal | + +**`credit_note`**: + +| Column | Type | Notes | +|--------|------|-------| +| `credit_note_id` | `uuid` | business id; `UNIQUE (tenant_id, )` | +| `tenant_id` | `uuid` | | +| `origin_invoice_id` | `string` | originating posted invoice (mandatory link) | +| `origin_invoice_item_ref` | `string` | | +| `revenue_stream` | `enum` | matches the line(s) it reduces | +| `currency` | `char` | | +| `amount_minor` | `bigint` | incl. tax | +| `recognized_part_minor` | `bigint` | | +| `deferred_part_minor` | `bigint` | | +| `split_basis_ref` | `string` | PO/allocation group + schedule state at effective time | +| `reason_code` | `string` | | + +**`debit_note`**: + +| Column | Type | Notes | +|--------|------|-------| +| `debit_note_id` | `uuid` | business id; `UNIQUE (tenant_id, )` | +| `tenant_id` | `uuid` | | +| `origin_invoice_id` | `string` | | +| `currency` | `char` | | +| `amount_minor` | `bigint` | incl. tax | +| `recognized_part_minor` | `bigint` | | +| `deferred_part_minor` | `bigint` | | + +**`refund`** (`UNIQUE (tenant_id, psp_refund_id, phase)`; `business_id = psp_refund_id:phase`): + +| Column | Type | Notes | +|--------|------|-------| +| `refund_id` | `uuid` | | +| `tenant_id` | `uuid` | | +| `psp_refund_id` | `string` | | +| `phase` | `enum` | `initiated \| confirmed \| rejected \| voided \| unknown_final` | +| `pattern` | `enum` | `A_UNALLOCATED \| B_RESTORE_AR` | +| `payment_id` | `string` | **MANDATORY both patterns** — origin payment; `NOT NULL` | +| `invoice_id` | `string` | null for Pattern A; required for Pattern B | +| `currency` | `char` | **MANDATORY both patterns**; `NOT NULL` | +| `amount_minor` | `bigint` | | +| `clearing_state` | `enum` | `PENDING \| SETTLED \| REVERSED` | +| `relates_to_refund_id` | `uuid` | refund-of-refund forward link; counter effect by economic direction — claw-back decrements | +| `reverses_entry_id` | `uuid` | ONLY for PSP-rejected stage-1 line-negation; distinct from `relates_to_refund_id` | + +Key constraints (beyond the column tables): + +- `payment_settlement` `CHECK (allocated_minor + refunded_unallocated_minor ≤ settled_minor)`, `CHECK (refunded_minor + clawed_back_minor ≤ settled_minor)`, `CHECK (refunded_minor ≥ 0)`, `CHECK (refunded_unallocated_minor ≥ 0)`; existing Slice 2 `CHECK`s stay; same stage-1-increment / reversal-decrement lifecycle (`clawed_back_minor` maintained by Slice 2). The `≥ 0` CHECKs are defense-in-depth: an underflowing claw-back/decrement is **deferred**, so these never fire in normal flow. +- `REFUND_CLEARING` is **credit-normal** and is part of the **Foundation's no-negative `account_balance` guarded set** — the Foundation declares the full set `CHECK (account_class NOT IN ('AR','CASH_CLEARING','UNALLOCATED','CONTRACT_LIABILITY','DISPUTE_HOLD','REFUND_CLEARING') OR balance_minor >= 0)` from the start, so there is **no cross-slice `ALTER`** and no guard-drop risk. Slice 3 only **posts to** the class via the Foundation API. `REUSABLE_CREDIT` stays **out** of the set (sub-grain-guarded). `REFUND_CLEARING` must drain to exactly zero and never go negative (stage-1 `CR` → stage-2 `DR`); aged unsettled balances alarm. +- `source_doc_type` / `flow` values `CREDIT_NOTE | DEBIT_NOTE | REFUND | MANUAL_ADJUSTMENT` (·) and `account_class` literals `CONTRA_REVENUE | REFUND_CLEARING | GOODWILL` are **Foundation-declared**; Slice 3 uses them (additive C2 enum: `CONTRA_REVENUE` debit-normal, `REFUND_CLEARING` credit-normal liability, `GOODWILL` non-revenue debit). +- **Unified lock order**: `recognition_schedule`/`recognition_segment` (shared with Slice 4) then `invoice_exposure` then `payment_allocation_refund`, appended after the shared balance caches — disjoint ranks, single global order. +- **No posted-invoice mutation:** all adjustments are new compensating entries (inherited append-only). + +## 7. Events & Alarms + +Success via the Slice 1 outbox: `billing.ledger.credit_note.posted`, `billing.ledger.debit_note.posted`, `billing.ledger.refund.recorded` (`phase`, incl. `unknown_final`), `billing.ledger.manual_adjustment.posted` (·). Alarms via the separate committed audit/alarm txn: `billing.ledger.invariant.alarm` with `alarmCategory ∈ {refund-clearing-aged, stage1-refund-orphan, credit-note-split-blocked, refund-quarantined, negative-tax-subbalance, attempted-write-off}`. PII-free. + +## 8. Definitions of Done + +### Credit Note Posting + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-credit-note-posting` + +The system **MUST** post credit notes as single balanced compensating entries (DR `CONTRA_REVENUE` / DR `CONTRACT_LIABILITY` / DR `TAX_PAYABLE` / CR `AR` up to open AR / CR `REUSABLE_CREDIT` remainder), with recognized/deferred split derivation, same-txn recognition-schedule reduction, split-basis recording, block-on-ambiguous, AR-only goodwill via `GOODWILL`, and the headroom/cumulative cap. + +**Implements**: +- `cpt-cf-bss-ledger-flow-credit-note` +- `cpt-cf-bss-ledger-algo-credit-note-recognized-deferred-split` +- `cpt-cf-bss-ledger-algo-credit-note-headroom-cap` + +**Touches**: +- API: `POST /v1/ledger/credit-notes` +- DB: `credit_note`, `invoice_exposure`, `recognition_schedule`, `reusable_credit_subbalance` +- Entities: `CreditNote`, `InvoiceExposure` + +### Debit Note Posting + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-debit-note-posting` + +The system **MUST** post debit notes as the S1-mirroring direct split (DR `AR` / CR `REVENUE` / CR `CONTRACT_LIABILITY` / CR `TAX_PAYABLE`) with mandatory posted tax evidence, the same-atomic-unit `ScheduleBuilder` hook for deferred portions (D4), and headroom raising on `invoice_exposure`. + +**Implements**: +- `cpt-cf-bss-ledger-flow-debit-note` +- `cpt-cf-bss-ledger-algo-adjustment-tax-routing` + +**Touches**: +- API: `POST /v1/ledger/debit-notes` +- DB: `debit_note`, `invoice_exposure`, `recognition_schedule` +- Entities: `DebitNote` + +### Refund Lifecycle and Clearing + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-refund-lifecycle` + +The system **MUST** record refund phase outcomes balance-first (Pattern A unallocated / Pattern B restore-AR), through the two-stage `REFUND_CLEARING` default, consuming aggregate + per-`(payment,invoice)` caps at stage-1 under the rank-1 lock, line-negating rejected/voided stage-1 with cap decrement, supporting the `unknown_final` dual-control disposition, refund-of-refund by economic direction, and the dispute hold on stage-2. + +**Implements**: +- `cpt-cf-bss-ledger-flow-refund` +- `cpt-cf-bss-ledger-algo-refund-cap-lifecycle` +- `cpt-cf-bss-ledger-algo-refund-of-refund-direction` +- `cpt-cf-bss-ledger-state-refund-phase` +- `cpt-cf-bss-ledger-state-refund-clearing` + +**Touches**: +- API: `POST /v1/ledger/refunds`, `GET /v1/ledger/refunds/{refundId}` +- DB: `refund`, `payment_settlement` (counters via `applyCounterDelta`), `payment_allocation_refund`, `pending_event_queue` +- Entities: `Refund` + +### Refund-with-Credit-Note Composite + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-refund-with-credit-note` + +The system **MUST** post the S5 refund and its paired S3 credit note in one transaction as two linked entries (the refund carries the paired `credit_note_id`), claiming both idempotency keys atomically, committing both entries or neither. + +**Implements**: +- `cpt-cf-bss-ledger-flow-refund-with-credit-note` + +**Touches**: +- API: `POST /v1/ledger/refund-with-credit-note` +- DB: `refund`, `credit_note`, `idempotency_dedup` +- Entities: `Refund`, `CreditNote` + +### Governed Manual Adjustments + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-manual-adjustment-governance` + +The system **MUST** restrict manual postings to governed, allow-listed business actions with segregation of duties, approval thresholds, dual-control, mandatory reason code + actor (AC #14), and the structural attempted-write-off guard (reject + capture + page). + +**Implements**: +- `cpt-cf-bss-ledger-flow-manual-adjustment` +- `cpt-cf-bss-ledger-algo-manual-adjustment-governance` + +**Touches**: +- API: `POST /v1/ledger/manual-adjustments` +- DB: `journal_entry`/`journal_line` (via `PostingService`), `secured_audit_record` +- Entities: `ManualAdjustment` + +### Guarded Counters and Slice Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-adjustment-guarded-counters` + +The system **MUST** enforce every cap as a DB `CHECK` on a locked row (never handler-only) — headroom, aggregate money-out, per-`(payment,invoice)` refund, AR floor — acquired in the unified total lock order with Slice 3 ranks appended after the shared/recognition tables. + +**Implements**: +- `cpt-cf-bss-ledger-algo-adjustment-caps-lock-order` +- `cpt-cf-bss-ledger-algo-credit-note-headroom-cap` +- `cpt-cf-bss-ledger-algo-refund-cap-lifecycle` + +**Touches**: +- DB: `invoice_exposure`, `payment_allocation_refund`, `payment_settlement`, `ar_invoice_balance` +- Entities: `InvoiceExposure`, `PaymentAllocationRefund` + +### Quarantine and Out-of-Order Handling + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-refund-quarantine` + +The system **MUST** quarantine refund-before-payment (persisted in `pending_event_queue`, 202 + `refund-quarantined` token, never posted), re-validate caps + then-current D2 threshold + dispute state on de-quarantine, defer underflowing claw-backs instead of aborting, and escalate never-reconciling claw-backs to `exception_queue` (`CLAWBACK_UNDERFLOW`). + +**Implements**: +- `cpt-cf-bss-ledger-algo-refund-quarantine` +- `cpt-cf-bss-ledger-algo-refund-of-refund-direction` + +**Touches**: +- API: `POST /v1/ledger/refunds` (202 path) +- DB: `pending_event_queue`, `exception_queue` +- Entities: `Refund` + +### Adjustment Inquiry Endpoints + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-dod-adjustment-inquiry` + +The system **MUST** expose the invoice exposure read (remaining headroom + true remaining AR) and the refund + clearing-state read as cache reads. + +**Implements**: +- `cpt-cf-bss-ledger-flow-adjustment-inquiry` + +**Touches**: +- API: `GET /v1/ledger/invoices/{invoiceId}/exposure`, `GET /v1/ledger/refunds/{refundId}` +- DB: `invoice_exposure`, `ar_invoice_balance`, `refund` +- Entities: `InvoiceExposure`, `Refund` + +## 9. Acceptance Criteria + +Delta over the Foundation testing architecture (same levels + mocking + concurrency policy inherited). + +Unit: + +- [ ] Recognized/deferred split + block-on-ambiguous + per-stream split; headroom-cap math; refund pattern A/B selection; S4 direct-split; tax routing + disaggregation dims; manual-adjustment allow-list + reason/actor + +Integration (testcontainers): + +- [ ] S3 reduces Contra-revenue + Contract liability + Tax + AR, never touches posted rows; **after an S3 deferred-portion credit, an S6 run cannot release the credited-back amount** (schedule `total_deferred` reduced) +- [ ] Headroom `CHECK` blocks over-cap (incl. after debit notes raise it); split-ambiguous **blocks** +- [ ] Goodwill uses `GOODWILL` and goodwill + S3 cannot jointly over-reduce AR (`ar_invoice_balance` floor) +- [ ] S4 deferral → schedule exists → S6 can release (D4) +- [ ] S5 Pattern A (no P&L) / B (restore AR), never debits Contract liability; two-stage Refund clearing + `REFUND_CLEARING` drains to zero (never negative) +- [ ] PSP-rejected stage-1 line-negated **and** caps decremented; aggregate + per-`(payment,invoice)` refund `CHECK`s block over-refund +- [ ] Refund-of-refund posts a new S5 with `relates_to_refund_id`; a **claw-back** refund-of-refund **decrements** the origin money-out counters (net refunded falls; the money-out cap is not spuriously tripped), while an **additional outbound** refund-of-refund increments under the cap +- [ ] **An out-of-order / excess claw-back whose decrement would drive a money-out counter below zero is DEFERRED to `pending_event_queue` (not aborted on the `CHECK (… ≥ 0)`) and applies on retry once the matching outbound-refund stage-1 lands** — and a never-reconciling claw-back escalates to `exception_queue` (`CLAWBACK_UNDERFLOW`) + alert +- [ ] Tax-subbalance negative beyond filing window alarms; attempted-write-off rejects + pages +- [ ] Pattern A refund consumes `refunded_unallocated` headroom — allocating the same payment's refunded cash blocks; credit note on a fully-paid invoice credits the `REUSABLE_CREDIT` remainder and seeds the wallet sub-grain; `unknown_final` dual-control disposition clears `REFUND_CLEARING` to a loss line + secured-audit record; refund-with-credit-note composite commits both entries or neither + +API: + +- [ ] RFC 9457 mapping for each code; idempotent-replay incl. **conflicting-payload hard-error + audit capture** for CREDIT_NOTE/DEBIT_NOTE/REFUND/MANUAL_ADJUSTMENT; refund-before-payment → 202 with body token `refund-quarantined` + +Ordering & exception: + +- [ ] Refund-before-payment **quarantines** (persisted in `pending_event_queue`); de-quarantine re-validates caps + the then-current D2 threshold + dispute state; stage-1-orphan + refund-clearing-aged alarms fire; aged clearing raises `STUCK_REFUND_CLEARING` + +Concurrency (unified lock order): + +- [ ] Concurrent credit notes on one invoice can't exceed headroom (serialize on `invoice_exposure`); concurrent refunds of one payment can't exceed settled nor race a concurrent allocation past the combined money-out caps (`payment_settlement`) nor a `(payment,invoice)` allocation (`payment_allocation_refund`); N concurrent stage-1 initiations summing over the cap → exactly one passes; a rejected stage-1 frees the cap to its pre-initiation value; cross-table deadlock-freedom with the appended ranks + +NFR verification: + +- [ ] (1) write p95 → load test; (2) `invoice_exposure` / `payment_settlement` hot-row load test (B3); (3) negative-tax / refund-clearing-aged / stage1-orphan alarms + +## 10. Non-Functional Considerations + +- **Performance / NFR mapping**: Inherits Slice 1 targets (B2 resolved 2026-06-10 via B11 — PRD draft committed as v1 SLOs, gated by the B3 load test; B3 remains open). Slice-3-specific: note/refund posts are single balanced entries (write p95 ≤ 500 ms); **negative-tax-subbalance (AC #17)**, refund-clearing aging, and stage-1-orphan are MUST-alarm (PRD § Observability). Availability/immutability inherited. Traces to `cpt-cf-bss-ledger-nfr-posting-performance`, `cpt-cf-bss-ledger-nfr-availability`. +- **Security & AuthZ**: Inherits Slice 1: RLS, append-only, PII-minimized events. Credit/debit notes, refunds, and governed manual adjustments require the billing-poster scope; refunds/notes above the D2 threshold and all governed manual postings require **dual-control** (segregation of duties); every manual posting carries reason code + actor (AC #14) and is constrained to the allow-listed account set. The ledger trusts Payments-module refund-approval provenance. +- **Observability / Feature metrics**: `ledger_credit_note_total` / `_blocked_total{reason}`, `ledger_debit_note_total`, `ledger_refund_total{phase,pattern}`, `ledger_refund_clearing_balance_minor` (gauge), `ledger_refund_clearing_aged_seconds`, `ledger_stage1_refund_orphan_total`, `ledger_refund_unknown_final_total`, `ledger_credit_note_headroom_reject_total`, `ledger_refund_quarantine_depth`, `ledger_negative_tax_subbalance_total`, `ledger_attempted_writeoff_total`. Thresholds wire to the NFR mapping + the alarms. +- **Risks & deferred work**: **Recognized/deferred split + schedule reduction** depends on Slice 4 schedule state being current at credit-note time; block-on-ambiguous is the safety net. **Hot rows** `invoice_exposure` (per invoice), `payment_settlement` / `payment_allocation_refund` (per payment) mirror earlier slices' contention; same load-test obligation (B3). **Deferred:** bad-debt/write-off/recovery (separate PRD); FX on cross-currency refunds (Slice 5 — [fx-multicurrency](./06-fx-multicurrency.md)); ERP export + jurisdiction net-presentation + S3+S5-pairing reconciliation (Slice 7). +- **Needs discussion** (inherits Slices 1/2/4 open items; slice-specific): + +| Item | Decision (default) | Status | Owner | +|------|--------------------|--------|-------| +| Two-stage vs single-step refund | two-stage (REFUND_CLEARING) default; single-step only when atomic | ✅ Accepted default | — | +| Dual-control threshold | default **≥ 1,000 USD-equivalent**, tenant range [100.. 1,000,000], out-of-range config **rejected** (no clamp); stricter default chosen over the proposed 10,000 | ✅ Ratified 2026-06-10 — duplicate rows merged | Finance | +| Goodwill/AR-only class + AR floor | non-revenue `GOODWILL`; AR floor = `ar_invoice_balance` CHECK | ✅ Accepted default | — | +| S4 deferred → schedule-build hook | S4 deferral triggers Slice 4 ScheduleBuilder in same txn | ✅ Accepted default | — | +| Refund-clearing aging threshold | tenant-policy window; default **7 days → Warn, 14 days → Page**; the same thresholds raise `STUCK_REFUND_CLEARING` | ✅ Ratified 2026-06-10 | Finance | +| `payment_allocation_refund` ownership | Slice 2 creates + maintains `allocated_minor` at allocation time; this feature consumes | ✅ Proposed default | — | +| Manual-adjustments API | `POST /v1/ledger/manual-adjustments`, allow-listed classes, dual-control, idempotent per adjustment id | ✅ Proposed default | — | +| Origin payment ref on money-out events | `payment_id` + `currency` mandatory on every refund — confirm PSP/bank refund/return events always carry the origin payment reference | ⏳ Confirm with Payments | Payments team | +| Refund-of-refund semantic | counter effect by economic direction — **canonical default = claw-back → decrement** money-out; additional-outbound increments. Confirm which the PSP refund-of-refund event represents | ⏳ Confirm with Payments | Payments team | +| Paid-invoice credit-note remainder | remainder → wallet (`REUSABLE_CREDIT`, `credit_grant_event_type = CREDIT_NOTE`); converting to **cash** = a separate S5 withdrawal on request, **never automatic**. | ✅ Ratified 2026-06-15 | Finance | diff --git a/gears/bss/ledger/docs/design/06-fx-multicurrency.md b/gears/bss/ledger/docs/design/06-fx-multicurrency.md new file mode 100644 index 000000000..4a00e9205 --- /dev/null +++ b/gears/bss/ledger/docs/design/06-fx-multicurrency.md @@ -0,0 +1,528 @@ + + + +# DESIGN — FX & Multi-Currency (Slice 5) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Trigger Unrealized Revaluation Run](#trigger-unrealized-revaluation-run) + - [Read Rate Snapshots and Functional Balances](#read-rate-snapshots-and-functional-balances) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Rate Snapshot and Lock Points](#rate-snapshot-and-lock-points) + - [Rate Source, Staleness and Fallback](#rate-source-staleness-and-fallback) + - [Dual-Currency Balance and Functional Translation](#dual-currency-balance-and-functional-translation) + - [Realized FX Gain/Loss](#realized-fx-gainloss) + - [Unrealized Revaluation Run](#unrealized-revaluation-run) +- [4. States (CDSL)](#4-states-cdsl) + - [Rate Snapshot State Machine](#rate-snapshot-state-machine) + - [Revaluation Entry State Machine](#revaluation-entry-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Functional Translation on Every Line](#functional-translation-on-every-line) + - [Rate Snapshot Store and Lock Points](#rate-snapshot-store-and-lock-points) + - [Rate Source Sync, Staleness and Fallback](#rate-source-sync-staleness-and-fallback) + - [Realized FX Posting](#realized-fx-posting) + - [Unrealized Revaluation and Reversal](#unrealized-revaluation-and-reversal) + - [FX Inquiry Endpoints](#fx-inquiry-endpoints) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +The ledger posts and reports in **multiple currencies**: each line carries its **transaction** amount and a **functional** translation at a **locked, snapshotted** rate; every entry balances in **both** columns; **realized FX** posts when a position closes at a different rate; **unrealized revaluation** is an optional, idempotent, next-period-reversed run; and rate-source failure/staleness is handled deterministically (PRD § Multi-currency and foreign exchange; AC #13/#18). + +**This feature removes the single-currency assumption** that Slices 1–4 carried (their money invariants were built FX-ready). It changes **no** existing transaction-currency posting shape; it adds the **functional-currency translation**, the realized-FX line, rate snapshots, and the realized/unrealized rules. + +**Traces to**: `cpt-cf-bss-ledger-fr-multi-currency-fx`, `cpt-cf-bss-ledger-fr-fx-rate-source-failure`, `cpt-cf-bss-ledger-fr-money-rounding-scale`, `cpt-cf-bss-ledger-fr-balanced-journal-entries`, `cpt-cf-bss-ledger-fr-posting-immutability`, `cpt-cf-bss-ledger-fr-reversal-canonical-pattern`, `cpt-cf-bss-ledger-fr-idempotency-per-flow`, `cpt-cf-bss-ledger-fr-accounting-periods-close`, `cpt-cf-bss-ledger-nfr-posting-performance` + +### 1.2 Purpose + +Give each tenant (legal entity) correct functional-currency books over multi-currency documents: a deterministic, auditor-reproducible locked rate on every posting, realized gain/loss exactly when a foreign-currency position closes, ASC 830 / IAS 21-compatible period-end remeasurement for Mode B tenants, and a deterministic answer when the rate source is unreachable or stale — never a silent rate re-pick and never a retro-translation of posted history. + +**Use cases**: `cpt-cf-bss-ledger-usecase-ledger-inquiry` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-bss-ledger-actor-billing-orchestration` | Posting flows (S1/S2/S3/S5/S6) whose entries get the functional translation + rate lock attached | +| `cpt-cf-bss-ledger-actor-payments-psp` | Settlement-side rate evidence; PSP/bank feed is the ratified fallback rate provider | +| `cpt-cf-bss-ledger-actor-finance-ops` | Triggers optional unrealized revaluation runs (finance scope); configures provider list / staleness policy | +| `cpt-cf-bss-ledger-actor-erp-gl` | Mode A: the ERP GL revalues (revaluation off); Mode B: BSS is ledger of record (revaluation default-on) | +| `cpt-cf-bss-ledger-actor-auditor` | Reads immutable rate snapshots; locked rates are deterministic + auditor-reproducible | +| `cpt-cf-bss-ledger-actor-revenue-assurance` | Receives missing/stale FX snapshot alarms | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — § Multi-currency and foreign exchange, § FX rate-source failure and staleness, AC #13/#18, Examples B/C, negative-Tax +- **Design**: [01-repository-foundation.md](./01-repository-foundation.md) — see §Component Model for the Foundation engine (`postBalancedEntry`, `applyBalanceDeltas`, `idempotencyClaim`, dual-column commit trigger, `BalanceProjector`, `TieOutJob`, outbox relay; read-then-write ordering via `SERIALIZABLE`/SSI) +- **Dependencies**: Foundation posting engine (Slice 1: `MoneyModule`, dual-column schema + commit trigger); payments-allocation (Slice 2: settlement / allocation / chargeback FX lock points; Unallocated carried functional value); adjustments-notes-refunds (Slice 3: refund realized FX; tax sub-grain — [adjustments-notes-refunds](./05-adjustments-notes-refunds.md)); asc606-recognition (Slice 4: recognition does not re-lock FX; schedule currency as posted); downstream reconciliation-export (Slice 7: FX consistency vs external rates) + +### 1.5 Scope + +**In scope** — FX scope per PRD AC #13/#18, § Multi-currency and foreign exchange, § FX rate-source failure and staleness, and Examples B/C: + +- transaction vs functional currency +- dual-currency balance +- rate snapshot + lock points (S1/S2/S6) +- realized vs unrealized FX +- deterministic FX rounding +- provider-unreachable + staleness + immutable snapshots + multi-provider fallback +- `FX_GAIN_LOSS`/`FX_UNREALIZED` +- the Slice-3 tax-sub-balance (`(jurisdiction, filing-period)` MAY-go-negative) FX interaction +- missing/stale FX snapshot alarms + +**Out of scope**: + +- **The FX rate provider itself** (integration, feeds) — external; the ledger **consumes** rates and snapshots them. Provider selection ratified 2026-06-10: **ECB primary, PSP/bank feed fallback** (needs-discussion F1). +- **Pricing-side FX / rate-lock governance** — Catalog (PRD module boundary); this feature manages **AR-side** FX gain/loss and posting-time locks. +- **ERP FX export / FX-consistency-vs-external-rate reconciliation** — Slice 7. +- **Engine mechanics / existing transaction-currency posting shapes** — Slices 1–4 (unchanged). +- **Platform / group-currency consolidation across tenants (or legal entities)** — **out of scope**. The ledger is strictly per-tenant: each books in its **own** functional currency; there is **no** platform/group reporting currency and **no** cross-tenant / cross-legal-entity FX re-translation here. Group consolidation lives **downstream** — the corporate ERP/GL after the Slice 7 export, or a BI layer that reads per-tenant functional balances and re-translates. If consolidated platform reporting becomes a product requirement it needs its **own owner**, not this feature. +- **Cross-currency allocation** — applying settled cash in currency X to an invoice posted in currency Y is **not in MVP**: Slice 2 rejects it at the API (`ALLOCATION_CURRENCY_MISMATCH`) because such an entry cannot balance per `(currency, currency_scale)` group without a designed conversion mechanism. When the business needs it, the shape is a **conversion event**: two same-currency balanced legs (close X-position / open Y-position) bridged by FX lines, locked at a documented conversion rate — a future extension of this feature (needs-discussion F6, ratified 2026-06-15: reject in MVP; conversion event **deferred post-MVP**). This feature's realized-FX rule therefore only ever deals with **document vs functional** currency differences. + +### 1.6 Constraints & Assumptions + +Inherits Slice 1 C1–C4 + A1–A6 (A4 money type applies per currency), Slices 2/3/4 assumptions. Slice-5-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| F1 | FX provider primary + fallback | Tenant-configured provider list; **ECB / central bank primary** (reference), **PSP / bank feed fallback** (settlement evidence); fallback order deterministic + recorded on the snapshot. **Ratified 2026-06-10 (needs-discussion F1).** | PRD | +| F2 | Unrealized revaluation | **Default-on for Mode B** (BSS = ledger of record) with multi-currency monetary positions; **off for Mode A** (ERP revalues) — a dedicated **idempotent, next-period-reversed** revaluation of **all foreign-currency monetary grains** `{AR, UNALLOCATED, REUSABLE_CREDIT}` (`CONTRACT_LIABILITY` excluded — non-monetary), per ASC 830/IAS 21, never a silent recompute of S1. | PRD | +| F3 | Stale-rate thresholds | **G10 currencies: > 24 h = stale**; other currencies tenant policy with an **upper bound 7 days**; a tenant threshold above 7 days MUST be **rejected at config time** (no silent clamp, Slice 1 config-validation pattern). Stale rates carry `stale=true`. | PRD | +| F4 | Provider-unreachable at S1 | Post **blocks** until a rate is available, **except** where tenant policy explicitly allows fallback to the last-good rate marked `stale=true`; silent fallback without the marker is forbidden. | PRD | +| F5 | Functional-currency scope | One functional currency **per legal entity** (Slice 1: default one legal entity per tenant). One functional rate per entry for the base translation. | PRD | + +### 1.7 Naming & Design-Introduced Names + +Reuses the PRD glossary and **inherits engine mechanics from the Foundation** (see 01-repository-foundation.md §Component Model): `PostingService`, append-only journal + strict line-negation reversal, `IdempotencyGate`, `MoneyModule` (banker's rounding, fixed-precision minor units, residual-cent rules), `BalanceProjector`, `FiscalPeriodGuard`, leaf-partition commit trigger, total fixed lock order, `TieOutJob`. The **Foundation owns the dual-column (transaction + functional) schema natively** — `functional_amount_minor` / `functional_currency` on `journal_line` and the `functional_*` columns on the shared balance caches, plus the dual-column commit-trigger check and the relaxed `amount_minor` CHECK for functional-only lines. **This feature populates them and owns** `rate_snapshot` + the `journal_line.rate_snapshot_ref` FK. Not restated. + +**Canonical slice numbering:** 1 posting-engine-core, 2 payments-allocation, 3 adjustments-notes-refunds, 4 asc606-recognition, **5 fx-multicurrency (this feature)**, 6 audit-immutability-observability, 7 reconciliation-export, 8 other. `FX_GAIN_LOSS` and `FX_UNREALIZED` (`account_class`) and `FX_REVALUATION | FX_REVAL_REVERSAL` (`source_doc_type` / idempotency `flow`) are **Foundation-declared**; this feature only **uses** them. + +Design-introduced names (Slice 5): + +| Name | Meaning | +|------|---------| +| **Transaction (document) currency** | Currency of the invoice / payment / refund (`journal_line.amount_minor` / `currency`). | +| **Functional currency** | Reporting currency of the **legal entity** (`journal_line.functional_amount_minor` / `functional_currency` at the locked rate). | +| **Functional-only line** | A line with `amount_minor = 0` and `functional_amount_minor > 0` — used for `FX_GAIN_LOSS` and the functional residual plug; it participates **only** in the functional-balance check. | +| `rate_snapshot` | Immutable record of a locked FX rate: `rate_id`, provider, base/quote, rate, `as_of`, `stale` flag, fallback order. | +| `FX_GAIN_LOSS` / `FX_UNREALIZED` | Realized-FX class (sign-by-role) / unrealized-revaluation contra class (reverses next period). | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph upstream["Upstream"] + S1ENG["Slices 1/2/3/4
posting flows (S1/S2/S3/S5/S6) with the FX-ready column"] + FXP["FX rate provider(s)
reference + settlement rates"] + end + LEDGER["Billing Ledger — FX & Multi-Currency (Slice 5)
functional translation · realized FX · revaluation · rate snapshots"] + REC["reconciliation-export (Slice 7)
FX consistency vs external rates"] + S1ENG --> LEDGER + FXP --> LEDGER + LEDGER --> REC +``` + +**Consumed:** posting events from Slices 1–4 (each line's transaction amount + the legal-entity functional currency); FX rates from the provider(s). **Functional-currency source of truth:** the legal-entity's functional currency is **owned by account-management / legal-entity setup (AMS)** — the same selling legal-entity / commercial-account that owns the books (Slice 7 ledger-ownership predicate), one functional currency per legal entity. The ledger **consumes it immutably per posting** — a posting's functional currency is fixed at post time; changing a legal entity's functional currency is a rare, upstream-owned event that **does not retro-translate** posted history. **Produced:** the functional translation on every line, `FX_GAIN_LOSS`/`FX_UNREALIZED` postings, `rate_snapshot` records, missing/stale-rate alarms. + +```mermaid +flowchart TB + subgraph engine["Slice 1 PostingService (inherited)"] + S1["PostingService · MoneyModule · BalanceProjector (functional column) · commit trigger (dual-column, Foundation)"] + end + subgraph s5["Slice 5"] + RL["RateLocker (snapshot + lock points)"] + FXP2["RealizedFxPoster"] + REVAL["UnrealizedRevaluationRun (optional)"] + RS["RateSource (provider list + fallback + staleness)"] + end + DB[("PostgreSQL
+ rate_snapshot; journal_line functional cols; caches functional_balance_minor")] + RS --> RL + RL --> S1 + FXP2 --> S1 + REVAL --> S1 + RL --> DB + FXP2 --> DB +``` + +Every posting flow (S1/S2/S3/S5/S6) calls `RateLocker` to attach a `rate_snapshot` + functional translation; `RealizedFxPoster` adds the `FX_GAIN_LOSS` functional-only line when a position closes. + +## 2. Actor Flows (CDSL) + +FX is mostly internal — it attaches to the existing posting flows of Slices 1–4 (see [Processes / Business Logic](#3-processes--business-logic-cdsl)). The actor-facing surfaces are the revaluation trigger and the read endpoints. + +### Trigger Unrealized Revaluation Run + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-fx-revaluation-run` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-ops` (finance scope; Mode B tenants — default-on; off for Mode A where `cpt-cf-bss-ledger-actor-erp-gl` revalues) + +**Success Scenarios**: +- Period-end run revalues all foreign-currency monetary grains `{AR, UNALLOCATED, REUSABLE_CREDIT}` at the period-end rate into a dedicated `FX_UNREALIZED` entry of functional-only lines; a first-of-next-period `FX_REVAL_REVERSAL` JE reverses it +- Re-trigger with the same `(tenant, period_id, revaluation_scope)` is an idempotent replay (`runId` is a retry token, not the dedup key) + +**Error Scenarios**: +- No acceptable local rate for a covered pair → `FX_RATE_UNAVAILABLE` (409) +- Stale rate where tenant policy forbids fallback → `FX_RATE_STALE_NOT_ALLOWED` (422) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/fx/revaluation-runs (body: `period_id`, `revaluation_scope`, `runId`) - `inst-reval-api` +2. [ ] - `p1` - Authorize the finance scope; idempotency: claim `(tenant, FX_REVALUATION, period_id:scope)` — the `runId` is a retry token **within** that key - `inst-reval-idem` +3. [ ] - `p1` - Algorithm: execute the run via `cpt-cf-bss-ledger-algo-fx-unrealized-revaluation` - `inst-reval-run` +4. [ ] - `p1` - Outbox: `billing.ledger.fx.revaluation_completed` - `inst-reval-event` +5. [ ] - `p1` - **RETURN** 202/201 (run reference); the reversal posts later as a fresh first-of-next-period JE (`FX_REVAL_REVERSAL`, `billing.ledger.fx.revaluation_reversed`) - `inst-reval-return` + +### Read Rate Snapshots and Functional Balances + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-flow-fx-inquiry` + +**Actor**: `cpt-cf-bss-ledger-actor-auditor`, `cpt-cf-bss-ledger-actor-finance-ops` + +**Success Scenarios**: +- An immutable rate snapshot is read by `rateId` (deterministic, auditor-reproducible: provider, `as_of`, `fallback_order`, `stale` flag, triangulation method where applicable) +- Balances are read in functional valuation from the `functional_balance_minor` cache + +**Steps**: +1. [ ] - `p2` - API: GET /v1/ledger/fx/rate-snapshots/{rateId} — read the immutable snapshot (read-only reference) - `inst-fxinq-snapshot` +2. [ ] - `p2` - API: GET /v1/ledger/balances?valuation=functional — cache read from `functional_balance_minor` - `inst-fxinq-balances` +3. [ ] - `p2` - **RETURN** 200 (snapshot / functional balances) - `inst-fxinq-return` + +## 3. Processes / Business Logic (CDSL) + +### Rate Snapshot and Lock Points + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-rate-snapshot` + +**Input**: a posting flow (S1/S2/S3/S5/S6) entry + the legal-entity functional currency +**Output**: `rate_snapshot_ref` + `functional_amount_minor` stamped on every line at the locked rate + +**Steps**: +1. [ ] - `p1` - `RateLocker` snapshots a rate and stamps `rate_snapshot_ref` + `functional_amount_minor` per the lock policy in effect; **no silent re-pick** at render/reconciliation without a new business event that re-locks (PRD). **One functional rate per entry** for the base translation - `inst-rl-lock` +2. [ ] - `p1` - **S1 invoice post:** rate in effect at post - `inst-rl-s1` +3. [ ] - `p1` - **S2 settlement / allocation:** lock on settle. On allocation that **closes** a position, **no new base rate is locked** — each closing account is relieved at its **own carried functional value** and the net difference posts to `FX_GAIN_LOSS` (→ realized FX, `cpt-cf-bss-ledger-algo-fx-realized-gain-loss`) - `inst-rl-s2` +4. [ ] - `p1` - **S6 recognition:** does **NOT** re-lock — schedule currency = as posted (Slice 4); FX on recognition is a **translation**, not a new realized event, unless a documented catch-up posts a new JE - `inst-rl-s6` +5. [ ] - `p1` - **Tax jurisdiction rate.** Tax is translated at the **entry** functional rate (one rate per entry). **IF** a jurisdiction mandates a different tax-FX rate, that difference routes to a **separate documented JE**, never mixed into one balanced entry at two rates - `inst-rl-tax` +6. [ ] - `p1` - Functional translation uses the Slice 1 `MoneyModule` (banker's rounding; +4 compute decimals not retained) - `inst-rl-money` + +### Rate Source, Staleness and Fallback + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-fx-rate-source-fallback` + +**Input**: currency pair + tenant provider/staleness policy at lock time +**Output**: an immutable `rate_snapshot` (provider, `as_of`, `fallback_order`, `stale` flag), or a blocking condition + +**Steps**: +1. [ ] - `p1` - **(🔄) Rate feed (post-path-local).** Acquire rates by a **background sync job** — per-provider poll cadence (ECB publishes once per TARGET business day; retry; a staleness watermark per pair), writing a **local rates store** (the "latest known rates", distinct from the per-lock `rate_snapshot` frozen on each entry) - `inst-rs-sync` +2. [ ] - `p1` - `RateSource` resolves at lock time by a **LOCAL read** of that store — **no synchronous provider call on the posting path** (consistent with Slice 1 C3 and Slice 4: a provider outage must not take down posting). A provider being unreachable fails the **sync job** (alarmed), not a post; `FX_RATE_UNAVAILABLE` therefore means **"no acceptable local rate for the pair"**, not "provider TCP timeout" - `inst-rs-local` +3. [ ] - `p1` - Resolve from a **tenant-configured provider list** with a **deterministic** default order **over the local store**; record the chosen provider + `as_of` + `fallback_order` on the immutable `rate_snapshot` - `inst-rs-resolve` +4. [ ] - `p1` - **Fallback concept 1 — secondary-provider live rate:** a lower-priority provider returns a **live** rate → snapshot records `fallback_order`, `stale = false`, **no block** - `inst-rs-fallback-live` +5. [ ] - `p1` - **Fallback concept 2 — no acceptable local rate** (the sync store has no fresh-enough rate for the pair — e.g. the sync job has been failing): the post **blocks** (`FX_RATE_UNAVAILABLE`, 409), **except** where tenant policy explicitly allows the last-good rate, snapshotted with `stale = true`. This is a **local-store** condition, not a live provider call from the post path; the provider-unreachability itself is handled by the sync job + its alarm. **Silent** fallback without the marker is forbidden - `inst-rs-fallback-stale` +6. [ ] - `p1` - **Staleness:** measured from the snapshot `as_of`. G10 > 24 h = stale; others tenant policy ≤ 7 days (over-7-day config rejected at config time, no silent clamp). **IF** a stale rate where tenant policy **forbids** fallback → `FX_RATE_STALE_NOT_ALLOWED` (422, blocks). A stale-allowed snapshot raises the **stale** alarm (Warn). A stale fallback reuses the original rate `as_of` (staleness measured from `as_of`) - `inst-rs-staleness` +7. [ ] - `p1` - **Rate revision after publish:** snapshots immutable; a later revision MUST NOT alter prior postings — corrections post as new compensating entries (Slice 3 / Slice 1 reversal); a provider's later revision is a **new** row (PRD) - `inst-rs-revision` +8. [ ] - `p1` - **(🔄 2026-06-15) ECB reference-rate specifics** (ECB is the ratified primary source, F1): (a) **non-publication dates** — ECB publishes once per TARGET business day, so on weekends / holidays use the **last published** rate, recorded on the snapshot; (b) **non-EUR pairs** (`X→Y`, neither EUR) — compute by **triangulation through EUR** (`X→EUR→Y`) and record the triangulation method on the snapshot. Both keep the locked rate deterministic and auditor-reproducible - `inst-rs-ecb` + +### Dual-Currency Balance and Functional Translation + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-fx-dual-currency-balance` + +**Input**: any journal entry (transaction lines + functional translations + functional-only lines) +**Output**: an entry that balances in **both** columns, enforced by the Foundation dual-column commit trigger + +**Steps**: +1. [ ] - `p1` - Every entry MUST balance in **both** columns. The **Foundation's** leaf-partition commit trigger natively enforces **two** assertions (dual-column): (1) **Transaction balance** — `SUM(DR.amount_minor) = SUM(CR.amount_minor)` per `(currency, currency_scale)`, **over lines that carry a transaction amount** (`amount_minor > 0`), unchanged from Slice 1; (2) **Functional balance** — `SUM(DR.functional_amount_minor) = SUM(CR.functional_amount_minor)` exactly, **over all lines** (including functional-only lines) - `inst-dual-checks` +2. [ ] - `p1` - **Functional-only lines** (`FX_GAIN_LOSS`, the functional residual plug) carry `amount_minor = 0` and `functional_amount_minor > 0`; they are excluded from check (1) and included in check (2). The **Foundation's** `amount_minor` CHECK is natively the relaxed form `CHECK (amount_minor > 0 OR (amount_minor = 0 AND functional_amount_minor > 0))` - `inst-dual-fonly` +3. [ ] - `p1` - **Functional rounding residual.** Per-line translation (`amount_minor × rate`, banker's rounding) can leave a per-entry functional residual even when the transaction column is exact. A **single deterministic plug** carries it: the residual (|residual| ≤ lines − 1 minor units) attaches to the entry's **anchor line** (AR for AR-anchored entries) or to a single capped `FX_GAIN_LOSS`/rounding line, so check (2) closes **by construction** rather than failing. (This is the FX extension of Slice 1 `MoneyModule` residual-cent determinism.) - `inst-dual-residual` +4. [ ] - `p1` - The Slice 1 "exact, no tolerance **at commit**" rule holds on both columns. Rounding-only variance on the functional column is absorbed into the inherited daily `TieOutJob` tolerance (extended to the functional column); **FX-consistency-vs-external-rate** variance is a separate Slice 7 reconciliation control - `inst-dual-tieout` +5. [ ] - `p1` - **Reversal of functional-only lines.** Strict line-negation ("same account, flipped side, positive amount") is extended for the relaxed CHECK: a functional-only line (`amount_minor = 0`, `functional_amount_minor > 0`) reverses as a functional-only line with the **side flipped, `amount_minor` still 0, and the same positive `functional_amount_minor`** (carrying the original `rate_snapshot_ref` — no re-lock). Both commit checks hold by construction: the reversal's transaction-column groups mirror the original's, and its functional column nets the original's - `inst-dual-reversal` +6. [ ] - `p1` - `BalanceProjector` maintains `functional_balance_minor` + `functional_currency` on the shared caches in the same transaction, locking the **same rows** in the canonical order (**no new lock-order rank**) - `inst-dual-projector` + +### Realized FX Gain/Loss + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-fx-realized-gain-loss` + +**Input**: a receipt, settlement, allocation, refund, or chargeback close where document ≠ functional currency +**Output**: a single `FX_GAIN_LOSS` functional-only line in the **same atomic entry** as the originating close + +**Steps**: +1. [ ] - `p1` - **Realized FX MUST post** on receipt, settlement, allocation, refund, or chargeback when the document and functional currency differ (PRD). **Normative rule:** realized FX closes the **functional** balance of **every** account whose **transaction** balance reaches zero on the close, each valued at **that account's own carried (locked) functional value**; the **net** difference posts to a single `FX_GAIN_LOSS` functional-only line so the functional column balances - `inst-rfx-rule` +2. [ ] - `p1` - **IF** a **partial** close (e.g. allocate 60 of 120 EUR): compute realized FX **pro-rata on the closed portion** (the relieved fraction valued at its carried rate vs the closing value); a full close is the special case where the whole position is relieved - `inst-rfx-partial` +3. [ ] - `p1` - **Carried-value source, normative:** read the carried functional value of a position from its balance-cache grain — per-invoice AR from `ar_invoice_balance`; the Unallocated pool from `unallocated_balance`; the wallet sub-grain from `reusable_credit_subbalance` (Slice 2) — each carrying `functional_balance_minor`. Value a partial relief **pro-rata at the grain's weighted-average carried rate** (`functional_balance_minor / balance_minor` at relief time, banker's rounding via `MoneyModule`); decrement the relieved functional amount from the grain in the same transaction - `inst-rfx-carried` +4. [ ] - `p1` - **(/ S5-minor) Lot-relief is an accounting policy, ratified deliberately:** **weighted-average (WAC)** is chosen over **FIFO** / **specific-lot** because it is **deterministic** from the cache grain (no per-lot tracking, no `journal_line` rescan). This is a conscious accounting-policy choice — to be confirmed acceptable to the target jurisdictions/auditors — **not** an implementation detail; switching method would change reported gain/loss. Rescanning `journal_line` per allocation and any cross-grain (e.g. cross-payer) averaging are **forbidden** — they produce a different gain/loss - `inst-rfx-wac` +5. [ ] - `p1` - Chargeback Lost/Won/Partial closes (Slice 2 ChargebackHandler) invoke `RealizedFxPoster` identically to allocation when document ≠ functional currency - `inst-rfx-chargeback` +6. [ ] - `p1` - The realized-FX line is part of the **same atomic entry** as the originating close (no separate dedup; governed by the originating flow's idempotency key) - `inst-rfx-atomic` + +**Worked example C** (USD functional, EUR invoice — PRD Example C; the PRD example shows only the AR leg; the complete treatment below also closes the cash leg, netting to the realized FX): + +```text +S1 (post) at 1.10: DR AR 120 EUR (=132.00 USD); CR Revenue 100 EUR (=110.00 USD); + CR Tax payable 20 EUR (=22.00 USD). + (This is also Example B's two-step structure extended with per-step FX locks.) +S2 settle at 1.08: DR Cash 120 EUR (=129.60 USD) / CR Unallocated 120 EUR (=129.60 USD). + Unallocated carried at 129.60 USD + (unallocated_balance.functional_balance_minor for the grain). +S2 allocate: transaction legs DR Unallocated 120 EUR / CR AR 120 EUR (balanced in EUR). + Each account is relieved at ITS OWN carried functional value, read from its + cache grain: DR Unallocated 129.60 USD (closes Unallocated to 0), + CR AR 132.00 USD (closes AR to 0). Had the grain held two settlements at + different rates, a partial allocation would relieve at the grain's + weighted-average carried rate. The functional column is short 2.40 on the + DR side → DR FX loss 2.40 USD (functional-only line). + Net realized FX = 2.40 USD loss. +S6 recognition: schedule currency = EUR (as posted); recognition does NOT re-lock; + FX handled at translation. +``` + +The original PRD Example C was illustrative and booked only the AR-leg 3.60 (omitting the offsetting 1.20 cash-leg gain); the **normative net is 2.40**, and the PRD Example C was corrected to net both legs (✅ resolved 2026-06-11, — needs-discussion). + +### Unrealized Revaluation Run + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-fx-unrealized-revaluation` + +**Input**: `(tenant, period_id, revaluation_scope)` for a Mode B tenant with open multi-currency monetary positions +**Output**: a dedicated `FX_UNREALIZED` entry of functional-only lines + a first-of-next-period reversal JE + +**Steps**: +1. [ ] - `p1` - **Default-on for Mode B** tenants (BSS = ledger of record) with open multi-currency AR; **off for Mode A**, where the ERP GL revalues (F2, — ASC 830/IAS 21 requires period-end remeasurement in whatever ledger produces the reporting balances) - `inst-ureval-mode` +2. [ ] - `p1` - When active, revalue **all foreign-currency monetary positions** at the period-end rate into a dedicated entry `DR/CR FX_UNREALIZED` (a contra class, sign-by-role) vs each position's functional balance — **never** a silent recompute of S1 - `inst-ureval-post` +3. [ ] - `p1` - **(🔄)** `revaluation_scope` enumerates the covered grains **`{AR, UNALLOCATED, REUSABLE_CREDIT}`** — open AR **and** the Slice 2 monetary caches `unallocated_balance` + `reusable_credit_subbalance` (both carry `functional_balance_minor`; both are monetary — owed back to / held for the customer), so a foreign-currency prepayment or wallet held across a period boundary is remeasured too (ASC 830 / IAS 21 remeasures **all** monetary items). **`CONTRACT_LIABILITY` is deliberately excluded** — it is **non-monetary** (a deferred performance obligation), which ASC 830/IAS 21 does not remeasure - `inst-ureval-scope` +4. [ ] - `p1` - Compose the revaluation entry of **functional-only** lines (`amount_minor = 0`): it adjusts the functional column only and passes transaction-balance check (1) trivially (zero in-scope lines) - `inst-ureval-fonly` +5. [ ] - `p1` - **Idempotency** per `(tenant, period_id, revaluation_scope)` (the `runId` is a retry token **within** that key, not the dedup key); `flow = FX_REVALUATION`, `business_id = period_id:scope` - `inst-ureval-idem` +6. [ ] - `p1` - **Reversal** is **not** a strict line-negation: it is a fresh **first-of-next-period** JE with its own `source_doc_type = FX_REVAL_REVERSAL`, posting in the next **OPEN** period, **idempotent per `(tenant, FX_REVAL_REVERSAL, period_id:scope)`** of the original revaluation period (mirroring the run key) — so it is exempt from the once-per-entry and closed-period constraints and posts cleanly (at most once) after close. Only **realized** FX is permanent - `inst-ureval-reversal` + +## 4. States (CDSL) + +### Rate Snapshot State Machine + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-state-fx-rate-snapshot` + +**States**: live, stale +**Initial State**: live (`stale = false` at snapshot creation from a fresh-enough local rate; a lower-priority provider's live rate is still `live`, with `fallback_order` recorded) + +**Transitions**: +1. [ ] - `p1` - **FROM** live **TO** stale **WHEN** the rate's age measured from `as_of` exceeds the threshold (F3: G10 > 24 h; others tenant policy ≤ 7 days) — a **new** snapshot minted from the last-good rate under tenant-allowed fallback carries `stale = true` (Warn alarm); where tenant policy forbids fallback the post blocks (`FX_RATE_STALE_NOT_ALLOWED`) and no snapshot is used - `inst-snap-stale` +2. [ ] - `p1` - `rate_snapshot` rows themselves are **immutable** — no UPDATE/DELETE transitions; a provider's later revision is a **new** row and MUST NOT alter prior postings - `inst-snap-immutable` + +### Revaluation Entry State Machine + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-state-fx-revaluation-entry` + +**States**: posted, reversed +**Initial State**: posted (the period-end `FX_REVALUATION` entry, functional-only lines) + +**Transitions**: +1. [ ] - `p1` - **FROM** posted **TO** reversed **WHEN** the first-of-next-period `FX_REVAL_REVERSAL` JE posts in the next OPEN period (idempotent per `(tenant, FX_REVAL_REVERSAL, period_id:scope)`; not a strict line-negation) — only **realized** FX is permanent - `inst-reval-state-reverse` + +## 5. API Surface + +FX is mostly internal (attached to existing posts). New surfaces: + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST` | `/v1/ledger/fx/revaluation-runs` | Trigger an optional unrealized revaluation for a period. | per `(tenant, period_id, revaluation_scope)`; `runId` is a retry token | +| `GET` | `/v1/ledger/fx/rate-snapshots/{rateId}` | Read an immutable rate snapshot. | — | +| `GET` | `/v1/ledger/balances?valuation=functional` | Read balances in functional currency (from `functional_balance_minor` cache). | cache read | + +**Problem responses (RFC 9457):** `FX_RATE_UNAVAILABLE` (409 — all providers unreachable, no allowed stale fallback), `FX_RATE_STALE_NOT_ALLOWED` (422 — stale rate where tenant forbids fallback). Posting flows otherwise return their own slice's codes; an FX line is part of the same entry. + +## 6. Data Model + +This feature owns **`rate_snapshot`** and the `journal_line.rate_snapshot_ref` FK; it **populates** the Foundation-owned functional columns (`functional_amount_minor`/`functional_currency` on `journal_line`, `functional_*` on the caches) and posts the Foundation-declared `FX_GAIN_LOSS`/`FX_UNREALIZED` classes. Tenant-scoped RLS (C1). + +**`rate_snapshot`** (PK `rate_id`; `UNIQUE (tenant_id, base_currency, quote_currency, provider, as_of, fallback_order)`; **immutable**): + +| Column | Type | Notes | +|--------|------|-------| +| `rate_id` | `uuid` | PK | +| `tenant_id` | `uuid` | RLS scope | +| `base_currency` | `char` | | +| `quote_currency` | `char` | | +| `rate_micro` | `bigint` | rate at fixed precision | +| `as_of` | `timestamptz` | rate timestamp; drives staleness | +| `provider` | `string` | chosen provider (F1 list) | +| `stale` | `bool` | `true` only for tenant-allowed last-good fallback | +| `fallback_order` | `int` | deterministic fallback position, recorded | + +**`journal_line` FX columns** (Slice 1 `journal_line`, FK `line_id`): + +| Column | Type | Notes | +|--------|------|-------| +| `amount_minor` | `bigint` | transaction; 0 for functional-only lines | +| `currency` | `char` | transaction (document) currency | +| `functional_amount_minor` | `bigint` | **ACTIVATED** by this feature (Foundation-owned column) | +| `functional_currency` | `char` | **ADDED** (Foundation-owned column) | +| `rate_snapshot_ref` | `uuid` | **ADDED by this feature**, FK `rate_snapshot` | + +**Balance-cache FX columns** (Slice 1/2 cache rows — `account_balance`, `ar_invoice_balance`, `ar_payer_balance`, `unallocated_balance`, `reusable_credit_subbalance`): + +| Column | Type | Notes | +|--------|------|-------| +| `balance_minor` | `bigint` | transaction (Slice 1/2) | +| `functional_balance_minor` | `bigint` | **ADDED** (Foundation-owned) — each position cache grain carries its own **carried functional value**; realized FX reads it from the grain — never by rescanning `journal_line`, never by a cross-payer average | +| `functional_currency` | `char` | **ADDED** (Foundation-owned) | + +Key constraints: + +- `rate_snapshot` **immutability enforced** like Slice 1: `REVOKE UPDATE, DELETE … FROM ` + `BEFORE UPDATE/DELETE` trigger that RAISEs (a revision can only INSERT a new row). A stale fallback reuses the original rate `as_of` (staleness measured from `as_of`). +- **The Foundation commit trigger (dual-column, native, on every leaf partition):** check (1) transaction balance per `(currency, currency_scale)` over `amount_minor > 0` lines; check (2) functional balance over all lines; both exact (no commit tolerance). +- `journal_line` `CHECK` relaxed: `amount_minor > 0 OR (amount_minor = 0 AND functional_amount_minor > 0)` (permits functional-only FX/residual lines). +- `functional_balance_minor` + `functional_currency` are **Foundation-owned columns** on the shared caches; maintained by `BalanceProjector` in the same txn (same rows → **no new lock-order rank**); `TieOutJob` recomputes the functional column from `journal_line.functional_amount_minor`. Realized FX relieves a position grain pro-rata at its **weighted-average carried rate**. **(/ S5-minor) Grain caveat:** these functional columns sit on a cache grain **without `legal_entity_id`** — correct only under the v1 default of **one legal entity per tenant**. A multi-legal-entity tenant would mix two functional currencies in one cache row; **`legal_entity_id` MUST enter the functional cache grain** before multi-legal-entity is enabled. +- `FX_GAIN_LOSS` (sign-by-role, not NO-negative) and `FX_UNREALIZED` (contra, reverses next period) are **Foundation-declared** `account_class` literals; `FX_REVALUATION | FX_REVAL_REVERSAL` are **Foundation-declared** `source_doc_type`/`flow` values. Revaluation `business_id = period_id:scope`. +- Functional translation uses the Slice 1 `MoneyModule` (banker's rounding; +4 compute decimals not retained); the per-entry functional residual is a single deterministic plug line. +- **Tax payable MAY go negative** per `(jurisdiction, filing-period)` (Slice 3 sub-grain guard); tax posted in transaction currency per `TaxBreakdown`, translated at the entry rate. + +## 7. Events & Alarms + +Success FX is part of the originating flow's outbox event (no separate event for the realized-FX line). New: `billing.ledger.fx.revaluation_completed` / `billing.ledger.fx.revaluation_reversed` (F2; — renamed from `revaluation_run` to keep the past-tense event convention). Alarms via the separate committed audit/alarm txn: `billing.ledger.invariant.alarm` with `alarmCategory ∈ {fx-snapshot-missing, fx-snapshot-stale-allowed, fx-snapshot-stale-blocked}` — **missing → Critical (blocks)**, **stale-allowed → Warn (posts successfully)**, **stale-not-allowed → Critical (blocks, 422)**. The two **blocking** states map to the API-surface codes (missing → `FX_RATE_UNAVAILABLE` 409; stale-blocked → `FX_RATE_STALE_NOT_ALLOWED` 422); stale-allowed has no error code. PII-free. + +## 8. Definitions of Done + +### Functional Translation on Every Line + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-fx-functional-translation` + +The system **MUST** stamp every journal line of every posting flow (S1/S2/S3/S5/S6) with `functional_amount_minor`, `functional_currency`, and `rate_snapshot_ref` at the locked rate, balance every entry in both columns via the Foundation dual-column commit trigger, carry the per-entry functional rounding residual on a single deterministic plug line, and reverse functional-only lines side-flipped with the original `rate_snapshot_ref`. + +**Implements**: +- `cpt-cf-bss-ledger-algo-fx-dual-currency-balance` +- `cpt-cf-bss-ledger-algo-rate-snapshot` + +**Touches**: +- DB: `journal_line` (functional columns + `rate_snapshot_ref`), `account_balance`/`ar_invoice_balance`/`ar_payer_balance`/`unallocated_balance`/`reusable_credit_subbalance` (`functional_balance_minor`) +- Entities: `JournalLine`, `RateSnapshot` + +### Rate Snapshot Store and Lock Points + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-fx-rate-snapshot` + +The system **MUST** lock exactly one functional rate per entry at the defined lock points (S1 at post; S2 on settle; allocation closes relieve at carried value with no new base rate; S6 never re-locks), persist each lock as an immutable `rate_snapshot` row, and never silently re-pick a rate without a new business event. + +**Implements**: +- `cpt-cf-bss-ledger-algo-rate-snapshot` +- `cpt-cf-bss-ledger-state-fx-rate-snapshot` + +**Touches**: +- DB: `rate_snapshot`, `journal_line.rate_snapshot_ref` +- Entities: `RateSnapshot` + +### Rate Source Sync, Staleness and Fallback + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-fx-rate-source` + +The system **MUST** acquire rates via the background sync job into a local rates store (no synchronous provider call on the posting path), resolve deterministically over the tenant-configured provider list with recorded `fallback_order`, enforce the F3 staleness thresholds (G10 24 h; others ≤ 7 d, over-7-d config rejected), block with `FX_RATE_UNAVAILABLE`/`FX_RATE_STALE_NOT_ALLOWED` per policy, mark allowed fallbacks `stale=true`, and handle ECB non-publication dates + EUR triangulation on the snapshot. + +**Implements**: +- `cpt-cf-bss-ledger-algo-fx-rate-source-fallback` + +**Touches**: +- DB: local rates store, `rate_snapshot` +- Entities: `RateSnapshot`, `RateSource` + +### Realized FX Posting + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-fx-realized` + +The system **MUST** post realized FX on receipt, settlement, allocation, refund, or chargeback when document ≠ functional currency: close each zero-transaction-balance account at its own carried functional value read from its cache grain, value partial reliefs pro-rata at the grain's weighted-average carried rate (WAC — a ratified accounting policy), and post the net difference as a single `FX_GAIN_LOSS` functional-only line inside the originating atomic entry. + +**Implements**: +- `cpt-cf-bss-ledger-algo-fx-realized-gain-loss` + +**Touches**: +- DB: `journal_line` (FX_GAIN_LOSS functional-only lines), `ar_invoice_balance`, `unallocated_balance`, `reusable_credit_subbalance` +- Entities: `RealizedFxPoster` + +### Unrealized Revaluation and Reversal + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-fx-revaluation` + +The system **MUST** run the Mode-B period-end revaluation over the monetary grains `{AR, UNALLOCATED, REUSABLE_CREDIT}` (`CONTRACT_LIABILITY` excluded — non-monetary) as an idempotent `FX_REVALUATION` entry of functional-only lines, reversed by a fresh first-of-next-period `FX_REVAL_REVERSAL` JE idempotent per the mirrored run key, never silently recomputing S1. + +**Implements**: +- `cpt-cf-bss-ledger-flow-fx-revaluation-run` +- `cpt-cf-bss-ledger-algo-fx-unrealized-revaluation` +- `cpt-cf-bss-ledger-state-fx-revaluation-entry` + +**Touches**: +- API: `POST /v1/ledger/fx/revaluation-runs` +- DB: `journal_entry`/`journal_line` (FX_UNREALIZED), `idempotency_dedup` +- Entities: `UnrealizedRevaluationRun` + +### FX Inquiry Endpoints + +- [ ] `p2` - **ID**: `cpt-cf-bss-ledger-dod-fx-inquiry` + +The system **MUST** expose the immutable rate-snapshot read and the functional-valuation balance read (from the `functional_balance_minor` cache). + +**Implements**: +- `cpt-cf-bss-ledger-flow-fx-inquiry` + +**Touches**: +- API: `GET /v1/ledger/fx/rate-snapshots/{rateId}`, `GET /v1/ledger/balances?valuation=functional` +- DB: `rate_snapshot`, balance caches +- Entities: `RateSnapshot` + +## 9. Acceptance Criteria + +Delta over the Foundation testing architecture (levels + mocking inherited). + +Unit: + +- [ ] Functional translation at a locked rate; realized-FX net math (Example C: AR 132.00 + Unallocated 129.60 → DR FX loss 2.40, both close to 0); weighted-average carried-rate pro-rata relief (two settlements at different rates, partial allocation); functional rounding-residual plug determinism; stale-threshold logic (G10 24 h vs tenant ≤ 7 d; over-7-d config rejected) + +Integration (testcontainers): + +- [ ] An EUR invoice under USD functional posts a balanced entry in **both** columns (the Foundation's dual-column commit trigger) +- [ ] A functional-only `FX_GAIN_LOSS` line passes the relaxed `amount_minor` CHECK and balances the functional column +- [ ] A full cross-currency settle→allocate closes **both** AR and Unallocated functional balances to **zero** with a 2.40 FX line +- [ ] Provider-unreachable at S1 **blocks** (`FX_RATE_UNAVAILABLE`) unless tenant allows a `stale=true` snapshot; secondary-provider live rate does **not** block (stale=false) +- [ ] A later provider rate revision does **not** alter a prior posting; `rate_snapshot` UPDATE/DELETE rejected + +API: + +- [ ] RFC 9457 mapping for `FX_RATE_UNAVAILABLE` / `FX_RATE_STALE_NOT_ALLOWED`; functional-valuation cache read; revaluation-run idempotent per `(tenant, period_id, scope)` + +Ordering & exception: + +- [ ] Optional revaluation is idempotent + reversed by a next-period `FX_REVAL_REVERSAL` JE (not a line-negation); the three snapshot alarm states fire correctly + +Concurrency: + +- [ ] Functional-column projection locks the same `account_balance` rows in the inherited order (no new deadlock surface) + +NFR verification: + +- [ ] (1) write p95 with the extra FX line → inherited load test (Foundation NFR targets); (2) fx-snapshot missing/stale-allowed/stale-blocked alarms + +## 10. Non-Functional Considerations + +- **Performance / NFR mapping**: Inherits Slice 1 targets (e.g. write p95 ≤ 500 ms — the Foundation NFR mapping). Slice-5-specific: missing FX snapshot **blocks** the post (Critical); stale-allowed is Warn; stale-not-allowed blocks (422) (AC #18). FX adds at most one functional-only line per entry → negligible latency impact on the inherited write-p95 target. Traces to `cpt-cf-bss-ledger-nfr-posting-performance`. +- **Security & AuthZ**: Inherits Slice 1: RLS, append-only, PII-minimized events. Triggering revaluation runs requires the finance scope; rate snapshots are read-only references. The ledger trusts the provider feed's authenticity (provider auth is upstream/ops). +- **Observability / Feature metrics**: `ledger_fx_realized_minor{functional_currency}`, `ledger_fx_snapshot_missing_total`, `ledger_fx_snapshot_stale_allowed_total`, `ledger_fx_snapshot_stale_blocked_total`, `ledger_fx_revaluation_duration_seconds` (— aligned with the `revaluation_completed` event rename), `ledger_fx_provider_fallback_total{provider}`. Thresholds wire to the NFR mapping + the snapshot alarms. +- **Risks & deferred work**: **FX provider** — **ratified 2026-06-10: ECB primary, PSP/bank feed fallback**; the snapshot/lock/realized-FX mechanics are stable. The **Foundation's dual-column commit trigger** (functional balance, two checks) + the relaxed `amount_minor` CHECK are Foundation-owned (native, on every leaf partition); this feature posts functional-only lines that exercise them; covered by integration tests. **Deferred:** FX consistency reconciliation vs external rates + ERP FX export → Slice 7; pricing-side FX/rate-lock → Catalog; cross-currency conversion event → deferred post-MVP. +- **Needs discussion** (inherits Slices 1–4 open items; slice-specific): + +| Item | Decision (default) | Status | Owner | +|------|--------------------|--------|-------| +| **FX provider primary + fallback list** | ECB/central-bank reference + PSP/bank settlement; deterministic fallback order | ✅ Ratified 2026-06-10: **ECB primary, PSP/bank feed fallback** | PM Team | +| Unrealized revaluation on/off | **default-on for Mode B** (BSS = ledger of record), **off for Mode A** (ERP revalues); idempotent + next-period-reversed; scope `{AR, UNALLOCATED, REUSABLE_CREDIT}` — consistent with F2 / the revaluation run | ✅ Accepted default | — | +| Stale-rate thresholds | G10 24 h; others ≤ 7 d (over-7-d config rejected) | ✅ Accepted default | — | +| Provider-unreachable policy | block by default; stale fallback only if tenant allows (marked) | ✅ Accepted default | — | +| Functional currency / one rate per entry | one functional currency per legal entity; one functional rate per entry | ✅ Accepted default | — | +| PRD Example C numbers | realized FX = **2.40** (same-currency allocation is not an FX event; Unallocated fully clears) | ✅ **Resolved 2026-06-11** — PRD Example C corrected | PM | +| Cross-currency allocation | **out of MVP** (Slice 2 rejects, `ALLOCATION_CURRENCY_MISMATCH`); the conversion-event mechanism is a **deferred post-MVP** extension of this feature | ✅ **Ratified 2026-06-15** — reject in MVP; conversion event deferred post-MVP | PM + Architecture | diff --git a/gears/bss/ledger/docs/design/07-reconciliation-export.md b/gears/bss/ledger/docs/design/07-reconciliation-export.md new file mode 100644 index 000000000..761730dc6 --- /dev/null +++ b/gears/bss/ledger/docs/design/07-reconciliation-export.md @@ -0,0 +1,826 @@ + + + + +# DESIGN — Reconciliation & ERP Export (Slice 7) + +**Owners:** @vstudzinskyi (BSS Billing Platform team) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming Conventions](#17-naming-conventions) + - [1.8 System Context](#18-system-context) + - [1.9 SoR Boundary & Operating Modes](#19-sor-boundary--operating-modes) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [API Surface](#api-surface) + - [Trigger Reconciliation Run](#trigger-reconciliation-run) + - [Finance-Initiated Period Close](#finance-initiated-period-close) + - [Resolve or Approve Exception](#resolve-or-approve-exception) + - [Trigger ERP Export / Read Export Status](#trigger-erp-export--read-export-status) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Reconciliation Framework Checks](#reconciliation-framework-checks) + - [AR to Derived Tie-Out](#ar-to-derived-tie-out) + - [Ledger to ERP Reconciliation](#ledger-to-erp-reconciliation) + - [Payments to PSP Tie](#payments-to-psp-tie) + - [Invoice Completeness Check](#invoice-completeness-check) + - [ERP Export Watermark Scan](#erp-export-watermark-scan) + - [ERP Export Replay & Duplicate-Key Disposition](#erp-export-replay--duplicate-key-disposition) + - [Restore & Re-Sync Runbook](#restore--re-sync-runbook) +- [4. States (CDSL)](#4-states-cdsl) + - [Period Close State Machine](#period-close-state-machine) + - [Export Record State Machine](#export-record-state-machine) + - [Exception State Machine](#exception-state-machine) +- [5. Data Model (Owned Tables)](#5-data-model-owned-tables) + - [5.1 export_record](#51-export_record) + - [5.2 reconciliation_run](#52-reconciliation_run) + - [5.3 exception_queue](#53-exception_queue) + - [5.4 period_close](#54-period_close) + - [5.5 Cross-Table Rules](#55-cross-table-rules) +- [6. Definitions of Done](#6-definitions-of-done) + - [Reconciliation Framework](#reconciliation-framework) + - [Idempotent ERP Export](#idempotent-erp-export) + - [Period Close Gate](#period-close-gate) + - [Exception Queue](#exception-queue) + - [Restore & Re-Sync](#restore--re-sync) + - [Owned Data Model](#owned-data-model) + - [Observability](#observability) + - [Security & AuthZ](#security--authz) +- [7. Acceptance Criteria](#7-acceptance-criteria) +- [8. Non-Functional Considerations](#8-non-functional-considerations) + - [8.1 Events Surface](#81-events-surface) + - [8.2 Feature Metrics](#82-feature-metrics) + - [8.3 NFR Mapping](#83-nfr-mapping) + - [8.4 Security & AuthZ Details](#84-security--authz-details) +- [9. Risks, Open Questions & Deferred Work](#9-risks-open-questions--deferred-work) +- [10. Needs Discussion](#10-needs-discussion) +- [11. References](#11-references) + + + +## 1. Context + +### 1.1 Overview + +The Billing Ledger **proves itself correct and hands off to the corporate GL**: daily/period reconciliation (AR ledger ↔ derived projection, Ledger ↔ ERP trial balance, Payments ↔ PSP); **idempotent, replay-safe ERP/GL export** with ack + variance; a **period-close gate** that blocks on variance or open exceptions; and the **SoR boundary** (BSS subledger authoritative; ERP downstream) — never shadow-editing a posting to match a failed ERP, never dropping a posted fact (PRD § Reconciliation flows, § Source-of-truth, § Accounting periods and close; AC #7/#12; manifest §4.4). + +This is canonical **slice 7** of the billing-ledger design set (canonical slice numbering: 1 posting-engine-core, 2 payments-allocation, 3 adjustments-notes-refunds, 4 asc606-recognition, 5 fx-multicurrency, 6 audit-immutability-observability, **7 reconciliation-export (this feature)**, 8 other). It posts **no new financial journal entries** (BSS-originated corrections flow through S3/S4/reversal in the adjustments feature); it reads, exports, reconciles, and **gates close**. + +### 1.2 Purpose + +Slice 7 owns the cross-module reconciliation framework, the ERP/GL export, the SoR boundary, and the period-close gate. It aggregates the per-slice **TieOutJob** contributions (AR-affecting posted facts from S1/S2/S3/S4 + FX), consumes the Slice 6 **alarm catalog** (`reconciliation-variance`, `export-failed-aged`) + secured audit store + tenant-scoping/elevated-access, the Slice 1 append-only journal + idempotent-replay contract, and the Slice 5 functional-balance column. Not restated where inherited. + +> **⚠️ v1 implementation status (design ↔ code reconciliation).** The **reconciliation / tie-out / period-close-gate half of this slice IS in v1** (`reconciliation_run`, `exception_queue`, `period_close`, the AR↔derived / Payments↔PSP / invoice-completeness checks, the close gate). The **ERP/GL export half is NOT in v1 — deferred post-v1**, and the code omits it entirely. Specifically deferred: +> - **`POST /v1/ledger/exports` and `GET /v1/ledger/exports/{exportId}`** (§4 REST) — no routes in v1. +> - **`export_record` table** (§5.1) and the **`ErpExporter` / ERP-export watermark scan** (§ Export flow / watermark scan) — not built; only 3 of the "four owned tables" ship (`reconciliation_run`, `exception_queue`, `period_close`). +> - **`EXPORT_PAYLOAD_CONFLICT` (409)** problem code and the **`EXPORT_FAILURE`** exception-queue type — not emitted in v1. +> - **`LEDGER_ERP` and `GL`** `reconciliation_run.check_type` values — v1 supports only `AR_DERIVED`, `PAYMENTS_PSP`, `INVOICE_COMPLETENESS`; the ERP/GL tie-out check types are deferred. +> - **`billing.ledger.export.acked`** event — deferred with the ERP-export half (and the parked event layer). +> - **`RECONCILIATION_VARIANCE` as a forced-close problem response** (the "422 if a blocking variance is forced" path) — v1 has no force-close override; a blocked close returns `PERIOD_CLOSE_BLOCKED` (409) and the variance is surfaced as an alarm/exception only. +> +> Rationale: v1 operates the BSS subledger as authoritative and gates close on tie-out + exceptions; the ERP/GL hand-off (Mode A export + ack) is scheduled post-v1. Everything below describing the export surface is design-forward, not v1. + +**Requirements**: `cpt-cf-bss-ledger-fr-ar-tie-out`, `cpt-cf-bss-ledger-fr-erp-export-idempotency`, `cpt-cf-bss-ledger-fr-accounting-periods-close`, `cpt-cf-bss-ledger-fr-exception-suspense-handling`, `cpt-cf-bss-ledger-nfr-availability`, `cpt-cf-bss-ledger-nfr-rto-rpo` + +**Integration contract**: `cpt-cf-bss-ledger-contract-erp-export` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-bss-ledger-actor-finance-approver` | Tenant Finance role: initiates period close; approves exceptions (incl. `GL_WRITEOFF_VARIANCE` acknowledge-to-approve); dual-control on period reopen | +| `cpt-cf-bss-ledger-actor-revenue-assurance` | Reviews reconciliation variance reports and the recon dashboard; triages variance tickets; close-block escalation workflow | +| `cpt-cf-bss-ledger-actor-finance-ops` | Works the exception queue: resolves or approves exceptions with reason + actor | +| `cpt-cf-bss-ledger-actor-erp-gl` | External ERP/GL: receives idempotent exports via the outbound gateway; returns ack/reject; GL of record in Mode A | +| `cpt-cf-bss-ledger-actor-payments-psp` | Supplies PSP settlement reports for the Payments↔PSP tie (control feed) | +| `cpt-cf-bss-ledger-actor-billing-orchestration` | Delivers the issued-invoice manifest and the bill-run-finished assertion — call-driven control feeds, never posting sources | +| `cpt-cf-bss-ledger-actor-cfo` | Cross-tenant recon rollups under Slice 6 elevated + audited context | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — § Reconciliation flows, § Source-of-truth (BSS/ERP, modes A/B), § Accounting periods and close, § Exceptions, § ERP duplicate-key disposition, AC #7/#12, manifest §4.4 +- **Design**: [01-repository-foundation.md](./01-repository-foundation.md) — the shared Foundation (journal, balance caches, commit trigger, lock order, idempotency, money, provisioning, data-access API) and the `ReconciliationFramework` / `ErpExporter` / `PeriodCloseGate` / `ExceptionQueue` components +- **Dependencies** (canonical slices → feature specs): S1 → [invoice-posting.md](./01a-invoice-posting.md), S2 → [payments-allocation.md](./03-payments-allocation.md), S3 → [adjustments-notes-refunds.md](./05-adjustments-notes-refunds.md), S4 → [asc606-recognition.md](./04-asc606-recognition.md), S5 → [fx-multicurrency.md](./06-fx-multicurrency.md), S6 → [audit-immutability-observability.md](./02-audit-immutability-observability.md); the repository Foundation is covered by [01-repository-foundation.md](./01-repository-foundation.md). "Slice N" references in this document resolve accordingly. + +### 1.5 Scope + +**Non-Goals / Out of Scope:** + +- **Full ERP as SoR** — optional; the manifest allows BSS-as-SoR with export (PRD). Slice 7 designs the **export + reconciliation boundary**, not the ERP internals. +- **BSS-originated financial corrections** — posted via S3/S4/reversal (Slice 3); Slice 7 **detects** the need (variance) and tickets, it does not post journal entries. +- **Customer-facing transaction history** — the Invoice/Payment services are SoR for human-readable history (PRD); the ledger is for finance/audit/tax/recon. +- **The per-slice posting tie-out contributions** — defined in Slices 1–5 (§4.10 etc.); Slice 7 **aggregates** them into the reconciliation framework + close gate. +- **Engine mechanics / alarm catalog definition** — Slices 1/6. + +**In Scope:** + +- dual-posting SoR (BSS subledger authoritative, ERP GL of record/mirror; modes A/B; no-shadow-edit; retry-no-drop; default variance ownership; Invoice/Payment customer-history SoR; RD-bss-erp) +- reconciliation flows tenant-scoped (AR↔derived tie-out + tolerance + variance-blocks-close AC #7; Ledger↔ERP idempotent export + ack/retry + variance report + failed-batch-queue; Payments↔PSP settlement tie) +- idempotent ERP export keyed by tuple + duplicate-key disposition (AC #12) +- period close (Finance-initiated, block on variance/open exceptions) +- exceptions (settled-no-match, export-failure, recon-mismatch, **missed-posting / invoice-completeness — **, late-payment-in-closed-period) +- reconciliation dashboard + exception queue UIs +- reconciliation-variance + failed-export-age alarms +- ERP-export-SLA + period-close-window NFRs +- net-vs-gross tax **views** in reconciliation + +### 1.6 Constraints & Assumptions + +Inherits Slices 1–6 (incl. B1 tax-presentation **resolved = gross** at posting). Slice-7-specific (defaults; open items → [§10](#10-needs-discussion)): + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| X1 | Export idempotency key | `(tenantId, sourceId, exportTarget, transactionId)`; export grain per Design (often invoice or journal entry). A re-export of the same key yields **identical business amounts** (AC #12); same key + different business amounts → alarm + block that key. (PRD spells the key with `invoiceId`; Design generalizes to `sourceId` under the PRD grain-per-Design allowance.) | PRD | +| X2 | Operating mode | **Default Mode A** (ERP = GL of record, BSS = authoritative subledger + exporter); BSS owns export replay + BSS-originated corrections; Finance owns GL-side true-up (RD-bss-erp). Mode is **deployment config**. **RACI made permanent 2026-06-10** (no longer an interim default). | PRD | +| X3 | Tax presentation **views** | Posting is **gross** (B1 resolved). Reconciliation supports **both gross and net/tax-split** views; **ratified 2026-06-10:** the per-jurisdiction view matrix is **Tax Engine config**, Slice 7 is a read-side consumer; default gross until the matrix fills. | PRD | +| X4 | AR tie-out tolerance | ≤ **1 minor unit per 1,000 posted lines** for rounding-only variance, **statutory floors override**; immaterial-rounding bucket + statutory registry per Design. | PRD | +| X5 | ERP export SLA / period-close window | **Ratified 2026-06-10:** journal-post → ERP ack **p95 ≤ 15 min** (X5a); close window **≤ 3 business days** (X5b). | PRD | + +### 1.7 Naming Conventions + +Reuses the PRD glossary and **inherits from Slices 1–6**. Design-introduced names (Slice 7): + +| Name | Meaning | +|------|---------| +| `export_record` **[NOT in v1 — deferred post-v1]** | One idempotent export of a posted artifact to a target, keyed `(tenantId, sourceId, exportTarget, transactionId)`; carries a `business_amount_hash` for same-key conflict detection. | +| `reconciliation_run` | One reconciliation check execution (AR↔derived, Payments↔PSP, invoice-completeness **in v1**; `Ledger↔ERP` / `GL` check types **deferred post-v1**) with a variance result. | +| `exception_queue` | Open exceptions that **block period close** (settled-no-match, export-failure **[`EXPORT_FAILURE` type NOT in v1 — deferred with the ERP-export half]**, mapping-gap, recon-mismatch, PSP-variance, split-ambiguous, recognition-policy-conflict, unscheduled-deferral, stuck-refund-clearing). `GL_WRITEOFF_VARIANCE` is an **acknowledge-to-approve** type: once Finance approves it, it does **not** block close. | +| `period_close` | Per-`(tenant, legal_entity, period)` close lifecycle (OPEN → CLOSING → CLOSED) with blocking reasons. | +| **Operating mode A / B** | A: ERP = GL of record, BSS = subledger+exporter. B: BSS = ledger of record, ERP = mirror. Deployment config. | + +### 1.8 System Context + +```mermaid +flowchart TB + subgraph upstream["Upstream"] + SLICES["Slices 1-6
posted journal + caches + per-slice tie-out facts + alarm catalog"] + PSP["Payments (PSP)
settlement reports"] + INV["Invoice / Billing-Orchestration
issued-invoice manifest (independent completeness source)"] + end + LEDGER["Billing Ledger — Reconciliation & ERP Export (Slice 7)
recon framework · idempotent export · period-close gate · exception queue · SoR boundary"] + subgraph downstream["Downstream"] + ERP["ERP / GL (external)
idempotent export + ack; GL of record (Mode A)"] + RA["Finance / Revenue Assurance
recon dashboard + exception queue"] + end + SLICES --> LEDGER + PSP --> LEDGER + INV -->|issued-invoice manifest| LEDGER + LEDGER -->|idempotent export, replay-safe| ERP + ERP -->|ack / reject| LEDGER + LEDGER --> RA +``` + +**Consumed:** posted journal + balance caches + **AR-affecting posted facts (S1–S4)** + the Slice 5 functional-balance column / FX-consistency control; PSP settlement reports (Payments); the **Invoice/Orchestration issued-invoice manifest** — the independent upstream source for the invoice-completeness check, a control feed only, never a posting source; the Slice 6 alarm catalog + secured audit + tenant-scoping. **(Ingestion model — call-driven):** the inbound control feeds (issued-invoice manifest, PSP settlement reports, and the post-MVP ERP trial-balance feed) are **call-driven** — the owning module delivers them via REST/control-feed, never an inbound bus on the post path (C3); these are control feeds, never posting sources. **Produced:** idempotent ERP exports; reconciliation variance reports; period-close gating; exception queue. + +Component layout (components are specified in [01-repository-foundation.md](./01-repository-foundation.md)): + +```mermaid +flowchart TB + subgraph engine["Slices 1-6 (inherited)"] + S1["posted journal · TieOutJob contributions · alarm catalog · audit/tenant-scope"] + end + subgraph s7["Slice 7"] + REC["ReconciliationFramework (AR / ERP / PSP)"] + EXP["ErpExporter (idempotent, retry, ack, duplicate-key)"] + CLOSE["PeriodCloseGate"] + EXQ["ExceptionQueue"] + end + DB[("PostgreSQL
+ export_record · reconciliation_run · exception_queue · period_close")] + OUTGW["Outbound API Gateway → ERP/GL"] + S1 --> REC + S1 -->|per-tenant watermark scan by created_seq| EXP + REC --> EXQ + REC --> CLOSE + EXP --> OUTGW + CLOSE --> EXQ + REC & EXP & CLOSE & EXQ --> DB +``` + +`ReconciliationFramework` runs the daily/period checks; `ErpExporter` is fed by a per-tenant watermark scan over committed entries ([ERP Export Watermark Scan](#erp-export-watermark-scan)) and exports via the outbound API gateway (replay-safe); `PeriodCloseGate` blocks close on variance/open exceptions; `ExceptionQueue` holds blocking items. + +### 1.9 SoR Boundary & Operating Modes + +- **Boundary-first SoR (manifest §4.4):** Billing is **SoR: BSS** for posted invoices + posted billing financials; the BSS subledger is authoritative for AR/revenue/contract-liability/posted-tax. Exports to ERP/GL are idempotent + replay-safe via the **outbound API gateway**. +- **Modes (X2):** **A** (default) — ERP = GL of record, BSS = subledger + exporter; **B** — BSS = ledger of record, ERP = mirror. Deployment config; the rules below hold in both. +- **No shadow-edit:** BSS does **not** edit a posted journal to match a failed ERP posting — variance is reported and, if a correction is genuinely needed, it is a BSS-originated **S3/S4/reversal** (Slice 3), never an in-place edit. **Retry, no drop:** BSS owns re-drive of failed exports with the same key; a posted fact is never dropped because export failed. **Variance ownership (permanent RACI, ratified 2026-06-10):** Finance owns GL-side true-up; BSS owns export re-drive and only corrections entered in BSS (S3/S4/reversal). +- **Customer-facing history** is the Invoice/Payment services' SoR, **not** the ledger (PRD). + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-bss-ledger-usecase-reconciliation-review`, `cpt-cf-bss-ledger-usecase-exception-resolution` + +### API Surface + +REST per `rest-api-design`, behind the inbound API gateway (reads); exports go via the **outbound** gateway. Reads tenant-scoped; cross-tenant elevated+audited (Slice 6). + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST` | `/v1/ledger/reconciliation-runs` | Trigger/schedule a reconciliation check for a period/scope. | per `(tenant, period_id, check_type, runId)` | +| `GET` | `/v1/ledger/reconciliation-runs/{runId}` | Read variance result. | — | +| `POST` | `/v1/ledger/exports` | Export posted artifacts to a target. **[NOT in v1 — deferred post-v1; see status note §1.2]** | per `(tenant, sourceId, exportTarget, transactionId)` | +| `GET` | `/v1/ledger/exports/{exportId}` | Read export status + ack. **[NOT in v1 — deferred post-v1]** | — | +| `GET` | `/v1/ledger/exceptions` | List open exceptions (dashboard/queue). | tenant-scoped | +| `POST` | `/v1/ledger/legal-entities/{legalEntityId}/periods/{periodId}/closure` | Finance-initiated period close (gated). Was `{periodId}:close` — colon custom methods don't route on axum 0.8 / matchit 0.8.4 (dynamic suffix unsupported). | per `(tenant, legal_entity, period_id)` | + +**Problem responses (RFC 9457):** `EXPORT_PAYLOAD_CONFLICT` (409 — same key, different business amounts) **[NOT in v1 — deferred with the ERP-export half]**, `PERIOD_CLOSE_BLOCKED` (409 — variance/open exceptions; body lists blocked reasons), `RECONCILIATION_VARIANCE` (surfaced as alarm/exception in v1; **the "422 if a blocking variance is forced" force-close path is NOT in v1 — deferred**), `CROSS_TENANT_ACCESS_DENIED` (403, Slice 6). A successful export replay returns the prior export reference (not an error). + +### Trigger Reconciliation Run + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-reconciliation-run` + +**Actor**: `cpt-cf-bss-ledger-actor-revenue-assurance` (also scheduled daily/period by the system) + +**Success Scenarios**: +- A reconciliation check runs for the period/scope, records `variance_minor` + `within_tolerance`, and completes `DONE` +- Result is readable at `GET /v1/ledger/reconciliation-runs/{runId}` and on the recon dashboard + +**Error Scenarios**: +- Out-of-tolerance variance → exception opened, Revenue Assurance ticketed, close blocked (`RECONCILIATION_VARIANCE` surfaced) +- Cross-tenant scope without Slice 6 elevated context → 403 `CROSS_TENANT_ACCESS_DENIED` + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/reconciliation-runs (period/scope, `check_type`), idempotent per `(tenant, period_id, check_type, runId)` - `inst-recon-api` +2. [ ] - `p1` - DB: INSERT `reconciliation_run` (status=RUNNING, tenant-scoped RLS) - `inst-recon-insert` +3. [ ] - `p1` - Algorithm: dispatch by `check_type` — AR_DERIVED → `cpt-cf-bss-ledger-algo-recon-ar-derived-tie-out`; LEDGER_ERP / GL → `cpt-cf-bss-ledger-algo-recon-ledger-erp`; PAYMENTS_PSP → `cpt-cf-bss-ledger-algo-recon-payments-psp-tie`; INVOICE_COMPLETENESS → `cpt-cf-bss-ledger-algo-recon-invoice-completeness` - `inst-recon-dispatch` +4. [ ] - `p1` - DB: UPDATE `reconciliation_run` with `variance_minor`, `within_tolerance` (tolerance per X4; statutory floors override), status=DONE (or FAILED) - `inst-recon-record` +5. [ ] - `p1` - **IF** variance exceeds tolerance - `inst-recon-if-variance` + 1. [ ] - `p1` - DB: INSERT `exception_queue` row (`RECON_MISMATCH` or check-specific type); alert + ticket Revenue Assurance; feed the close gate (**blocks close**) - `inst-recon-exception` + 2. [ ] - `p1` - Raise `reconciliation-variance` alarm (Warn → Page; Slice 6 catalog) - `inst-recon-alarm` +6. [ ] - `p1` - Emit `billing.ledger.reconciliation.completed` (`check_type`, `variance`) via the Slice 1 outbox - `inst-recon-event` +7. [ ] - `p1` - **RETURN** run result (variance report; gross and net/tax-split views per X3) - `inst-recon-return` + +### Finance-Initiated Period Close + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-period-close` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-approver` (tenant Finance role, Slice 6 RBAC) + +**Success Scenarios**: +- All gates pass; `fiscal_period` flips OPEN → CLOSED together with `period_close` CLOSING → CLOSED in the same commit; `billing.ledger.period.closed` emitted + +**Error Scenarios**: +- Variance above tolerance or open blocking exceptions → 409 `PERIOD_CLOSE_BLOCKED` with `blocked_reasons` listed +- An in-period post outruns the CLOSING recompute watermark → close fails back to CLOSING and the recompute re-runs pre-lock + +**Close mechanics (normative, two-phase — recompute before the lock, cheap checks under it):** + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/legal-entities/{legalEntityId}/periods/{periodId}/closure, idempotent per `(tenant, legal_entity, period_id)`; requires the tenant Finance role - `inst-close-api` +2. [ ] - `p1` - **CLOSING (pre-lock):** the expensive work runs while `fiscal_period` stays OPEN — a **period-scoped `reconciliation_run` computed in the close window** (not merely the latest nightly run), recording its **watermark** = the max in-period `created_seq` it covered - `inst-close-precompute` +3. [ ] - `p1` - **Flip (in-lock):** the close transaction takes the Foundation `fiscal_period` row `FOR UPDATE` and re-checks only **cheap monotonic predicates** - `inst-close-lock` + 1. [ ] - `p1` - recon watermark ≥ the then-current max in-period `created_seq` - `inst-close-check-watermark` + 2. [ ] - `p1` - zero OPEN close-blocking `exception_queue` rows - `inst-close-check-exceptions` + 3. [ ] - `p1` - no `journal_line.mapping_status = PENDING` (Slice 1 §4.2) - `inst-close-check-mapping` + 4. [ ] - `p1` - every recognition segment due ≤ N is `DONE` (Slice 4 §4.3) - `inst-close-check-recognition` +4. [ ] - `p1` - **IF** all predicates pass: flip `fiscal_period` `OPEN → CLOSED` together with `period_close CLOSING → CLOSED` in the **same commit**. **No recompute runs under the lock.** - `inst-close-flip` +5. [ ] - `p1` - **ELSE IF** any in-period post outran the watermark: the close **fails back to CLOSING** and the recompute re-runs pre-lock - `inst-close-failback` +6. [ ] - `p1` - **ELSE**: record blocked reasons on `period_close.blocked_reasons`; **RETURN** 409 `PERIOD_CLOSE_BLOCKED` (body lists blocked reasons); escalation via the Revenue Assurance workflow - `inst-close-blocked` +7. [ ] - `p1` - Emit `billing.ledger.period.closed` via the Slice 1 outbox - `inst-close-event` +8. [ ] - `p1` - **RETURN** close result - `inst-close-return` + +Concurrent posts take the same `fiscal_period` lock (`FOR SHARE`, Foundation §4.9) to assert OPEN, so they serialize against the flip only for the **cheap** in-lock checks — posting write p95 is unaffected by the recompute; under a sustained bill run the close drains via a bounded `lock_timeout` / drain window until the watermark catches up (promoted from a Foundation §4.9 risk note to a committed close mechanism). **(Impl note 2026-06-29 — realization:** the toolkit `SecureORM` exposes no row-lock primitive, so the literal `FOR UPDATE` / `FOR SHARE` two-phase is realized as a single-active **`coord` lease + one `SERIALIZABLE` (SSI) transaction** — the pre-close tie-out reads the journal lines a concurrent post writes, so Postgres SSI aborts the loser, which is **correctness-equivalent** for the close-vs-post race (pinned by a concurrency testcontainer). Consequently the recompute currently runs **inside** the txn; the design's **recompute-outside-lock** optimization and its **"posting p95 unaffected"** NFR are a **tracked follow-up**, not yet realized.**)** + +Close is **blocked** while: + +- AR-derived (or any) reconciliation **variance for the period exceeds tolerance** (X4); or +- required **exception queues** are open (unmapped accounts, failed exports above threshold, unresolved PSP variance, stuck refund clearing, unscheduled deferrals); or +- any **recognition segment due ≤ N is not `DONE`** — the period-N gate waits for the period's recognition run; a release entry posts with the segment's `period_id` while N is OPEN, and a segment that nonetheless misses close posts into the current open period with the original period as audit linkage (mechanism owned by Slice 4 §4.3/§4.6); or +- **open foreign-currency AR not revalued (Mode B):** a **Mode B** ledger-owner (BSS = ledger of record) with **open multi-currency AR** cannot close until the period's **FX revaluation run has completed** for that owner — an un-revalued open-FX-AR position blocks close and raises `fx-revaluation-incomplete`. In **Mode A** the ERP runs revaluation; the Slice 7 BSS↔ERP FX reconciliation confirms it happened (the mode config flag alone is **not** evidence). Owns PRD AC #33; the revaluation run itself is Slice 5 §4.5. + +**(🔄 2026-06-15; 🔄) Close-after-bill-run — in-ledger gate.** Orchestration MUST complete the period's **bill run before close is initiated**, so earned-but-unbilled usage does not sit outside the period (the contract-asset gap is out of scope for MVP, PRD § Deferred). **In-ledger enforcement:** the close gate checks a per-`(tenant, period)` **bill-run-finished assertion** — a **call-driven control signal** Orchestration posts when the period's bill run completes (call-driven ingestion; a control feed, never a posting source). **Close blocks** until that assertion is present, so a period whose billing **never ran** cannot close silently — covering *not-yet-run* billing, complementing the `MISSED_POSTING` check that covers *lost* posts. **Residual MVP risk (normative, stated in the close-gate contract):** until **both** the bill-run-finished signal **and** the manifest feed are live, completeness leans on runbook discipline — close cannot fully guarantee that the period's billing both ran and arrived. + +**Late / closed-period handling:** material backdating into a closed period follows the Slice 1 `FiscalPeriodGuard` exception path; a late-arriving payment with a closed-period settlement timestamp **posts to the current open period** with the closed period as audit linkage (Slice 1 clock-skew / closed-period policy + source-timestamp metadata). A payment in period N+1 allocated to a **closed-period-N** open invoice is reconciled at the **AR-class grain cumulatively per payer** (the AR tie-out is not period-isolated), and the Payments↔PSP tie nets such cross-period allocations so neither period shows a phantom variance. The close window is committed (X5b): **≤ 3 business days**. + +### Resolve or Approve Exception + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-exception-resolution` + +**Actor**: `cpt-cf-bss-ledger-actor-finance-ops` (Operations/Finance; approvals: `cpt-cf-bss-ledger-actor-finance-approver`) + +**Success Scenarios**: +- Exception resolved (or Finance-approved) with reason + actor; close unblocks when no blocking rows remain + +**Error Scenarios**: +- Aged blocking exception → alarm pages before the close window + +**Steps**: +1. [ ] - `p1` - API: GET /v1/ledger/exceptions (dashboard/queue, tenant-scoped) - `inst-exc-list` +2. [ ] - `p1` - Operator resolves per exception type (routing rules below); resolution requires Operations/Finance role with reason + actor - `inst-exc-resolve` +3. [ ] - `p1` - **IF** type is `GL_WRITEOFF_VARIANCE`: Finance **acknowledges → `APPROVED_EXCEPTION`**; it then does **not** block close (tracked until the bad-debt successor PRD ships) - `inst-exc-glwriteoff` +4. [ ] - `p1` - DB: UPDATE `exception_queue.status` (OPEN → ACK → RESOLVED / APPROVED_EXCEPTION) - `inst-exc-update` +5. [ ] - `p1` - **RETURN** updated exception; close gate re-evaluates open blocking rows - `inst-exc-return` + +`ExceptionQueue` holds blocking items (Operations/Finance resolve or approve): + +- **Settled-no-match:** a settled payment with no allocation match stays in unallocated (Slice 2) until matched/refunded/on-account; aged → alert. +- **Export failure:** retry-safe, alert, queue — no silent drop ([ERP Export Replay & Duplicate-Key Disposition](#erp-export-replay--duplicate-key-disposition)). +- **Reconciliation mismatch:** variance report + ticket; **blocks close** until resolved or an approved exception is recorded. +- **Mapping gap / suspense:** the Slice 1 `mapping_status=PENDING` (route-to-suspense) lines surface here and block close (Slice 1 §4.2). +- **Stuck refund clearing (`STUCK_REFUND_CLEARING`, close-blocking):** a stage-1 refund whose `REFUND_CLEARING` stays unrelieved beyond the aging thresholds (**7 d Warn, 14 d Page**) opens this exception (emitted by the Slice 3 aging alarm and by the [Payments to PSP Tie](#payments-to-psp-tie)). Resolution is the Slice 3 dual-control terminal `unknown_final` disposition (clears `REFUND_CLEARING` to a documented loss line + secured-audit record, Slice 3 §4.4); Slice 7 detects and blocks, never posts. +- **Routed from other slices:** `SPLIT_AMBIGUOUS` (Slice 3 §4.2 split-ambiguity), `RECOGNITION_POLICY_CONFLICT` and `UNSCHEDULED_DEFERRAL` (Slice 4 §4.2) park here and block close like any open exception. +- **Missed posting (`MISSED_POSTING`, close-blocking):** an `invoiceId` in the upstream issued-invoice manifest with **no** committed `INVOICE_POST` entry (lost / never-delivered post message), or a manifest count / amount-total mismatch. Opened by the independent invoice-completeness check, keyed by the missing `invoiceId`. Resolution is the upstream's **idempotent re-post** (Slice 1 at-most-once makes replay safe) or a Finance-approved exception. Blocks close; a `MISSED_POSTING` aged past the configurable threshold pages via `reconciliation-variance` so it is caught well before the close window. + +### Trigger ERP Export / Read Export Status + +> **⚠️ NOT in v1 — this entire flow (and the [ERP Export Watermark Scan](#erp-export-watermark-scan) / [Replay & Duplicate-Key Disposition](#erp-export-replay--duplicate-key-disposition) / [Idempotent ERP Export](#idempotent-erp-export) sections) is deferred post-v1.** See the status note in §1.2. v1 has no `/exports` surface, no `export_record`, no `ErpExporter`, no `EXPORT_PAYLOAD_CONFLICT` / `EXPORT_FAILURE`, and no `billing.ledger.export.acked`. + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-flow-erp-export` + +**Actor**: `cpt-cf-bss-ledger-actor-erp-gl` (target; export operations require the integration/finance scope). Normally driven by the background watermark scan; the endpoint triggers/re-drives explicitly. + +**Success Scenarios**: +- Posted artifacts exported to the target; ERP ack matched; `export_record` → ACKED; `billing.ledger.export.acked` emitted +- A successful export replay returns the prior export reference (not an error) + +**Error Scenarios**: +- Same key + different business amounts → 409 `EXPORT_PAYLOAD_CONFLICT`, alarm, key blocked +- ERP reject → actionable reason → `EXPORT_FAILURE` exception; retry queue, never dropped + +**Steps**: +1. [ ] - `p1` - API: POST /v1/ledger/exports (posted artifacts → target), idempotent per `(tenant, sourceId, exportTarget, transactionId)` (X1) - `inst-exp-api` +2. [ ] - `p1` - Algorithm: create/refresh `PENDING` `export_record` rows via `cpt-cf-bss-ledger-algo-erp-export-cursor` (background feed) or this explicit request - `inst-exp-feed` +3. [ ] - `p1` - Algorithm: export + ack + replay/duplicate-key handling via `cpt-cf-bss-ledger-algo-erp-export-replay` - `inst-exp-drive` +4. [ ] - `p1` - API: GET /v1/ledger/exports/{exportId} — read export status + ack - `inst-exp-status` +5. [ ] - `p1` - **RETURN** export status (`PENDING | ACKED | FAILED`, `ack_ref`) - `inst-exp-return` + +## 3. Processes / Business Logic (CDSL) + +### Reconciliation Framework Checks + +Tenant-scoped by default (cross-tenant rollups are Slice-6 elevated, audited). Checks: + +| Check | Sources | Outcome | +|-------|---------|---------| +| **AR ↔ derived (AC #7)** | AR cache vs recomputed sum of **actual AR-class `journal_line` deltas** (signs from the posted lines, **not** inferred from flow/phase names): **(S1 + S4 + AR-restoring refunds + chargeback Lost/Partial-or-split + AR-restoring reversals) − S2 allocations − S3 credit notes − CreditApplication Apply-to-AR − reversing entries**. AR-neutral and **excluded**: the dispute-opened sub-class move, the AR-reclass variant of chargeback-Won, and the Grant-credit wallet shape. | Variance > tolerance (X4) → alert + ticket Revenue Assurance + **block close** | +| **Ledger ↔ ERP** | **MVP = ack-level** — each exported entry reconciled to its ERP **ack** (failed / aged → exception). **Full BSS-TB vs ERP-TB reconciliation is post-MVP**: it needs an inbound **ERP trial-balance feed** (cadence, ERP→BSS account mapping, storage) that is **not yet designed** (the recurring inbound-feed concern). | MVP: export/ack variance (`export-failed-aged`). Post-MVP: trial-balance variance report per mode / RACI (X2) | +| **Payments ↔ PSP** | Ledger Cash/clearing + **`REFUND_CLEARING`** (each stage-1 refund tied to its PSP refund settlement/outcome) + unallocated pool vs PSP settlement reports (net of allocations) | Variance **beyond a rounding tolerance** (shares the X4 budget — exact-match is brittle for cross-system penny rounding) → exception; unrelieved stage-1 clearing past the aging thresholds → `STUCK_REFUND_CLEARING` | +| **GL / invoice-completeness** *(anchored to the block-close missed-posting obligation; the invoice-completeness leg is specified below)* | **GL:** BSS GL postings vs ERP ack. **Invoice-completeness:** the **independent** Invoice/Orchestration **issued-invoice manifest** (issued `invoiceId` set + control totals) vs the set posted to the ledger | GL: missed-posting / incompleteness exception. Invoice-completeness: a `MISSED_POSTING` exception per missing `invoiceId` → **blocks close** | + +Variance/tolerance: X4 (rounding-only ≤ 1 minor unit per 1,000 lines; statutory floors override). FX-consistency-vs-external-rate variance (the Slice 5 deferred control) lives here. Tax: reconciliation supports **gross and net/tax-split views** (X3); per-`(rate, jurisdiction, filing-period)` disaggregation from Slice 3 feeds filing reconciliation. + +### AR to Derived Tie-Out + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-recon-ar-derived-tie-out` + +**Input**: tenant, period/scope; AR cache; posted `journal_line`s + +**Output**: `variance_minor` + `within_tolerance`; out-of-tolerance → exception + close block + +**Steps**: +1. [ ] - `p1` - DB: read the AR balance cache for the scope - `inst-ar-read-cache` +2. [ ] - `p1` - DB: recompute the derived AR projection as the sum of **actual AR-class `journal_line` deltas** — signs from the posted lines, **not** inferred from flow/phase names - `inst-ar-recompute` +3. [ ] - `p1` - Apply the formula: (S1 + S4 + AR-restoring refunds + chargeback Lost/Partial-or-split + AR-restoring reversals) − S2 allocations − S3 credit notes − CreditApplication Apply-to-AR − reversing entries - `inst-ar-formula` +4. [ ] - `p1` - Exclude AR-neutral shapes: the dispute-opened sub-class move, the AR-reclass variant of chargeback-Won, and the Grant-credit wallet shape - `inst-ar-exclusions` +5. [ ] - `p1` - Compare cache vs projection; evaluate tolerance per X4 (≤ 1 minor unit per 1,000 posted lines rounding-only; statutory floors override; immaterial-rounding bucket + statutory registry) - `inst-ar-tolerance` +6. [ ] - `p1` - **IF** variance > tolerance: alert + ticket Revenue Assurance + **block close** - `inst-ar-block` +7. [ ] - `p1` - **RETURN** variance result (cumulative per payer at the AR-class grain — the tie-out is not period-isolated) - `inst-ar-return` + +### Ledger to ERP Reconciliation + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-recon-ledger-erp` + +**Input**: `export_record` set for the scope; ERP acks + +**Output**: export/ack variance; exceptions for failed/aged exports + +**Steps**: +1. [ ] - `p1` - **MVP = ack-level:** reconcile each exported entry to its ERP **ack** - `inst-erp-ack-level` +2. [ ] - `p1` - **IF** an export is failed or aged: open an exception; raise `export-failed-aged` - `inst-erp-failed-aged` +3. [ ] - `p1` - **RETURN** export/ack variance report - `inst-erp-return` +4. [ ] - `p3` - **Post-MVP (🔮):** full BSS-TB vs ERP-TB reconciliation — needs an inbound **ERP trial-balance feed** (cadence, ERP→BSS account mapping, storage) that is **not yet designed**; trial-balance variance report per mode / RACI (X2) - `inst-erp-postmvp` + +### Payments to PSP Tie + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-recon-payments-psp-tie` + +**Input**: ledger Cash/clearing + `REFUND_CLEARING` + unallocated pool; PSP settlement reports + +**Output**: variance beyond rounding tolerance → exception; aged clearing → `STUCK_REFUND_CLEARING` + +**Steps**: +1. [ ] - `p1` - Compare ledger Cash/clearing + **`REFUND_CLEARING`** (each stage-1 refund tied to its PSP refund settlement/outcome) + unallocated pool vs PSP settlement reports (net of allocations) - `inst-psp-compare` +2. [ ] - `p1` - **IF** variance beyond a rounding tolerance (shares the X4 budget — exact-match is brittle for cross-system penny rounding): open an exception - `inst-psp-variance` +3. [ ] - `p1` - **IF** stage-1 clearing unrelieved past the aging thresholds (7 d Warn, 14 d Page): open `STUCK_REFUND_CLEARING` - `inst-psp-stuck` +4. [ ] - `p1` - Net cross-period allocations (payment in N+1 against a closed-period-N open invoice) so neither period shows a phantom variance - `inst-psp-crossperiod` +5. [ ] - `p1` - **RETURN** tie result - `inst-psp-return` + +### Invoice Completeness Check + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-recon-invoice-completeness` + +**Input**: the Invoice/Orchestration **issued-invoice manifest** per `(tenant, period)` (issued `invoiceId` set + control totals: count + summed gross amount); the ledger's committed `INVOICE_POST` `source_business_id` set + +**Output**: `MISSED_POSTING` exceptions (close-blocking); control-total mismatch flags + +**Invoice completeness — upstream→ledger (normative).** The ledger is **passive** — it posts what it receives (Foundation §2) — so a post message that is lost, dropped, or never emitted creates **no** entry, and neither idempotency (at-most-once on a business key — catches **duplicates**, not **omissions**) nor the internal AR tie-out (cache↔lines, both derived from the **same** posted lines) can see the gap. This check closes that hole with an **independent** source. + +**Steps**: +1. [ ] - `p1` - Receive the issued-invoice manifest per `(tenant, period)` from Invoice/Billing-Orchestration — the authoritative set of `invoiceId`s it issued, with **control totals** (count + summed gross amount); a **control feed only**, never a posting source, delivered call-driven - `inst-cmpl-manifest` +2. [ ] - `p1` - Compute the **set difference** `issued − posted` (posted = the `INVOICE_POST` `source_business_id`s committed to `journal_entry`) and reconcile the control totals - `inst-cmpl-setdiff` +3. [ ] - `p1` - **FOR EACH** issued `invoiceId` with no committed entry — or on a count / amount-total mismatch - `inst-cmpl-foreach` + 1. [ ] - `p1` - DB: open a **`MISSED_POSTING`** exception (`exception_queue`) keyed by the missing `invoiceId`; **blocks period close** until the post arrives (the upstream's **idempotent** re-post — Slice 1 at-most-once makes replay safe) or a Finance-approved exception is recorded - `inst-cmpl-exception` +4. [ ] - `p1` - **Latency:** run **near-real-time** off a per-tenant watermark over the manifest — a `MISSED_POSTING` aged past a configurable threshold pages via the `reconciliation-variance` alarm, so detection is **not** deferred to the close window — **and** again as the mandatory pre-close gate - `inst-cmpl-latency` +5. [ ] - `p1` - **RETURN** completeness result - `inst-cmpl-return` + +The manifest is **not** customer-facing invoice history (that SoR stays with the Invoice/Payment services, [§1.5](#15-scope)) and the ledger never posts from it — it posts only the validated `INVOICE_POST` it receives. **Independence is load-bearing:** the manifest MUST come from the issuing service, never be re-derived from the ledger's own posted set, or the check becomes circular and cannot detect an omission. **Launch-blocking dependency:** the manifest-feed delivery is a **launch-blocking cross-team commitment** (owner: Invoice/Orchestration) — to be scheduled now. Until it lands the `MISSED_POSTING` check is **inert** and the close gate cannot guarantee no lost posts; that residual risk is stated in the close-gate contract ([Finance-Initiated Period Close](#finance-initiated-period-close)). + +### ERP Export Watermark Scan + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-erp-export-cursor` + +**Input**: committed journal entries ordered by `(tenant, created_seq)`; per-tenant watermark + +**Output**: `PENDING` `export_record` rows (the feed for `ErpExporter`) + +**Feed (what creates `export_record`, normative):** a per-tenant **watermark scan** over committed journal entries ordered by `(tenant, created_seq)` creates `PENDING` `export_record` rows. + +**Steps**: +1. [ ] - `p1` - DB: scan committed journal entries ordered by `(tenant, created_seq)` above the per-tenant watermark - `inst-scan-read` +2. [ ] - `p1` - **The watermark MUST be visibility-aware**, not a raw `created_seq > N` cursor: `created_seq` is assigned at INSERT, not COMMIT (Foundation §4.11), so a lower-seq entry can commit *after* a higher-seq one (large entry, hot-row lock wait, sync-replication lag); a raw cursor would advance past it and **never export that committed entry** — a silently missing financial fact in ERP - `inst-scan-visibility` +3. [ ] - `p1` - Advance the scan only up to the **snapshot xmin horizon** (`pg_snapshot_xmin(pg_current_snapshot)`) — never past a `created_seq` that could still belong to an in-flight transaction; entries above the horizon are exported on the next pass - `inst-scan-horizon` +4. [ ] - `p1` - DB: INSERT `PENDING` `export_record` rows for the covered entries - `inst-scan-insert` +5. [ ] - `p1` - Guarantee: an **original is always exported before its reversal** (a reversal's `created_seq` is strictly greater than its original's, and a reversal cannot exist until its original committed) - `inst-scan-ordering` +6. [ ] - `p1` - **RETURN** advanced watermark. This scan — not outbox consumption — is the completeness source for the 15-min SLA (X5a). Internal outbox consumption (per-tenant FIFO publication by `created_seq`, consumer registry with idempotency keys + lag alarms) is owned by Foundation §7.2 - `inst-scan-return` + +### ERP Export Replay & Duplicate-Key Disposition + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-erp-export-replay` + +**Input**: `export_record` rows not in terminal `ACKED` state + +**Output**: `ACKED` / `FAILED` export records; `EXPORT_FAILURE` exceptions; `EXPORT_PAYLOAD_CONFLICT` alarms + +`ErpExporter` exports posted artifacts via the outbound API gateway, keyed by `(tenantId, sourceId, exportTarget, transactionId)` (X1): + +**Steps**: +1. [ ] - `p1` - **Idempotent + replay-safe (AC #12):** a successful replay is **indistinguishable** from the first success (same business amounts to GL). The `export_record` carries a `business_amount_hash`; a replay with the **same key + same hash** is a no-op success - `inst-rep-idem` +2. [ ] - `p1` - **IF** same key + different hash: raise an alarm and **block further exports of that key** pending operator review (mirrors the Slice 1 AC #19 conflict pattern); 409 `EXPORT_PAYLOAD_CONFLICT` - `inst-rep-conflict` +3. [ ] - `p1` - **Ack:** ERP returns an ack matched to `transaction_id`, **at-most-once**; on reject, an actionable reason → exception (`EXPORT_FAILURE`) - `inst-rep-ack` +4. [ ] - `p1` - **Retry / no drop (durable):** exports are driven from a durable **outbox row written in the same transaction** that persists/updates `export_record`; a re-driver re-picks any `export_record` not in terminal `ACKED` state, keyed by the PK so re-drive is idempotent (same key+hash = no-op at ERP). A posted source fact **MUST NOT** be deleted; failed batches **MUST** queue for retry; an aged failed export raises the `export-failed-aged` alarm (Slice 6 catalog) - `inst-rep-retry` +5. [ ] - `p1` - **ERP duplicate-key disposition:** **IF** ERP returns "already posted" for a key BSS believes is fresh (state-store restore, ack-loss retry): treat as **idempotent success** — reconcile `export_record` to `ACKED` **without** posting new BSS journal lines; log both BSS + ERP state for audit; a mismatched payload for the same key alarms + blocks further exports of that key - `inst-rep-dupkey` +6. [ ] - `p1` - Same-key-different-amount detection on the duplicate-key path requires the ERP duplicate response to carry the prior amount/hash; where the connector cannot, the divergence **would** be caught by the pre-close Ledger↔ERP trial-balance reconciliation — but **that full TB reconciliation is post-MVP** (it needs the undefined inbound ERP-TB feed), so in **MVP** a same-key-different-amount on a connector that cannot return the prior amount/hash is a **documented detection gap** pending the ERP-TB feed; the local `business_amount_hash` still catches BSS-side payload drift for the same key ([§10](#10-needs-discussion)) - `inst-rep-gap` +7. [ ] - `p1` - **(/ S7-minor) Connector-capability requirement (stated):** an ERP connector **SHOULD** return the prior amount/hash on a duplicate-key "already posted" response so BSS can detect same-key-different-amount at the connector level; this capability is **documented per connector**, and where a connector lacks it the MVP detection gap above applies (full TB-recon detection is the post-MVP backstop) - `inst-rep-connector` +8. [ ] - `p1` - **RETURN** export outcome + emit `billing.ledger.export.acked` on success - `inst-rep-return` + +### Restore & Re-Sync Runbook + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-algo-restore-resync` + +**Input**: a cell restore (PITR/backup) that can rewind ledger state behind already-acked ERP exports and already-published events + +**Output**: re-synced export state; re-anchored tamper chain; `restore-event` audit record + +Normative recovery sequence: + +**Steps**: +1. [ ] - `p1` - **Deterministic export identity:** the export `transaction_id` is **derived deterministically from `(tenant, sourceId, source document version)`** — never minted fresh — so a post-restore re-post of the same source document reproduces the same export key and the ERP duplicate-key disposition resolves it as idempotent success (no silent ERP double-posting) - `inst-rst-identity` +2. [ ] - `p1` - **Export freeze + mandatory reconciliation:** after any restore, export for the affected scope is **disabled**; it is re-enabled **only after a mandatory Ledger↔ERP reconciliation** over the affected periods. ERP acks newer than the restore point are reconciled into `export_record` as `ACKED` (duplicate-key disposition), never re-driven blindly - `inst-rst-freeze` +3. [ ] - `p1` - **Chain re-anchor + audit:** the tamper-evidence chain is re-anchored at the last WORM checkpoint at or before the restore point, and a `restore-event` secured-audit record is written — both owned by Slice 6 (§4.8 checkpoints, §4.4 secured audit) - `inst-rst-chain` + +## 4. States (CDSL) + +### Period Close State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-period-close` + +**States**: OPEN, CLOSING, CLOSED, REOPENED + +**Initial State**: OPEN + +**Transitions**: +1. [ ] - `p1` - **FROM** OPEN **TO** CLOSING **WHEN** the tenant Finance role initiates close (POST …/periods/{periodId}/closure); the pre-lock period-scoped recompute runs - `inst-pc-open-closing` +2. [ ] - `p1` - **FROM** CLOSING **TO** CLOSED **WHEN** all in-lock cheap monotonic predicates pass; flips together with the Foundation `fiscal_period` `OPEN → CLOSED` in the same commit - `inst-pc-closing-closed` +3. [ ] - `p1` - **FROM** CLOSING **TO** CLOSING **WHEN** an in-period post outruns the recompute watermark — close fails back and the recompute re-runs pre-lock (no recompute under the lock) - `inst-pc-failback` +4. [ ] - `p1` - **FROM** CLOSED **TO** REOPENED **WHEN** a formal reopen is approved — flips `fiscal_period` back to `OPEN`; gated by dual-control and written as a Slice 6 `period-reopen` secured-audit record (who/when/why); no posting carries a closed-period effective date without this transition - `inst-pc-reopen` +5. [ ] - `p1` - **FROM** REOPENED **TO** CLOSING **WHEN** post-reopen corrections are posted and close is re-initiated (`REOPENED→CLOSING→CLOSED`) - `inst-pc-reclose` + +### Export Record State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-export-record` + +**States**: PENDING, ACKED, FAILED + +**Initial State**: PENDING + +**Transitions**: +1. [ ] - `p1` - **FROM** PENDING **TO** ACKED **WHEN** ERP acks the export matched to `transaction_id` (at-most-once), or ERP returns duplicate-key "already posted" (idempotent success, no new BSS lines), or a post-restore ack newer than the restore point is reconciled in - `inst-ex-acked` +2. [ ] - `p1` - **FROM** PENDING **TO** FAILED **WHEN** ERP rejects with an actionable reason → `EXPORT_FAILURE` exception - `inst-ex-failed` +3. [ ] - `p1` - **FROM** FAILED **TO** PENDING **WHEN** the re-driver re-picks the record (any non-`ACKED` state; PK-keyed, idempotent re-drive); failed rows queue for retry and are never deleted; aged → `export-failed-aged` - `inst-ex-redrive` + +`ACKED` is terminal. Same key + different `business_amount_hash` blocks further exports of that key pending operator review (no state advance). + +### Exception State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-state-exception-queue` + +**States**: OPEN, ACK, RESOLVED, APPROVED_EXCEPTION + +**Initial State**: OPEN + +**Transitions**: +1. [ ] - `p1` - **FROM** OPEN **TO** ACK **WHEN** Operations/Finance acknowledges the exception (reason + actor recorded) - `inst-eq-ack` +2. [ ] - `p1` - **FROM** OPEN **TO** RESOLVED / **FROM** ACK **TO** RESOLVED **WHEN** the underlying cause clears (e.g. upstream idempotent re-post clears `MISSED_POSTING`; `unknown_final` disposition clears `STUCK_REFUND_CLEARING`) - `inst-eq-resolved` +3. [ ] - `p1` - **FROM** OPEN/ACK **TO** APPROVED_EXCEPTION **WHEN** Finance approves per policy — for `GL_WRITEOFF_VARIANCE` this is the normal acknowledge-to-approve path, after which it does **not** block close - `inst-eq-approved` + +Open rows **block period close** — **except** `GL_WRITEOFF_VARIANCE` once `APPROVED_EXCEPTION`. + +## 5. Data Model (Owned Tables) + +Adds `export_record`, `reconciliation_run`, `exception_queue`, `period_close`; tenant-scoped RLS (C1). This feature posts **no** financial journal entries → **no balance-cache lock rank**. (Per the schema-ownership rule, handler slices own only their own domain tables and post through the Foundation data-access API; there are no cross-slice `ALTER`s.) + +### 5.1 export_record + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | uuid | PK part | +| `source_id` | string | PK part — invoiceId or journal entry id | +| `export_target` | string | PK part — ERP system id | +| `transaction_id` | string | PK part — derived deterministically from `(tenant, sourceId, source document version)` (restore runbook) | +| `business_amount_hash` | string | detects same-key conflicting payload (→ alarm + block key) | +| `status` | enum | `PENDING \| ACKED \| FAILED` | +| `ack_ref` | string | ERP ack reference | +| `retry_count` | int | re-drive counter | +| `last_attempt_at` | timestamptz | backs the re-driver scan | + +PK `(tenant_id, source_id, export_target, transaction_id)` — the idempotency key (X1, AC #12). Failed → retry queue, never deleted. Partial index `(tenant_id, last_attempt_at) WHERE status <> 'ACKED'` backs the re-driver scan, the `export-failed-aged` alarm, and the 15-min SLA (X5a). + +### 5.2 reconciliation_run + +| Column | Type | Notes | +|--------|------|-------| +| `run_id` | uuid | run identity | +| `tenant_id` | uuid | tenant-scoped (RLS) | +| `period_id` | string | period scope | +| `check_type` | enum | `AR_DERIVED \| LEDGER_ERP \| PAYMENTS_PSP \| GL \| INVOICE_COMPLETENESS` | +| `variance_minor` | bigint | per X4 | +| `within_tolerance` | bool | per X4 | +| `status` | enum | `RUNNING \| DONE \| FAILED` | +| `at_utc` | timestamptz | run timestamp | + +An out-of-tolerance run opens an `exception_queue` row and feeds the close gate. + +### 5.3 exception_queue + +| Column | Type | Notes | +|--------|------|-------| +| `exception_id` | uuid | identity | +| `tenant_id` | uuid | tenant-scoped (RLS) | +| `type` | enum | `SETTLED_NO_MATCH \| EXPORT_FAILURE \| MAPPING_GAP \| RECON_MISMATCH \| PSP_VARIANCE \| SPLIT_AMBIGUOUS \| RECOGNITION_POLICY_CONFLICT \| UNSCHEDULED_DEFERRAL \| STUCK_REFUND_CLEARING \| SETTLEMENT_RETURN_OVER_ALLOCATED \| CHARGEBACK_ON_REFUNDED \| GL_WRITEOFF_VARIANCE \| MISSED_POSTING` | +| `ref` | string | reference to the offending artifact (e.g. missing `invoiceId`) | +| `status` | enum | `OPEN \| ACK \| RESOLVED \| APPROVED_EXCEPTION` | +| `opened_at` | timestamptz | opening time (aging thresholds) | + +Types SETTLED_NO_MATCH / EXPORT_FAILURE / MAPPING_GAP / RECON_MISMATCH / PSP_VARIANCE, extended additively (C2) with SPLIT_AMBIGUOUS (emitted by Slice 3 §4.2), RECOGNITION_POLICY_CONFLICT + UNSCHEDULED_DEFERRAL (Slice 4 §4.2), STUCK_REFUND_CLEARING (Slice 3 aging / Slice 7 PSP tie), SETTLEMENT_RETURN_OVER_ALLOCATED + CHARGEBACK_ON_REFUNDED (Slice 2 §4.2/§4.5 — money-out routes), GL_WRITEOFF_VARIANCE (— a BSS↔ERP AR variance from a GL-side write-off while bad-debt is out of BSS scope), MISSED_POSTING (— an issued invoice with no committed `INVOICE_POST`, from the invoice-completeness check); open rows **block period close** — **except** `GL_WRITEOFF_VARIANCE`, which Finance **acknowledges → `APPROVED_EXCEPTION`** and which then does **not** block close (tracked until the bad-debt successor PRD ships). **(/ S7-minor) Running-divergence expectation (stated):** each approved `GL_WRITEOFF_VARIANCE` leaves the BSS subledger AR **knowingly overstating** vs ERP by the written-off amount — a **standing, accumulating** BSS↔ERP gap until the bad-debt PRD lands. **Finance owns monitoring its growth** via the aggregate of open/approved `GL_WRITEOFF_VARIANCE` rows (a reported running total, not a silently growing drift). + +### 5.4 period_close + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | uuid | PK part | +| `legal_entity_id` | uuid | PK part | +| `period_id` | string | PK part | +| `status` | enum | `OPEN \| CLOSING \| CLOSED \| REOPENED` | +| `initiated_by` | string | tenant Finance role | +| `blocked_reasons` | jsonb | recorded blocking reasons | +| `closed_at` | timestamptz | close completion | + +PK `(tenant_id, legal_entity_id, period_id)`; `OPEN→CLOSING→CLOSED`; close consistent with the Foundation `fiscal_period` (close flips both, gated by this feature). Changing a CLOSED period requires a formal **reopen**: `CLOSED→REOPENED` (flips `fiscal_period` back to `OPEN`) → post → `REOPENED→CLOSING→CLOSED`, gated by dual-control and written as a Slice 6 `period-reopen` secured-audit record (who/when/why); no posting carries a closed-period effective date without this transition. + +### 5.5 Cross-Table Rules + +- **No shadow-edit:** **no** application-role path updates a posted `journal_line`/`journal_entry` to match ERP (Slice 1 REVOKE + BEFORE UPDATE/DELETE trigger); owner/superuser/migration access is out-of-band and **detected, not prevented**, via the Slice 6 tamper-evidence chain + `scope_freeze`. Corrections are S3/S4/reversal. +- `exception_queue` + `reconciliation_run` are tenant-scoped (RLS). + +## 6. Definitions of Done + +### Reconciliation Framework + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-recon-framework` + +The system **MUST** run the tenant-scoped reconciliation checks (AR↔derived per AC #7, Ledger↔ERP ack-level MVP, Payments↔PSP incl. `REFUND_CLEARING`, GL / invoice-completeness per) with X4 tolerance + statutory floors, opening exceptions and blocking close on out-of-tolerance variance, and supporting gross and net/tax-split views (X3). + +**Implements**: +- `cpt-cf-bss-ledger-flow-reconciliation-run` +- `cpt-cf-bss-ledger-algo-recon-ar-derived-tie-out` +- `cpt-cf-bss-ledger-algo-recon-ledger-erp` +- `cpt-cf-bss-ledger-algo-recon-payments-psp-tie` +- `cpt-cf-bss-ledger-algo-recon-invoice-completeness` + +**Touches**: +- API: `POST /v1/ledger/reconciliation-runs`, `GET /v1/ledger/reconciliation-runs/{runId}` +- DB Table: `reconciliation_run`, `exception_queue` +- Entities: `ReconciliationRun` + +### Idempotent ERP Export + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-erp-export` + +The system **MUST** export posted artifacts via the outbound API gateway keyed by `(tenantId, sourceId, exportTarget, transactionId)` (X1), fed by the visibility-aware per-tenant watermark scan (snapshot xmin horizon, original-before-reversal ordering), replay-safe (same key+hash = no-op success; same key+different hash → alarm + block key), with ERP ack matching, the duplicate-key "already posted" idempotent-success disposition, and durable retry with no drop. + +**Implements**: +- `cpt-cf-bss-ledger-flow-erp-export` +- `cpt-cf-bss-ledger-algo-erp-export-cursor` +- `cpt-cf-bss-ledger-algo-erp-export-replay` +- `cpt-cf-bss-ledger-state-export-record` + +**Touches**: +- API: `POST /v1/ledger/exports`, `GET /v1/ledger/exports/{exportId}` +- DB Table: `export_record` (+ outbox row in the same transaction) +- Entities: `ExportRecord` + +### Period Close Gate + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-period-close-gate` + +The system **MUST** gate Finance-initiated close with the two-phase mechanism (period-scoped recompute pre-lock with watermark; cheap monotonic in-lock predicates; flip `fiscal_period` + `period_close` in the same commit; fail back to CLOSING when a post outruns the watermark), blocking on: variance above tolerance, open blocking exceptions, `mapping_status = PENDING` lines, recognition segments due ≤ N not `DONE`, Mode B un-revalued open-FX AR (PRD AC #33, `fx-revaluation-incomplete`), and a missing bill-run-finished assertion. Reopen requires dual-control + `period-reopen` secured audit. Close window ≤ 3 business days (X5b). + +**Implements**: +- `cpt-cf-bss-ledger-flow-period-close` +- `cpt-cf-bss-ledger-state-period-close` + +**Touches**: +- API: `POST /v1/ledger/legal-entities/{legalEntityId}/periods/{periodId}/closure` +- DB Table: `period_close`, `fiscal_period` (Foundation-owned; flipped together), `exception_queue`, `reconciliation_run` +- Entities: `PeriodClose` + +### Exception Queue + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-exception-queue` + +The system **MUST** hold blocking exceptions across all routed types (incl. those emitted by Slices 2/3/4), enforce close-blocking semantics (with the `GL_WRITEOFF_VARIANCE` acknowledge-to-approve carve-out and its reported running total), support resolution/approval with reason + actor, and page on aged blocking exceptions before the close window. + +**Implements**: +- `cpt-cf-bss-ledger-flow-exception-resolution` +- `cpt-cf-bss-ledger-state-exception-queue` + +**Touches**: +- API: `GET /v1/ledger/exceptions` +- DB Table: `exception_queue` +- Entities: `Exception` + +### Restore & Re-Sync + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-restore-resync` + +The system **MUST** implement the restore runbook: deterministic export `transaction_id` derivation from `(tenant, sourceId, source document version)`, post-restore export freeze with re-enable gated on a mandatory Ledger↔ERP reconciliation over affected periods, ack reconciliation via the duplicate-key disposition (never blind re-drive), and Slice 6 chain re-anchor + `restore-event` audit record. + +**Implements**: +- `cpt-cf-bss-ledger-algo-restore-resync` + +**Touches**: +- DB Table: `export_record` +- Entities: `ExportRecord` + +### Owned Data Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-recon-data-model` + +The system **MUST** create the four owned tables with the constraints of [§5](#5-data-model-owned-tables): `export_record` PK/idempotency + partial index, `reconciliation_run` tolerance fields, the full additive `exception_queue` type set, `period_close` PK + reopen lifecycle, tenant-scoped RLS (C1), and the no-shadow-edit guarantee (REVOKE + trigger; tamper-evidence detection for out-of-band access). + +**Implements**: +- `cpt-cf-bss-ledger-state-export-record` +- `cpt-cf-bss-ledger-state-exception-queue` +- `cpt-cf-bss-ledger-state-period-close` + +**Touches**: +- DB Table: `export_record`, `reconciliation_run`, `exception_queue`, `period_close` + +### Observability + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-recon-observability` + +The system **MUST** emit the outbox success events and wire the Slice 6 alarms (`reconciliation-variance` Warn → Page + block close above tolerance; `export-failed-aged` Warn → Page, retry never drop) and the feature metrics of [§8.2](#82-feature-metrics), PII-free. + +**Implements**: +- `cpt-cf-bss-ledger-flow-reconciliation-run` +- `cpt-cf-bss-ledger-flow-erp-export` + +**Touches**: +- Events: `billing.ledger.export.acked`, `billing.ledger.reconciliation.completed`, `billing.ledger.period.closed` + +### Security & AuthZ + +- [ ] `p1` - **ID**: `cpt-cf-bss-ledger-dod-recon-security` + +The system **MUST** enforce the [§8.4](#84-security--authz-details) rules: RLS + tenant-scoped reads, Slice 6 elevated + audited cross-tenant rollups, seller-only book ownership, role gating (Finance close, integration/finance export, Operations/Finance exception resolution with reason + actor), outbound ERP credentials in the platform secret store, PII-free on the wire. + +**Implements**: +- `cpt-cf-bss-ledger-flow-period-close` +- `cpt-cf-bss-ledger-flow-exception-resolution` +- `cpt-cf-bss-ledger-flow-erp-export` + +**Touches**: +- API: all feature endpoints + +## 7. Acceptance Criteria + +Testing is a **delta over Foundation §11** (levels + mocking inherited; ERP/PSP are true external clients — mocked at API level, real DB). **What MUST NOT be mocked:** the DB constraints (export PK, RLS), the real reconciliation recompute, and the Slice 1 append-only REVOKE. + +**Unit:** + +- [ ] AR tie-out recompute formula (incl. dispute-opened AR-neutral exclusion) +- [ ] export-key + business_amount_hash +- [ ] close-gate blocked-reason aggregation +- [ ] tolerance/statutory-floor logic + +**Integration (testcontainers):** + +- [ ] AR cache ↔ recomputed projection tie-out within tolerance +- [ ] an injected variance opens an exception + **blocks close** +- [ ] export idempotency: same key+hash = no-op success; **same key + different hash → `EXPORT_PAYLOAD_CONFLICT` + block key** +- [ ] ERP duplicate-key "already posted" → idempotent success, no new BSS lines +- [ ] failed export queues + retries + never deletes source +- [ ] Payments↔PSP tie (incl. `REFUND_CLEARING`) +- [ ] an aged stage-1 refund (unrelieved `REFUND_CLEARING` past threshold) opens `STUCK_REFUND_CLEARING` + blocks close +- [ ] close of N blocks while a recognition segment due ≤ N is not `DONE` +- [ ] a post that outruns the CLOSING recompute watermark fails the close back to CLOSING (no recompute under the lock) +- [ ] the exporter watermark scan exports an original strictly before its reversal +- [ ] late-arriving closed-period payment posts to current open period +- [ ] an issued-invoice-manifest `invoiceId` with **no** committed `INVOICE_POST` opens a `MISSED_POSTING` exception and **blocks close**; a manifest count / gross-total mismatch is flagged; the upstream idempotent re-post clears the exception +- [ ] **no path can edit a posted journal_line** to match ERP (REVOKE holds) + +**API:** + +- [ ] RFC 9457 mapping for each problem code +- [ ] period-close blocked response lists reasons +- [ ] export replay returns prior reference + +**Reconciliation (PRD obligation):** + +- [ ] AR tie-out, Ledger↔ERP, Payments↔PSP, **upstream→ledger invoice-completeness**, rounding/FX variance, close-block-above-tolerance — all covered + +**Restore/replay (PRD obligation):** + +- [ ] a post-restore re-post re-derives the same export `transaction_id` → the ERP duplicate-key disposition fires (idempotent success, no ERP double-post) +- [ ] export re-enable is gated on the mandatory post-restore Ledger↔ERP reconciliation +- [ ] chain re-anchor + `restore-event` audit record covered by the Slice 6 suite + +**Concurrency:** + +- [ ] concurrent exports of the same key → exactly one effect (PK) +- [ ] a close attempt racing with a new in-period post is gated consistently (the Foundation `fiscal_period` FOR SHARE) + +**NFR verification:** + +- [ ] (X5a/X5b) ERP-export end-to-end SLA + close-window timing against the committed numbers (p95 ≤ 15 min; ≤ 3 business days) +- [ ] (X4) tie-out tolerance; recon-variance + failed-export alarms +- [ ] reconciliation/variance availability + availability/immutability verified via inherited Slice 1/6 suites (no slice-7-specific number) + +## 8. Non-Functional Considerations + +### 8.1 Events Surface + +Success via the Slice 1 outbox: `billing.ledger.export.acked`, `billing.ledger.reconciliation.completed` (`check_type`, `variance`), `billing.ledger.period.closed`. Alarms via the Slice 6 catalog (separate-committed): `reconciliation-variance` (Warn → Page; block close above tolerance), `export-failed-aged` (Warn → Page; retry, never drop). PII-free. + +### 8.2 Feature Metrics + +`ledger_reconciliation_variance_minor{check_type}`, `ledger_reconciliation_runs_total` / `_out_of_tolerance_total`, `ledger_export_acked_total` / `_failed_total`, `ledger_export_failed_age_seconds`, `ledger_export_payload_conflict_total`, `ledger_period_close_blocked_total{reason}`, `ledger_period_close_duration_days`, `ledger_exception_queue_depth{type}`. Thresholds wire to [§8.3](#83-nfr-mapping) + the recon-variance / failed-export alarms. + +### 8.3 NFR Mapping + +| NFR | Mechanism | Status | +|-----|-----------|--------| +| ERP export end-to-end SLA (X5a) | idempotent export via outbound gateway + ack matching | **Committed 2026-06-10: p95 ≤ 15 min** | +| Period close window (X5b) | gated close; pre-close reconciliation | **Committed 2026-06-10: ≤ 3 business days** | +| AR tie-out tolerance (X4) | daily recompute vs cache; statutory floors | X4 default | +| Reconciliation / variance availability | tenant-scoped reads; dashboard | Inherited (read-path, no committed number — see Slice 6) | +| Availability / immutability | inherited Slices 1/6 (`cpt-cf-bss-ledger-nfr-availability`) | Inherited | + +### 8.4 Security & AuthZ Details + +Inherits Slices 1/6: RLS; reads tenant-scoped; cross-tenant rollups (platform CFO recon) require Slice 6 elevated + audited context. **Ledger-owner = seller.** The owning `tenant_id` — the RLS/owner axis, the `export_target` holder, and the period-close unit — **is a selling legal-entity / commercial-account** (the ledger-ownership predicate, ADR-0001); buyer-type tenants appear only as `payer`/`resource` and own **no** books / close / `export_target`. The buyer hierarchy (payer resolution, Slice 2) is a **different axis** from this seller hierarchy. (Seller types = `platform` + `partner`, buyer = `organization` — verified in the AMS tenant-type catalogue `vhp-core-am/config/tenant-types.yaml`; book-ownership is read from a proposed `x-gts-traits.owns_billing_books` trait, not a hardcoded list — see ADR-0001 / `01-repository-foundation.md` §4.4. ⏳ AMS to add the trait + confirm the seller set.) Provisioning of a seller legal-entity's books — seeding the chart of accounts (`tenant_account`), the first `fiscal_period`, and currency scales — is defined in **Foundation §4.12** via the idempotent `POST /v1/ledger/legal-entities/{id}/provisioning` (triggered by AMS/billing-setup **before** the first post); Slice 7 owns only the `export_target` configuration for that legal-entity, which must be in place before the first close/export. Period close requires the **tenant Finance role**; export operations the integration/finance scope; exception resolution Operations/Finance with reason + actor. Outbound ERP credentials live in the platform secret store. PII-free on the wire. + +## 9. Risks, Open Questions & Deferred Work + +- **NFR numbers (X5a/X5b) — committed 2026-06-10:** export→ack p95 ≤ 15 min; close window ≤ 3 business days. +- **Tax presentation views (X3) — resolved 2026-06-10:** the per-jurisdiction matrix is Tax Engine config; Slice 7 renders per the matrix, default gross until filled. No longer blocks this slice. +- **Variance-ownership RACI (X2) — made permanent 2026-06-10:** tenant Finance owns GL-side true-up; BSS owns export re-drive + BSS-originated corrections (S3/S4/reversal only). +- **Deferred:** ERP internals / full-ERP-SoR (out of scope); customer-facing history (Invoice/Payment services). + +## 10. Needs Discussion + +Inherits Slices 1–6 open items. Slice-7-specific: + +| Item | Decision (default) | Status | Owner | +|------|--------------------|--------|-------| +| Net vs gross tax **views** + recon tolerance | gross posting (B1); recon supports gross + net/split views; per-jurisdiction matrix = **Tax Engine config**, Slice 7 read-side consumer, default gross until filled | ✅ Ratified 2026-06-10 | PM Team | +| ERP export end-to-end SLA | **p95 ≤ 15 min** journal-post → ERP ack | ✅ Ratified 2026-06-10 | PM + Finance | +| Period close window | **≤ 3 business days** from period end to close-complete | ✅ Ratified 2026-06-10 | Finance | +| Operating mode + variance-ownership RACI | Mode A default; RACI **permanent**: tenant Finance — GL-side true-up; BSS — export re-drive + BSS-originated corrections (S3/S4/reversal, no shadow-edit) | ✅ Ratified 2026-06-10 | Product + Architecture | +| Export grain | per Design (invoice or journal entry) | ✅ Accepted default | — | +| AR tie-out tolerance | ≤ 1 minor unit / 1,000 lines; statutory floors | ✅ Accepted default | — | +| Upstream→ledger invoice completeness | Independent Invoice/Orchestration **issued-invoice manifest** (issued `invoiceId` set + control totals) reconciled `issued − posted`; gap → close-blocking `MISSED_POSTING`; near-real-time watermark + Page alarm **and** pre-close gate | ✅ Direction A accepted 2026-06-16; **🔴 manifest-feed delivery is launch-blocking — to be scheduled with Invoice/Orchestration; check inert until then** | Architecture + Invoice/Orchestration | +| Close-after-bill-run signal | close gate checks a per-`(tenant, period)` **bill-run-finished** assertion (call-driven control signal from Orchestration); blocks close without it — replaces runbook-only | ✅ In-ledger gate designed 2026-06-17; **signal delivery to be scheduled with Invoice/Orchestration (launch-blocking with)** | Architecture + Invoice/Orchestration | +| Ledger↔ERP recon depth | **MVP = ack-level**; full BSS-TB vs ERP-TB reconciliation + the inbound **ERP trial-balance feed** (cadence, ERP→BSS mapping, storage) **deferred post-MVP**; the same-key-different-amount duplicate-key fallback is post-MVP too (connector-capability gap stated) | 🔮 **Deferred post-MVP** — ack-level ships; TB recon + ERP-TB feed designed later | Architecture + ERP-integration | + +## 11. References + +- Slices 1–6 (posted facts, per-slice tie-out contributions, alarm catalog, audit/tenant-scope, FX functional column): [invoice-posting.md](./01a-invoice-posting.md), [payments-allocation.md](./03-payments-allocation.md), [adjustments-notes-refunds.md](./05-adjustments-notes-refunds.md), [asc606-recognition.md](./04-asc606-recognition.md), [fx-multicurrency.md](./06-fx-multicurrency.md), [audit-immutability-observability.md](./02-audit-immutability-observability.md); repository Foundation: [01-repository-foundation.md](./01-repository-foundation.md). (Legacy design set: `DESIGN-billing-ledger-repository-foundation-202606091200` … `-audit-immutability-observability-202606091700`.) +- [PRD.md](../PRD.md) (legacy `PRD-billing-ledger-balances-202604041200`) — § Reconciliation flows, § Source-of-truth (BSS/ERP, modes A/B), § Accounting periods and close, § Exceptions, § ERP duplicate-key disposition, AC #7/#12, manifest §4.4. +- `PRD-billing-module-202601120119`, `PRD-billing-system-202601120119` — module + program context (reconciliation framework, ERP integration). diff --git a/gears/bss/ledger/docs/design/README.md b/gears/bss/ledger/docs/design/README.md new file mode 100644 index 000000000..ae2a7e3aa --- /dev/null +++ b/gears/bss/ledger/docs/design/README.md @@ -0,0 +1,60 @@ + + + + +# Billing Ledger & Balances — Design Set + +This folder holds the Billing Ledger technical design as a **set of slice designs**, mirroring the layout of the source design set. Every slice posts **through** the shared Repository-Foundation ([`01-repository-foundation.md`](./01-repository-foundation.md)) — the double-entry posting engine, schema, universal invariants, total lock order, and in-process data-access API. The Foundation owns no domain policy; each slice below is a handler that builds balanced lines and calls the Foundation API. + +Requirements (WHAT/WHY) live in [`../PRD.md`](../PRD.md); the "why this way" rationale for the book-ownership decision is captured as an ADR in [`../ADR/`](../ADR/). + +## Slices (ordered by implementation phase) + +The numeric prefix = **implementation order** (the ratified phasing). It is **deliberately not** the canonical PRD slice number: slices are numbered by PRD decomposition but built in dependency order (e.g. ASC 606 recognition is PRD Slice 4 but built in Phase 3 because adjustments depends on its schedules; reconciliation-export is design Slice 7 but PRD S7 = ASC 606 Compliance — the two axes never line up; see [`01-repository-foundation.md` §4.1](./01-repository-foundation.md#41-naming-glossary-discipline-and-module-alignment)). + +| Doc | PRD slice # | Phase | What it is | +|-----|-------------|-------|------------| +| [`01-repository-foundation.md`](./01-repository-foundation.md) | 1 (+ naming, was slice 8) | 0/1 | **Foundation**: shared engine — journal, balance caches, commit trigger, lock order, idempotency, money, provisioning, the data-access API. Everything posts through it; built first. Also carries the naming/glossary discipline and the three ledger-wide normative statements (§4). | +| [`01a-invoice-posting.md`](./01a-invoice-posting.md) | 1 | 1 | Invoice-post handler: legs (DR AR / CR Revenue + Contract-liability + Tax), account mapping, suspense routing, AR aging, full reversal. | +| [`02-audit-immutability-observability.md`](./02-audit-immutability-observability.md) | 6 | starts in 1, completed in 6 | Tamper chain, freeze, secured store, PII/erasure, alarm catalog. Tamper-evidence is mandatory in prod from the first post (launch blocker); PII/erasure/audit-packs land in Phase 6. | +| [`03-payments-allocation.md`](./03-payments-allocation.md) | 2 | 2 | Settlement, allocation (Mode A), chargebacks/disputes, wallet. First business value: AR is actually cleared. | +| [`04-asc606-recognition.md`](./04-asc606-recognition.md) | 4 | 3 | Recognition schedules + recognition runs, `ScheduleBuilder`. Built earlier than its canonical number — adjustments depends on its schedules. | +| [`05-adjustments-notes-refunds.md`](./05-adjustments-notes-refunds.md) | 3 | 4 | Credit/debit notes, refunds, manual governance. Needs the Phase-2 counters and Phase-3 schedules. | +| [`06-fx-multicurrency.md`](./06-fx-multicurrency.md) | 5 | 5 | Functional currency, realized FX, rate snapshots. A purely additive layer over 01–05. | +| [`07-reconciliation-export.md`](./07-reconciliation-export.md) | 7 | 6 | Reconciliations, ERP export, period close gate. Only makes sense once all posting flows exist. | + +## Dependency order + +```text +01-repository-foundation (shared engine, schema, invariants, data-access API) + │ + ├─→ 01a-invoice-posting (Phase 1) + │ ├─→ 03-payments-allocation (Phase 2) + │ ├─→ 04-asc606-recognition (Phase 3) + │ │ └─→ 05-adjustments-notes-refunds (Phase 4, also needs 03) + │ └─→ 06-fx-multicurrency (Phase 5, additive over 01a–05) + │ + ├─→ 02-audit-immutability-observability (starts Phase 1, completes Phase 6) + └─→ 07-reconciliation-export (Phase 6, needs all posting flows) +``` + +- `01a-invoice-posting` needs only the Foundation: it is the first posting flow. +- `03-payments-allocation` needs invoice-posting — there must be AR to settle/allocate. +- `04-asc606-recognition` needs invoice-posting (schedules derive from posted legs); built **before** adjustments precisely because adjustments depends on its schedules. +- `05-adjustments-notes-refunds` needs **both** the Phase-2 wallet/counters (03) and the Phase-3 recognized/deferred split (04). +- `06-fx-multicurrency` is additive over 01a–05: it activates the Foundation's native functional columns and the multi-currency trigger relaxation. +- `02-audit-immutability-observability` starts in Phase 1 (Mode S tamper chain is a launch blocker) and completes in Phase 6; it protects every posting flow. +- `07-reconciliation-export` needs all posting flows: the period-close gate and cross-system reconciliations only make sense once every flow that posts into a period exists (a minimal OPEN→CLOSED close subset ships in MVP). + +## Cross-cutting / normative + +The three ledger-wide normative statements and the naming discipline live in the Foundation design (§4); the ledger-ownership predicate additionally has an ADR: + +- **Naming & glossary discipline** — [`01-repository-foundation.md` §4.1](./01-repository-foundation.md#41-naming-glossary-discipline-and-module-alignment) (`journal_entry`/`journal_line` not `LedgerEntry`; `UNALLOCATED` ≠ `REUSABLE_CREDIT`; `SUSPENSE` = mapping parking only; chargeback holds in `DISPUTE_HOLD`). +- **Foundation schema ownership** — [`01-repository-foundation.md` §4.2](./01-repository-foundation.md#42-foundation-schema-ownership-normative). +- **Call-driven ingestion** — [`01-repository-foundation.md` §4.3](./01-repository-foundation.md#43-call-driven-ingestion-model-normative). +- **Ledger-ownership predicate** — [`01-repository-foundation.md` §4.4](./01-repository-foundation.md#44-ledger-ownership-predicate-normative) · ADR [`0001`](../ADR/0001-cpt-cf-bss-ledger-adr-book-ownership-predicate.md). + +## Deferred to future scope (post-MVP) + +Cross-currency conversion (rejected in MVP — payments-allocation rejects `ALLOCATION_CURRENCY_MISMATCH`; the conversion-event mechanism is a deferred extension of fx-multicurrency), the statutory allocation registry, contract assets / unbilled, bad-debt / write-off / recovery, the full variable-consideration mechanism, escheatment filing, free-form GL, inter-tenant settlement / reseller payout, `NUMERIC(38,0)` money (`BIGINT` minor units confirmed for MVP), the ledger-side payer re-validation guard against the tenant tree, and historical / as-of temporal balance (reconstructable from `journal_line`). Each slice carries its own deferred markers; the consolidated registry is in [`../PRD.md`](../PRD.md) § "Deferred to future scope". diff --git a/gears/bss/ledger/ledger-sdk/Cargo.toml b/gears/bss/ledger/ledger-sdk/Cargo.toml new file mode 100644 index 000000000..dd6d1c656 --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bss-ledger-sdk" +edition.workspace = true +license.workspace = true +description = "BSS Billing Ledger SDK: infrastructure-free contract for ClientHub consumers" + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +# `chrono` provides `NaiveDate` in the posting/close value types; no serde +# feature — this contract crate carries no `#[derive(Serialize/Deserialize)]` +# (the gear's REST DTOs own serde and map onto these types). +chrono = { workspace = true } +thiserror = { workspace = true } +toolkit-canonical-errors = { workspace = true } +# Canonical OData query + `Page` envelope for the list endpoints. Infra-free +# (no sea-orm): the gear binds the `FilterField`→column mapping internally, the +# SDK only carries the parsed `ODataQuery` and the `Page` result. Mirrors how +# the platform list surfaces (RBAC/AM/RG) thread `toolkit_odata` through. +toolkit-odata = { workspace = true } +toolkit-security = { workspace = true } +uuid = { workspace = true } diff --git a/gears/bss/ledger/ledger-sdk/src/api.rs b/gears/bss/ledger/ledger-sdk/src/api.rs new file mode 100644 index 000000000..c1c886046 --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/api.rs @@ -0,0 +1,445 @@ +//! The in-process data-access API handlers call through `ClientHub`. P3 +//! exposes the post entry point and an account-balance read; reversal, +//! history, and counter-deltas arrive in later slices. + +use uuid::Uuid; + +use toolkit_canonical_errors::CanonicalError; +use toolkit_security::SecurityContext; + +use crate::close::CloseOutcome; +use crate::posting::{ + AllocateOutcome, AllocatePayment, AllocationView, ArInvoiceBalanceView, BalanceView, + ChangeRecognitionSchedule, CreditApplication, CreditApplicationApplied, DisputeOutcome, + EntryView, LineView, ODataQuery, Page, PostEntry, PostingRef, RecognitionRunOutcome, + RecognitionScheduleList, RecognitionScheduleView, RecordDisputePhase, ReturnPayment, + RevenueDisaggregation, RevenueDisaggregationQuery, ScheduleChangeRef, SettlePayment, + TriggerRecognitionRun, UnallocatedView, +}; +use crate::provisioning::{AccountInfo, ProvisionOutcome, ProvisionRequest}; + +#[async_trait::async_trait] +pub trait LedgerClientV1: Send + Sync { + /// Post a balanced entry in one ACID transaction. Idempotent on the + /// `(tenant, flow, business_id)` key. + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` for unbalanced/empty/too-large, + /// `FailedPrecondition` for period/account/payer-closed or no-negative, + /// `Aborted` for an idempotency conflict, `PermissionDenied` when the PEP + /// denies, `Internal` on an infrastructure fault. + async fn post_balanced_entry( + &self, + ctx: &SecurityContext, + entry: PostEntry, + ) -> Result; + + /// Read the cached normal-side-positive balance for an account, or `None` + /// if it has never been posted to. The account is single-currency (v1: one + /// account per currency), so the currency is implied by `account_id`. + /// + /// # Errors + /// A [`CanonicalError`]: `PermissionDenied` when the PEP denies, or + /// `Internal` on a storage failure. + async fn read_account_balance( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + account_id: Uuid, + ) -> Result, CanonicalError>; + + /// List the chart of accounts for a tenant — every account's persistent + /// `account_id` + coordinate, so a caller can resolve the ids it needs to + /// post / read balances. Cursor-paginated via the canonical `query` + /// (`$filter` over `account_class` / `currency` / `revenue_stream` / + /// `lifecycle_state`, `$orderby`, `limit` / `cursor`). Tenant-scoped: the + /// `$filter` ANDs the caller's authorized-subtree scope, so a target outside + /// it returns an empty page (SQL-level BOLA, no leak — never replaced by the + /// filter). + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` on a malformed `$filter` / + /// cursor, `PermissionDenied` when the PEP denies, or `Internal` on a + /// storage failure. + async fn list_accounts( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, CanonicalError>; + + /// Read a single posted entry with its lines, or `None` if absent. Tenant- + /// scoped: an `entry_id` owned by a tenant outside the caller's authorized + /// subtree returns `None` (SQL-level BOLA, no existence leak). + /// + /// # Errors + /// A [`CanonicalError`]: `PermissionDenied` when the PEP denies, or + /// `Internal` on a storage failure. + async fn get_entry( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + entry_id: Uuid, + ) -> Result, CanonicalError>; + + /// List journal lines for a tenant under the canonical `query`, cursor- + /// paginated. `$filter` covers `payer_tenant_id` / `account_class` / + /// `period_id` / `invoice_id`; `$orderby` / `limit` / `cursor` page the + /// result. Tenant-scoped: the `$filter` ANDs the caller's authorized-subtree + /// scope (SQL-level BOLA), so rows outside it are never returned and a + /// foreign filter value can't widen the set. + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` on a malformed `$filter` / + /// cursor, `PermissionDenied` when the PEP denies, or `Internal` on a + /// storage failure. + async fn list_lines( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, CanonicalError>; + + /// List the account-balance cache rows for a tenant under the canonical + /// `query` (`$filter` over `account_class` / `currency`, `$orderby`, + /// `limit` / `cursor`). Tenant-scoped: the `$filter` ANDs the caller's + /// authorized-subtree scope (SQL-level BOLA), so a target outside it yields + /// an empty page (no leak, never replaced by the filter). + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` on a malformed `$filter` / + /// cursor, `PermissionDenied` when the PEP denies, or `Internal` on a + /// storage failure. + async fn list_balances( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, CanonicalError>; + + /// List the per-invoice AR-balance cache rows for a tenant, optionally + /// narrowed to one `payer_tenant_id` (the AR-aging source). Tenant-scoped: + /// a target outside the caller's authorized subtree yields an empty list + /// (SQL-level BOLA, no leak). + /// + /// # Errors + /// A [`CanonicalError`]: `PermissionDenied` when the PEP denies, or + /// `Internal` on a storage failure. + async fn list_ar_invoice_balances( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + payer_tenant_id: Option, + ) -> Result, CanonicalError>; + + /// Provision a tenant's ledger: seed its chart of accounts, non-ISO + /// currency scales, fiscal-calendar config, and initial OPEN period in + /// one transaction. Idempotent and additive — a re-call leaves existing + /// rows untouched and reports them as "existing". + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` on a malformed calendar / empty + /// seed or a non-ISO scale beyond the supported headroom; `PermissionDenied` + /// when the PEP denies; `Internal` on a storage failure. + async fn provision( + &self, + ctx: &SecurityContext, + req: ProvisionRequest, + ) -> Result; + + /// Close a fiscal period (OPEN→CLOSED) after a clean pre-close tie-out. + /// # Errors + /// A [`CanonicalError`]: `FailedPrecondition` if the tenant's books don't + /// tie out or the period is not OPEN; `NotFound` when the period is absent; + /// `PermissionDenied` when the PEP denies; `Internal` on a storage failure. + async fn close_period( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + period_id: String, + ) -> Result; + + /// Settle a payment (the money-in side): record a received receipt into the + /// payer's unallocated pool (`CR UNALLOCATED` gross, `DR CASH_CLEARING` net, + /// `DR PSP_FEE_EXPENSE` fee). Idempotent on `(tenant, payment_id)` — a + /// re-settle replays the prior post with no new ledger effect. A settlement + /// records money already received and lands even for a closed payer. + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` for an unrepresentable settlement + /// (`gross < 0`, `fee < 0`, `fee > gross`); `FailedPrecondition` when a + /// required account class is not provisioned or the period is closed; + /// `PermissionDenied` when the PEP denies; `Internal` on a storage failure. + async fn settle_payment( + &self, + ctx: &SecurityContext, + req: SettlePayment, + ) -> Result; + + /// Record a settlement return (the reversal of a money-in): claw a + /// previously-settled receipt back out of the payer's unallocated pool + /// (`DR UNALLOCATED` / `CR CASH_CLEARING`) and decrement the original + /// payment's `settled_minor`. Idempotent on `(tenant, psp_return_id)` — a + /// re-post replays the prior entry with no new ledger effect. Records money + /// already moved and lands even for a closed payer. + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` for a non-positive amount; + /// `FailedPrecondition` when the return exceeds the still-returnable settled + /// amount (`SETTLEMENT_RETURN_OVER_ALLOCATED`), a required account class is + /// not provisioned, or the period is closed; `PermissionDenied` when the PEP + /// denies; `Internal` on a storage failure. + async fn return_payment( + &self, + ctx: &SecurityContext, + req: ReturnPayment, + ) -> Result; + + /// Record a chargeback dispute phase (the dispute state machine, §4.5). The + /// LEDGER chooses the variant at `opened` from `funds_at_open` (card rails + /// withheld the cash ⇒ cash-hold `DR DISPUTE_HOLD / CR CASH_CLEARING`; + /// invoice/ACH did not move it ⇒ AR-reclass `ACTIVE → DISPUTED`, + /// AR-class-neutral) and the `won`/`lost` outcomes branch on the recorded + /// variant. Idempotent on `(tenant, CHARGEBACK, "dispute_id:cycle:phase")` — + /// a re-post replays the prior entry. A dispute records a card-network / bank + /// event and lands even for a closed payer. + /// + /// Returns [`DisputeOutcome`]: when the phase can post (it IS the `opened`, + /// or its `opened` cycle exists) it yields `Recorded` (the posting handle). + /// When a `won`/`lost` arrives BEFORE its `opened` (§4.7 out-of-order) the + /// request is instead durably **queued** for a later drain and yields + /// `Queued` (the queue key + `queued_at`) — the REST surface renders this as + /// HTTP 202 `dispute-phase-queued`, NOT a rejection. The queued effect is + /// applied once the `opened` lands (by the periodic drain); a replay during + /// the queued window returns the same `Queued` handle. + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` for a non-positive amount, an + /// unknown `phase` / `funds_at_open`, or a missing AR-reclass `invoice_id`; + /// `FailedPrecondition` when the phase is not a legal transition from the + /// dispute's current state (`INVALID_DISPUTE_PHASE`), the clawback exceeds the + /// settled amount (`CHARGEBACK_EXCEEDS_SETTLED`), a `lost` lands on an + /// already-refunded payment (`CHARGEBACK_ON_REFUNDED`), a required account + /// class is not provisioned, or the period is closed; `PermissionDenied` + /// when the PEP denies; `Internal` on a storage failure. (A `won`/`lost` + /// before its `opened` is no longer an error — it queues; see above.) + async fn record_dispute_phase( + &self, + ctx: &SecurityContext, + req: RecordDisputePhase, + ) -> Result; + + /// Allocate a payment's unallocated pool to the payer's open receivables (the + /// money-out side): drains the pool (`DR UNALLOCATED`) into AR (`CR AR` per + /// invoice). By default the split is decided by the tenant's precedence + /// policy; a caller-computed [`AllocatePayment::splits`] (Mode B) instead + /// supplies the per-invoice shares, which are validated against the open + /// receivables. Idempotent on `(tenant, allocation_id)`. + /// + /// Returns [`AllocateOutcome`]: when the payment is already settled the + /// allocation posts inline and yields `Applied` (the posting handle + the + /// per-invoice splits). When the payment is NOT yet settled the request is + /// instead durably **queued** for a later drain (§4.7 + /// allocation-before-settlement) and yields `Queued` (the queue key + + /// `queued_at`) — the REST surface renders this as HTTP 202 + /// `allocation-queued`, NOT a rejection. The queued effect is applied once + /// the settlement lands (the drain is deferred to a later slice); a replay + /// during the queued window returns the same `Queued` handle. + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` when there is no open AR to + /// allocate, an over-cap allocation exceeds what was settled + /// (`ALLOCATION_EXCEEDS_SETTLED`), the candidate set is too large + /// (`ALLOCATION_TOO_LARGE`), the currency mismatches the settlement + /// (`ALLOCATION_CURRENCY_MISMATCH`), or a caller-computed split is invalid + /// (`ALLOCATION_SPLIT_INVALID`); `FailedPrecondition` on a closed period; + /// `PermissionDenied` when the PEP denies; `Internal` on a storage failure. + /// (An unsettled payment is no longer an error — it queues; see above.) + async fn allocate_payment( + &self, + ctx: &SecurityContext, + req: AllocatePayment, + ) -> Result; + + /// List a payment's recorded allocations (the per-invoice splits applied to + /// it). Tenant-scoped: a `payment_id` owned by a tenant outside the caller's + /// authorized subtree yields an empty list (SQL-level BOLA, no existence + /// leak). + /// + /// # Errors + /// A [`CanonicalError`]: `PermissionDenied` when the PEP denies, or + /// `Internal` on a storage failure. + async fn list_payment_allocations( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + payment_id: String, + ) -> Result, CanonicalError>; + + /// Read the payer's unallocated pool balance for a currency — the still- + /// undrained portion of settled receipts. Tenant-scoped: a payer outside the + /// caller's authorized subtree yields a zero balance (SQL-level BOLA). + /// + /// # Errors + /// A [`CanonicalError`]: `PermissionDenied` when the PEP denies, or + /// `Internal` on a storage failure. + async fn read_unallocated( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + payer_tenant_id: Uuid, + currency: String, + ) -> Result; + + /// Operate a tenant's reusable-credit wallet (the wallet surface, + /// architecture §5.2) — ONE entry point, two kinds. A `Grant` parks + /// `amount_minor` of the payer's unallocated pool into the wallet sub-grain + /// (`DR UNALLOCATED` / `CR REUSABLE_CREDIT`), capped at the live pool. An + /// `Apply` spends the wallet against the named open receivables oldest-grant- + /// first (`N×DR REUSABLE_CREDIT` / `M×CR AR`), capped on both the receivable + /// side (open AR) and the wallet side (spendable sub-grains). Idempotent on + /// `(tenant, CREDIT_APPLY, credit_application_id)`. Returns the posting handle + /// plus — for an apply — the wallet draw-downs and the per-invoice shares. + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` when a grant exceeds the payer's + /// unallocated pool (`GRANT_EXCEEDS_UNALLOCATED`), an apply target names an + /// unknown/closed invoice or over-applies the open AR (`CREDIT_EXCEEDS_OPEN_AR`), + /// or the payer's spendable wallet cannot cover the apply total + /// (`CREDIT_EXCEEDS_WALLET`); `FailedPrecondition` on a closed period or an + /// unprovisioned account class; `PermissionDenied` when the PEP denies; + /// `Internal` on a storage failure. + async fn post_credit_application( + &self, + ctx: &SecurityContext, + req: CreditApplication, + ) -> Result; + + /// Trigger an ASC 606 recognition run for one fiscal period (the S6 + /// release, design §4.3 / §5): release the period's due `PENDING` + /// recognition segments, each as one balanced `DR CONTRACT_LIABILITY / + /// CR REVENUE` entry. The trigger is idempotent on + /// `(tenant, period_id, run_id)` — a replay of the same `run_id` returns the + /// prior run reference without starting a second run — and a per-`(tenant, + /// period_id)` single-active-run guard serializes overlapping runs; each + /// released segment is independently at-most-once via the per-segment + /// `RECOGNITION` gate, so overlapping different `run_id`s can never + /// double-credit a segment. + /// + /// Returns [`RecognitionRunOutcome`]: when every due segment released in + /// period order it yields `Ran` (the run reference + the release tally). + /// When a due segment's lower-period predecessor was not yet `DONE` the + /// segment is parked `QUEUED` (design §4.6 ordering) and the run yields + /// `Queued` — the REST surface renders this as HTTP 202 + /// `recognition-period-queued`, NOT a rejection. A later run drains the + /// `QUEUED` segments once their predecessors commit. + /// + /// # Errors + /// A [`CanonicalError`]: `Aborted` when a per-schedule over-recognition cap + /// CHECK rejects a release (`OVER_RECOGNITION`); `FailedPrecondition` when a + /// target period is closed and no current open period exists, or a required + /// account class is not provisioned; `PermissionDenied` when the PEP denies; + /// `Internal` on a storage failure. + async fn trigger_recognition_run( + &self, + ctx: &SecurityContext, + req: TriggerRecognitionRun, + ) -> Result; + + /// Disaggregate recognized ASC 606 revenue by stream (design §3.5 / §4.5). + /// The source is recognized revenue = the **DONE** recognition segments (each + /// posted a `DR CONTRACT_LIABILITY / CR REVENUE` release), grouped by + /// `(period_id, revenue_stream)` and summed, ordered by + /// `(period_id, revenue_stream)`. `query.period_id` `None` ⇒ every period; + /// `Some(_)` narrows to that period. Tenant-scoped on `query.tenant_id`: a + /// target outside the caller's authorized subtree yields no entries (SQL-level + /// BOLA, no leak). + /// + /// # Errors + /// A [`CanonicalError`]: `PermissionDenied` when the PEP denies, or `Internal` + /// on a storage failure. + async fn list_revenue_disaggregation( + &self, + ctx: &SecurityContext, + query: RevenueDisaggregationQuery, + ) -> Result; + + /// Change or cancel an ASC 606 recognition schedule (design §3.6 / §4.6, the + /// modification path). Targets one ACTIVE `(tenant, schedule_id)` schedule. + /// **First** the upstream modification-accounting `treatment` is gated: + /// `"prospective"` / `"separate_contract"` proceed; `"catch_up"` or any + /// unknown value is rejected `MODIFICATION_TREATMENT_REVIEW` with NO state + /// change (the ledger never silently treats a modification as prospective — + /// upstream owns the catch-up decision, §3.6). Then: + /// + /// - **`"cancel"`** marks the schedule `CANCELLED` (bumping `version`). The + /// unreleased deferred remainder stays as `CONTRACT_LIABILITY` (no + /// auto-reversal — out of v1 scope); already-recognized segments are + /// untouched. The schedule's still-`PENDING`/`QUEUED` segments will no + /// longer release (the runner releases ACTIVE schedules only). + /// - **`"replace"`** marks the old schedule `REPLACED` and mints a NEW + /// `schedule_id` (`version = old + 1`) carrying the SAME business-key dims, + /// with `total_deferred = old.total_deferred − old.recognized` (the + /// REMAINING deferred) re-planned over `new_segments` (PENDING). Prospective: + /// already-recognized revenue is NOT unwound and no compensating journal + /// entry is posted (the `CONTRACT_LIABILITY` balance already equals the + /// remaining deferred; the new schedule re-plans its release). + /// + /// Idempotent on `change_id`: a replay returns the prior [`ScheduleChangeRef`] + /// and mints no second schedule. Emits `billing.ledger.schedule.changed` + /// in-txn on success. + /// + /// # Errors + /// A [`CanonicalError`]: `InvalidArgument` for an unknown `action`, an empty / + /// over-long `change_id`, a `replace` whose `new_segments` are missing / sum + /// to the wrong remaining-deferred total, a `catch_up`/unknown `treatment` + /// (`MODIFICATION_TREATMENT_REVIEW`), or when no ACTIVE schedule exists for + /// `(tenant, schedule_id)`; `PermissionDenied` when the PEP denies; `Internal` + /// on a storage failure. + async fn change_recognition_schedule( + &self, + ctx: &SecurityContext, + cmd: ChangeRecognitionSchedule, + ) -> Result; + + /// Read one ASC 606 recognition schedule's lifecycle view (design §3.7 / §4, + /// the `GET /recognition-schedules/{schedule_id}` surface): the schedule + /// header (status, version, revenue stream, currency, total-deferred / + /// recognized-to-date, the originating invoice + item-link anchor, the PO / + /// subscription / policy refs) plus its segments, ordered by `segment_no` + /// (1:1 with `period_id`, so also period order). The schedule PK is + /// `(tenant_id, schedule_id)`. `Ok(None)` ⇒ no such schedule exists for the + /// `(tenant_id, schedule_id)` pair, OR it lies outside the caller's + /// authorized subtree — tenant-scoped (SQL-level BOLA), so the two are + /// indistinguishable (no existence leak; the REST handler renders a 404 in + /// either case). + /// + /// # Errors + /// A [`CanonicalError`]: `PermissionDenied` when the PEP denies, or + /// `Internal` on a storage failure. + async fn get_recognition_schedule( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + schedule_id: String, + ) -> Result, CanonicalError>; + + /// List ASC 606 recognition schedules for `tenant_id`, optionally narrowed to + /// one originating `invoice_id` (`source_invoice_id`) and/or one + /// `revenue_stream` — the discovery surface for the server-minted + /// `schedule_id` (`GET /recognition-schedules`, and the post-commit lookup + /// that echoes a freshly-minted id on invoice-post). Header views only (the + /// by-id surface carries the segments). Tenant-scoped (SQL-level BOLA): + /// schedules outside the caller's subtree are silently excluded. + /// + /// # Errors + /// A [`CanonicalError`]: `PermissionDenied` when the PEP denies, or + /// `Internal` on a storage failure. + async fn list_recognition_schedules( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + invoice_id: Option, + revenue_stream: Option, + ) -> Result; +} diff --git a/gears/bss/ledger/ledger-sdk/src/bill_run_finished.rs b/gears/bss/ledger/ledger-sdk/src/bill_run_finished.rs new file mode 100644 index 000000000..edd2dfd02 --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/bill_run_finished.rs @@ -0,0 +1,47 @@ +//! The bill-run-finished control feed (`BillRunFinishedV1`). +//! +//! One of the three launch-blocking control feeds of Slice 7 Phase 3 (design §4.3 / +//! N-recon-1): the owning Orchestration asserts whether a `(tenant, period)`'s +//! bill-run has finished, and the ledger's close gate reads it back. A control feed +//! ONLY — never a posting source (design §1.2). The default +//! [`UnconfiguredBillRunFinishedV1`] is a fail-safe no-op (returns `None` ⇒ the +//! bill-run assertion is inert until the feed lands; design §0 decision 3), mirroring +//! [`crate::UnconfiguredRateProviderV1`]. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::issued_invoice_manifest::ControlFeedError; + +/// Read port for the owning Orchestration's bill-run-finished assertion (call-driven; +/// the ledger never pulls a bus on the post path). The fail-safe default returns +/// `None` ⇒ the assertion is inert (design §0 decision 3). +#[async_trait] +pub trait BillRunFinishedV1: Send + Sync { + /// `Some(true)`/`Some(false)` when the owning Orchestration asserted the period's + /// bill-run state; `None` when not asserted (feed not configured). + /// + /// # Errors + /// [`ControlFeedError`] on a configured-feed failure. + async fn is_finished( + &self, + tenant: Uuid, + period: &str, + ) -> Result, ControlFeedError>; +} + +/// Fail-safe default: not asserted ⇒ `None` ⇒ the bill-run check inert (mirrors +/// `UnconfiguredRateProviderV1`). +#[derive(Debug, Default, Clone, Copy)] +pub struct UnconfiguredBillRunFinishedV1; + +#[async_trait] +impl BillRunFinishedV1 for UnconfiguredBillRunFinishedV1 { + async fn is_finished( + &self, + _tenant: Uuid, + _period: &str, + ) -> Result, ControlFeedError> { + Ok(None) + } +} diff --git a/gears/bss/ledger/ledger-sdk/src/close.rs b/gears/bss/ledger/ledger-sdk/src/close.rs new file mode 100644 index 000000000..a835b022f --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/close.rs @@ -0,0 +1,12 @@ +//! Period-close request/response value types for the in-process data-access +//! API. Infrastructure-free: a minimal `OPEN→CLOSED` transition gated by a +//! synchronous pre-close tie-out (REST close + reopen/dual-control arrive in a +//! later slice). + +/// The outcome of a period-close call. `already_closed` is `true` when the +/// period was already `CLOSED` (the call is idempotent — a re-close is a no-op). +#[derive(Clone, Debug)] +pub struct CloseOutcome { + pub period_id: String, + pub already_closed: bool, +} diff --git a/gears/bss/ledger/ledger-sdk/src/enums.rs b/gears/bss/ledger/ledger-sdk/src/enums.rs new file mode 100644 index 000000000..ef653ffef --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/enums.rs @@ -0,0 +1,221 @@ +//! Central enums declared in their FINAL form (architecture §7.2): all +//! values present up front so no later phase needs an additive change. + +use std::fmt; +use std::str::FromStr; + +/// Raised when a stored literal does not match any known variant. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[error("unknown {kind} literal: {value:?}")] +pub struct UnknownLiteral { + pub kind: &'static str, + pub value: String, +} + +macro_rules! str_enum { + ($name:ident, $kind:literal, { $($variant:ident => $lit:literal),+ $(,)? }) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum $name { $($variant),+ } + + impl $name { + pub fn as_str(&self) -> &'static str { + match self { $(Self::$variant => $lit),+ } + } + /// Every literal, for building a SQL `CHECK (col IN (...))`. + pub const ALL: &'static [&'static str] = &[ $($lit),+ ]; + } + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } + } + impl FromStr for $name { + type Err = UnknownLiteral; + fn from_str(s: &str) -> Result { + match s { + $($lit => Ok(Self::$variant),)+ + other => Err(UnknownLiteral { kind: $kind, value: other.to_owned() }), + } + } + } + }; +} + +str_enum!(Side, "side", { Debit => "DR", Credit => "CR" }); +str_enum!(MappingStatus, "mapping_status", { Resolved => "RESOLVED", Pending => "PENDING" }); + +str_enum!(AccountClass, "account_class", { + Ar => "AR", + CashClearing => "CASH_CLEARING", + Unallocated => "UNALLOCATED", + ReusableCredit => "REUSABLE_CREDIT", + ContractLiability => "CONTRACT_LIABILITY", + Revenue => "REVENUE", + TaxPayable => "TAX_PAYABLE", + Suspense => "SUSPENSE", + DisputeHold => "DISPUTE_HOLD", + RefundClearing => "REFUND_CLEARING", + ContraRevenue => "CONTRA_REVENUE", + Goodwill => "GOODWILL", + DisputeLossExpense => "DISPUTE_LOSS_EXPENSE", + PspFeeExpense => "PSP_FEE_EXPENSE", + FxGainLoss => "FX_GAIN_LOSS", + FxUnrealized => "FX_UNREALIZED", +}); + +impl AccountClass { + /// Account classes whose balance must never go negative — the single + /// source of truth shared by the conditional no-negative CHECK on + /// `account_balance`, the projector's in-posting guard, and the tie-out + /// reconciliation backstop. Any class NOT listed here may legitimately + /// go negative. + pub const GUARDED: &'static [AccountClass] = &[ + AccountClass::Ar, + AccountClass::CashClearing, + AccountClass::Unallocated, + AccountClass::ContractLiability, + AccountClass::DisputeHold, + AccountClass::RefundClearing, + ]; + + /// True when this class is in [`AccountClass::GUARDED`] (must stay `>= 0`). + #[must_use] + pub fn is_guarded(self) -> bool { + Self::GUARDED.contains(&self) + } + + /// Classes whose balance sub-divides by `revenue_stream` — the per-stream + /// classes. The single source of truth shared by the chart-resolution key + /// (these classes resolve on their stream; the rest on `stream = None`) and + /// the `chk_journal_line_revenue_stream` CHECK (which requires a stream for + /// exactly these classes). System parking / clearing classes (AR, TAX, + /// SUSPENSE, CASH, …) carry no stream. + pub const PER_STREAM: &'static [AccountClass] = + &[AccountClass::Revenue, AccountClass::ContractLiability]; + + /// True when this class keys on `revenue_stream` (is in + /// [`AccountClass::PER_STREAM`]); false for the stream-less system classes. + #[must_use] + pub fn is_per_stream(self) -> bool { + Self::PER_STREAM.contains(&self) + } +} + +str_enum!(SourceDocType, "source_doc_type", { + InvoicePost => "INVOICE_POST", + Reversal => "REVERSAL", + MappingCorrection => "MAPPING_CORRECTION", + PaymentSettle => "PAYMENT_SETTLE", + PaymentAllocate => "PAYMENT_ALLOCATE", + Chargeback => "CHARGEBACK", + CreditApply => "CREDIT_APPLY", + SettlementReturn => "SETTLEMENT_RETURN", + ManualAdjustment => "MANUAL_ADJUSTMENT", + ScheduleBuild => "SCHEDULE_BUILD", + Recognition => "RECOGNITION", + CreditNote => "CREDIT_NOTE", + DebitNote => "DEBIT_NOTE", + Refund => "REFUND", + FxRevaluation => "FX_REVALUATION", + FxRevalReversal => "FX_REVAL_REVERSAL", +}); + +// `Flow` shares the `source_doc_type` literal set per architecture §7.2. +pub type Flow = SourceDocType; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn account_class_round_trips_every_variant() { + for lit in AccountClass::ALL { + let parsed: AccountClass = lit.parse().unwrap(); + assert_eq!(&parsed.as_str(), lit); + } + } + + /// WIRE-TOKEN LOCK. The chain verifier + /// (`bss-ledger::infra::jobs::verifier::reconstruct`) re-parses these stored + /// literals to recompute each historical row hash. A renamed literal would + /// still round-trip WITHIN the SDK (both `as_str` and `FromStr` change + /// together) yet would fail to parse every already-persisted row → mass false + /// tamper-freeze. Pinning the exact tokens (and the full set) makes any + /// rename / add / remove break CI here instead of in production. + #[test] + fn wire_tokens_are_frozen() { + assert_eq!(Side::ALL, ["DR", "CR"]); + assert_eq!(MappingStatus::ALL, ["RESOLVED", "PENDING"]); + assert_eq!( + AccountClass::ALL, + [ + "AR", + "CASH_CLEARING", + "UNALLOCATED", + "REUSABLE_CREDIT", + "CONTRACT_LIABILITY", + "REVENUE", + "TAX_PAYABLE", + "SUSPENSE", + "DISPUTE_HOLD", + "REFUND_CLEARING", + "CONTRA_REVENUE", + "GOODWILL", + "DISPUTE_LOSS_EXPENSE", + "PSP_FEE_EXPENSE", + "FX_GAIN_LOSS", + "FX_UNREALIZED", + ] + ); + assert_eq!( + SourceDocType::ALL, + [ + "INVOICE_POST", + "REVERSAL", + "MAPPING_CORRECTION", + "PAYMENT_SETTLE", + "PAYMENT_ALLOCATE", + "CHARGEBACK", + "CREDIT_APPLY", + "SETTLEMENT_RETURN", + "MANUAL_ADJUSTMENT", + "SCHEDULE_BUILD", + "RECOGNITION", + "CREDIT_NOTE", + "DEBIT_NOTE", + "REFUND", + "FX_REVALUATION", + "FX_REVAL_REVERSAL", + ] + ); + } + + #[test] + fn unknown_literal_is_rejected() { + assert!("NOPE".parse::().is_err()); + assert_eq!("DR".parse::().unwrap(), Side::Debit); + } + + #[test] + fn display_matches_as_str() { + assert_eq!(Side::Debit.to_string(), "DR"); + assert_eq!(AccountClass::Ar.to_string(), "AR"); + assert_eq!(SourceDocType::Reversal.to_string(), "REVERSAL"); + } + + #[test] + fn guarded_set_is_the_no_negative_classes() { + for c in AccountClass::GUARDED { + assert!(c.is_guarded(), "{c} must be guarded"); + } + for c in [ + AccountClass::Revenue, + AccountClass::TaxPayable, + AccountClass::Suspense, + AccountClass::ContraRevenue, + ] { + assert!(!c.is_guarded(), "{c} must not be guarded"); + } + assert_eq!(AccountClass::GUARDED.len(), 6); + } +} diff --git a/gears/bss/ledger/ledger-sdk/src/error.rs b/gears/bss/ledger/ledger-sdk/src/error.rs new file mode 100644 index 000000000..51d53e6fb --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/error.rs @@ -0,0 +1,155 @@ +//! `LedgerError` — typed projection of [`CanonicalError`] for BSS Ledger +//! consumers (ADR-0005). The gear's single `From for +//! CanonicalError` ladder is the authoritative AIP-193 classification; this is +//! the forward-compatible typed *view* a consumer matches on. Match the +//! category for coarse handling, `code` for the exact ledger condition (the old +//! `LedgerErrorCode` `SCREAMING_SNAKE` names), and `resource_type`/`resource_name` +//! for the entry / fiscal-period / account it concerns. +//! +//! Consumers: `?` propagates the `CanonicalError`; opt into the typed view with +//! `.map_err(LedgerError::from)` at the call site. + +use thiserror::Error; +use toolkit_canonical_errors::{CanonicalError, InvalidArgument}; + +/// Typed projection of [`CanonicalError`] for ledger consumers. +#[derive(Debug, Clone, Error)] +#[non_exhaustive] +pub enum LedgerError { + /// Bad request shape/value. `field` is the attributed request field; + /// `code` is the machine-readable reason. + #[error("invalid argument [{field}/{code}]: {detail}")] + InvalidArgument { + field: String, + code: String, + detail: String, + }, + /// Resource state forbids the operation. `subject` is the violated + /// resource (`fiscal_period`/`account`/`account_balance`/…); `code` is the + /// exact precondition. + #[error("failed precondition [{subject}/{code}]: {detail}")] + FailedPrecondition { + subject: String, + code: String, + detail: String, + }, + /// Conflict; the caller may retry. `code` is the abort reason + /// (`IDEMPOTENCY_PAYLOAD_CONFLICT`/`CURRENCY_SCALE_LOCKED`). + #[error("aborted [{code}]: {detail}")] + Aborted { code: String, detail: String }, + /// The named resource does not exist. + #[error("not found [{resource_type}]: {resource_name}")] + NotFound { + resource_type: String, + resource_name: String, + detail: String, + }, + /// A bounded resource is exhausted (backpressure). `code` is the quota + /// subject (`TENANT_POSTING_LOCKED`). + #[error("resource exhausted [{code}]: {detail}")] + ResourceExhausted { code: String, detail: String }, + /// Authorization denial. `reason` is the deny reason. + #[error("permission denied [{reason}]: {detail}")] + PermissionDenied { reason: String, detail: String }, + /// The request carried no authenticated `SecurityContext`. + #[error("unauthenticated: {detail}")] + Unauthenticated { detail: String }, + /// Transient outage; retry later. + #[error("service unavailable: {detail}")] + Unavailable { detail: String }, + /// Unclassified internal failure — `detail` is already redacted at the + /// canonical boundary (no server-side diagnostic). + #[error("internal: {detail}")] + Internal { detail: String }, + /// Catch-all for canonical categories the ledger does not model — preserves + /// the full [`CanonicalError`] so consumers stay forward-compatible. + #[error("{canonical}")] + Other { canonical: CanonicalError }, +} + +impl From for LedgerError { + fn from(err: CanonicalError) -> Self { + let detail = err.detail().to_owned(); + match err { + CanonicalError::InvalidArgument { ctx, .. } => project_invalid_argument(ctx, detail), + + CanonicalError::FailedPrecondition { ctx, .. } => { + ctx.violations.into_iter().next().map_or_else( + || Self::FailedPrecondition { + subject: String::new(), + code: String::new(), + detail: detail.clone(), + }, + |v| Self::FailedPrecondition { + subject: v.subject, + code: v.type_, + detail: v.description, + }, + ) + } + + CanonicalError::Aborted { ctx, .. } => Self::Aborted { + code: ctx.reason, + detail, + }, + + CanonicalError::NotFound { + resource_type, + resource_name, + .. + } => Self::NotFound { + resource_type: resource_type.unwrap_or_default(), + resource_name: resource_name.unwrap_or_default(), + detail, + }, + + CanonicalError::ResourceExhausted { ctx, .. } => Self::ResourceExhausted { + code: ctx + .violations + .into_iter() + .next() + .map(|v| v.subject) + .unwrap_or_default(), + detail, + }, + + CanonicalError::PermissionDenied { ctx, .. } => Self::PermissionDenied { + reason: ctx.reason, + detail, + }, + + CanonicalError::Unauthenticated { .. } => Self::Unauthenticated { detail }, + + CanonicalError::ServiceUnavailable { .. } => Self::Unavailable { detail }, + + CanonicalError::Internal { .. } => Self::Internal { detail }, + + other => Self::Other { canonical: other }, + } + } +} + +fn project_invalid_argument(ctx: InvalidArgument, detail: String) -> LedgerError { + let first = match ctx { + InvalidArgument::FieldViolations { field_violations } => { + field_violations.into_iter().next() + } + InvalidArgument::Format { .. } | InvalidArgument::Constraint { .. } => None, + }; + first.map_or( + LedgerError::InvalidArgument { + field: String::new(), + code: String::new(), + detail, + }, + |v| LedgerError::InvalidArgument { + field: v.field, + code: v.reason, + detail: v.description, + }, + ) +} + +#[cfg(test)] +#[path = "error_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger-sdk/src/error_tests.rs b/gears/bss/ledger/ledger-sdk/src/error_tests.rs new file mode 100644 index 000000000..3b1842f16 --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/error_tests.rs @@ -0,0 +1,335 @@ +//! `LedgerError` is the typed view consumers match on — its `From` +//! projection and Display strings are the contract; lock them. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use toolkit_canonical_errors::{CanonicalError, resource_error}; + +use super::LedgerError; + +// Minimal resource stubs for constructing canonical errors in tests. +#[resource_error("gts.cf.bss.ledger.entry.v1~")] +struct TestEntry; +#[resource_error("gts.cf.bss.ledger.ledger.v1~")] +struct TestLedger; +#[resource_error("gts.cf.bss.ledger.fiscal_period.v1~")] +struct TestFiscalPeriod; + +// --------------------------------------------------------------------------- +// Display +// --------------------------------------------------------------------------- + +#[test] +fn display_invalid_argument() { + let e = LedgerError::InvalidArgument { + field: "lines".into(), + code: "UNBALANCED".into(), + detail: "dr!=cr".into(), + }; + let s = e.to_string(); + assert!(s.contains("lines"), "{s}"); + assert!(s.contains("UNBALANCED"), "{s}"); + assert!(s.contains("dr!=cr"), "{s}"); +} + +#[test] +fn display_failed_precondition() { + let e = LedgerError::FailedPrecondition { + subject: "fiscal_period".into(), + code: "PERIOD_CLOSED".into(), + detail: "closed at 2025-12-31".into(), + }; + let s = e.to_string(); + assert!(s.contains("fiscal_period"), "{s}"); + assert!(s.contains("PERIOD_CLOSED"), "{s}"); +} + +#[test] +fn display_aborted() { + let e = LedgerError::Aborted { + code: "IDEMPOTENCY_PAYLOAD_CONFLICT".into(), + detail: "duplicate key".into(), + }; + let s = e.to_string(); + assert!(s.contains("IDEMPOTENCY_PAYLOAD_CONFLICT"), "{s}"); + assert!(s.contains("duplicate key"), "{s}"); +} + +#[test] +fn display_not_found() { + let e = LedgerError::NotFound { + resource_type: "fiscal_period".into(), + resource_name: "2025-Q4".into(), + detail: "no such period".into(), + }; + let s = e.to_string(); + assert!(s.contains("fiscal_period"), "{s}"); + assert!(s.contains("2025-Q4"), "{s}"); +} + +#[test] +fn display_resource_exhausted() { + let e = LedgerError::ResourceExhausted { + code: "TENANT_POSTING_LOCKED".into(), + detail: "backpressure".into(), + }; + let s = e.to_string(); + assert!(s.contains("TENANT_POSTING_LOCKED"), "{s}"); +} + +#[test] +fn display_permission_denied() { + let e = LedgerError::PermissionDenied { + reason: "INSUFFICIENT_SCOPE".into(), + detail: "missing ledger.write".into(), + }; + let s = e.to_string(); + assert!(s.contains("INSUFFICIENT_SCOPE"), "{s}"); +} + +#[test] +fn display_unauthenticated() { + let e = LedgerError::Unauthenticated { + detail: "no bearer token".into(), + }; + let s = e.to_string(); + assert!(s.contains("no bearer token"), "{s}"); +} + +#[test] +fn display_unavailable() { + let e = LedgerError::Unavailable { + detail: "db offline".into(), + }; + let s = e.to_string(); + assert!(s.contains("db offline"), "{s}"); +} + +#[test] +fn display_internal() { + let e = LedgerError::Internal { + detail: "unexpected state".into(), + }; + let s = e.to_string(); + assert!(s.contains("unexpected state"), "{s}"); +} + +// --------------------------------------------------------------------------- +// From projection +// --------------------------------------------------------------------------- + +#[test] +fn projects_invalid_argument_field_violation() { + let canonical = TestEntry::invalid_argument() + .with_field_violation( + "lines", + "debit does not equal credit", + "LEDGER_ENTRY_UNBALANCED", + ) + .create(); + match LedgerError::from(canonical) { + LedgerError::InvalidArgument { + field, + code, + detail, + } => { + assert_eq!(field, "lines"); + assert_eq!(code, "LEDGER_ENTRY_UNBALANCED"); + assert_eq!(detail, "debit does not equal credit"); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_invalid_argument_format_to_empty_field_code() { + // Format arm: resource_error builder with_format → Format variant. + let canonical = TestLedger::invalid_argument() + .with_format("malformed request body") + .create(); + match LedgerError::from(canonical) { + LedgerError::InvalidArgument { field, code, .. } => { + assert!( + field.is_empty(), + "field should be empty for Format, got {field:?}" + ); + assert!( + code.is_empty(), + "code should be empty for Format, got {code:?}" + ); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_invalid_argument_constraint_to_empty_field_code() { + let canonical = TestLedger::invalid_argument() + .with_constraint("constraint violated") + .create(); + match LedgerError::from(canonical) { + LedgerError::InvalidArgument { field, code, .. } => { + assert!( + field.is_empty(), + "field should be empty for Constraint, got {field:?}" + ); + assert!( + code.is_empty(), + "code should be empty for Constraint, got {code:?}" + ); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_failed_precondition() { + let canonical = TestFiscalPeriod::failed_precondition() + .with_precondition_violation("fiscal_period", "period is closed", "PERIOD_CLOSED") + .create(); + match LedgerError::from(canonical) { + LedgerError::FailedPrecondition { + subject, + code, + detail, + } => { + assert_eq!(subject, "fiscal_period"); + assert_eq!(code, "PERIOD_CLOSED"); + assert_eq!(detail, "period is closed"); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_aborted() { + let canonical = TestEntry::aborted("duplicate idempotency key") + .with_reason("IDEMPOTENCY_PAYLOAD_CONFLICT") + .create(); + match LedgerError::from(canonical) { + LedgerError::Aborted { code, detail } => { + assert_eq!(code, "IDEMPOTENCY_PAYLOAD_CONFLICT"); + assert_eq!(detail, "duplicate idempotency key"); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_not_found_with_resource_type_and_name() { + let canonical = TestFiscalPeriod::not_found("period not found") + .with_resource("2025-Q4") + .create(); + match LedgerError::from(canonical) { + LedgerError::NotFound { + resource_type, + resource_name, + detail, + } => { + // resource_type comes from gts_type prefix; name is the resource. + assert!( + !resource_type.is_empty(), + "resource_type should not be empty" + ); + assert_eq!(resource_name, "2025-Q4"); + assert_eq!(detail, "period not found"); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_not_found_missing_resource_name_to_empty() { + // not_found with empty resource string → resource_name = "". + let canonical = TestFiscalPeriod::not_found("not found") + .with_resource("") + .create(); + match LedgerError::from(canonical) { + LedgerError::NotFound { resource_name, .. } => { + assert!(resource_name.is_empty()); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_resource_exhausted() { + let canonical = TestEntry::resource_exhausted("posting quota exceeded") + .with_quota_violation("TENANT_POSTING_LOCKED", "tenant is locked") + .create(); + match LedgerError::from(canonical) { + LedgerError::ResourceExhausted { code, detail } => { + assert_eq!(code, "TENANT_POSTING_LOCKED"); + assert_eq!(detail, "posting quota exceeded"); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_permission_denied() { + let canonical = TestEntry::permission_denied() + .with_reason("INSUFFICIENT_SCOPE") + .create(); + match LedgerError::from(canonical) { + LedgerError::PermissionDenied { reason, .. } => { + assert_eq!(reason, "INSUFFICIENT_SCOPE"); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_unauthenticated() { + let canonical = CanonicalError::unauthenticated() + .with_reason("NO_BEARER") + .create(); + match LedgerError::from(canonical) { + LedgerError::Unauthenticated { detail } => { + assert!(!detail.is_empty()); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_service_unavailable() { + let canonical = CanonicalError::service_unavailable() + .with_detail("db offline") + .create(); + match LedgerError::from(canonical) { + LedgerError::Unavailable { detail } => { + assert_eq!(detail, "db offline"); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_internal() { + let canonical = CanonicalError::internal("something went wrong").create(); + match LedgerError::from(canonical) { + LedgerError::Internal { detail } => { + // Internal detail is the redacted string (not the description). + assert!(!detail.is_empty()); + } + other => panic!("wrong projection: {other:?}"), + } +} + +#[test] +fn projects_unmodelled_category_to_other() { + // Cancelled is not modelled in LedgerError — must fall through to Other. + // We build it via the standard io::Error From impl that produces Internal, + // then use the Cancelled category which has no explicit arm in LedgerError::from. + // Use serde_json::Error(io::Error) path that yields Internal (already tested). + // Instead, build Cancelled via a resource_error struct that exposes no public + // cancelled() fn — fall back to AlreadyExists which also has no LedgerError arm. + // AlreadyExists is not modelled in LedgerError — must fall through to Other. + let canonical = TestEntry::already_exists("dup entry") + .with_resource("entry-123") + .create(); + match LedgerError::from(canonical) { + LedgerError::Other { .. } => {} + other => panic!("expected Other for AlreadyExists, got {other:?}"), + } +} diff --git a/gears/bss/ledger/ledger-sdk/src/issued_invoice_manifest.rs b/gears/bss/ledger/ledger-sdk/src/issued_invoice_manifest.rs new file mode 100644 index 000000000..9dd967477 --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/issued_invoice_manifest.rs @@ -0,0 +1,68 @@ +//! The issued-invoice manifest control feed (`IssuedInvoiceManifestV1`). +//! +//! One of the three launch-blocking control feeds of Slice 7 Phase 3 (design §4.3 / +//! N-recon-1): the Invoice/Orchestration service publishes the authoritative set of +//! issued invoiceIds a `(tenant, period)` was billed for, and the ledger's +//! invoice-completeness check reads it back at close. A control feed ONLY — never a +//! posting source (design §1.2). Call-driven: the ledger never pulls a bus on the +//! post path. The default [`UnconfiguredIssuedInvoiceManifestV1`] is a fail-safe no-op +//! (returns `None` ⇒ the completeness check is inert until the feed lands; design §0 +//! decision 3), mirroring [`crate::UnconfiguredRateProviderV1`]. + +use async_trait::async_trait; +use uuid::Uuid; + +/// A configured control feed failed (unreachable / malformed). A configured-but-failing +/// feed fails the close gate loud (design §0 decision 3), never silently passes. +#[derive(Debug, thiserror::Error)] +pub enum ControlFeedError { + #[error("control feed unavailable: {0}")] + Unavailable(String), +} + +/// The independent issued-invoice manifest a `(tenant, period)` was billed for — +/// the Invoice/Orchestration control feed (design §4.3 / N-recon-1). Control feed +/// ONLY, never a posting source. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IssuedInvoiceManifest { + /// The authoritative set of issued invoiceIds for the period. + pub invoice_ids: Vec, + /// Control total: count of issued invoices (`== invoice_ids.len()` on a consistent feed). + pub count: u64, + /// Control total: summed gross amount in minor units. + pub gross_total_minor: i64, +} + +/// Read port for the issued-invoice manifest (call-driven; the ledger never pulls a +/// bus on the post path). The fail-safe default returns `None` ⇒ the +/// invoice-completeness check is inert (design §0 decision 3). +#[async_trait] +pub trait IssuedInvoiceManifestV1: Send + Sync { + /// The latest manifest the owning service published for `(tenant, period)`, or + /// `None` when no manifest is available (feed not configured / nothing pushed yet). + /// + /// # Errors + /// [`ControlFeedError`] when a CONFIGURED feed is unreachable / errors (the gate + /// then fails loud, never silently passes). + async fn latest_manifest( + &self, + tenant: Uuid, + period: &str, + ) -> Result, ControlFeedError>; +} + +/// Fail-safe default: no manifest ⇒ `None` ⇒ invoice-completeness inert (mirrors +/// `UnconfiguredRateProviderV1`). +#[derive(Debug, Default, Clone, Copy)] +pub struct UnconfiguredIssuedInvoiceManifestV1; + +#[async_trait] +impl IssuedInvoiceManifestV1 for UnconfiguredIssuedInvoiceManifestV1 { + async fn latest_manifest( + &self, + _tenant: Uuid, + _period: &str, + ) -> Result, ControlFeedError> { + Ok(None) + } +} diff --git a/gears/bss/ledger/ledger-sdk/src/lib.rs b/gears/bss/ledger/ledger-sdk/src/lib.rs new file mode 100644 index 000000000..b6f79c7c5 --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/lib.rs @@ -0,0 +1,48 @@ +//! BSS Billing Ledger SDK — infrastructure-free contract crate. +//! +//! Publishes the in-process data-access API trait (`LedgerClientV1`, resolved +//! from `ClientHub`) plus the value types, enums, and error codes a caller +//! needs to invoke the ledger. Pure money math (rounding, allocation, ISO +//! scales) is gear-internal (`bss-ledger::domain`), NOT part of the contract. + +pub mod api; +pub mod bill_run_finished; +pub mod close; +pub mod enums; +pub mod error; +pub mod issued_invoice_manifest; +pub mod posting; +pub mod provisioning; +pub mod psp_settlement_feed; +pub mod rate_provider; + +pub use api::LedgerClientV1; +pub use bill_run_finished::{BillRunFinishedV1, UnconfiguredBillRunFinishedV1}; +pub use close::CloseOutcome; +pub use enums::{AccountClass, Flow, MappingStatus, Side, SourceDocType}; +pub use error::LedgerError; +pub use issued_invoice_manifest::{ + ControlFeedError, IssuedInvoiceManifest, IssuedInvoiceManifestV1, + UnconfiguredIssuedInvoiceManifestV1, +}; +pub use posting::{ + AllocateOutcome, AllocatePayment, AllocationApplied, AllocationQueued, AllocationSplit, + AllocationView, ArInvoiceBalanceView, BalanceView, ChangeRecognitionSchedule, ChangeSegment, + CreditApplication, CreditApplicationApplied, CreditApply, CreditDebitView, CreditGrant, + DisputeOutcome, DisputeQueued, DisputeRecorded, EntryView, LineView, ODataQuery, Page, + PostEntry, PostLine, PostingRef, RecognitionRunOutcome, RecognitionRunQueued, + RecognitionRunRef, RecognitionScheduleList, RecognitionScheduleSegmentView, + RecognitionScheduleSummaryView, RecognitionScheduleView, RecordDisputePhase, ReturnPayment, + RevenueDisaggregation, RevenueDisaggregationEntry, RevenueDisaggregationQuery, + ScheduleChangeRef, SettlePayment, TriggerRecognitionRun, UnallocatedView, +}; +pub use provisioning::{ + AccountInfo, FiscalCalendarSpec, Granularity, ProvisionAccount, ProvisionCurrencyScale, + ProvisionOutcome, ProvisionRequest, +}; +pub use psp_settlement_feed::{ + PspSettlementFeedV1, PspSettlementReport, UnconfiguredPspSettlementFeedV1, +}; +pub use rate_provider::{ + CurrencyPair, ProviderRate, RateProviderError, RateProviderV1, UnconfiguredRateProviderV1, +}; diff --git a/gears/bss/ledger/ledger-sdk/src/posting.rs b/gears/bss/ledger/ledger-sdk/src/posting.rs new file mode 100644 index 000000000..232e10deb --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/posting.rs @@ -0,0 +1,745 @@ +//! Posting request/response DTOs for the in-process data-access API. +//! Handlers build balanced lines from these; the foundation persists them. +//! All amounts are `i64` minor units. + +use chrono::{DateTime, NaiveDate, Utc}; +use uuid::Uuid; + +use crate::enums::{AccountClass, MappingStatus, Side, SourceDocType}; + +/// One balanced journal line to post. Mirrors the `journal_line` columns a +/// handler supplies; the foundation fills DB-generated/derived fields. +#[derive(Clone, Debug)] +pub struct PostLine { + pub line_id: Uuid, + pub payer_tenant_id: Uuid, + pub seller_tenant_id: Option, + pub resource_tenant_id: Option, + pub account_id: Uuid, + pub account_class: AccountClass, + pub gl_code: Option, + pub side: Side, + pub amount_minor: i64, + pub currency: String, + pub invoice_id: Option, + pub due_date: Option, + pub revenue_stream: Option, + pub mapping_status: MappingStatus, + pub functional_amount_minor: Option, + pub functional_currency: Option, + pub tax_jurisdiction: Option, + pub tax_filing_period: Option, + pub tax_rate_ref: Option, + pub invoice_item_ref: Option, + pub sku_or_plan_ref: Option, + pub price_id: Option, + pub pricing_snapshot_ref: Option, + pub po_allocation_group: Option, + pub credit_grant_event_type: Option, + /// AR dispute sub-class (`ACTIVE`/`DISPUTED`), set on the two AR legs of a + /// chargeback reclass; `None` on every other line. The projector routes a + /// `DISPUTED` AR line's signed amount into `ar_invoice_balance.disputed_minor` + /// while the reclass nets ZERO on `balance_minor` (AR-class-neutral). + pub ar_status: Option, +} + +/// A balanced entry to post: header fields + its lines. The legal entity is +/// NOT supplied by the caller — in v1 there is exactly one legal entity per +/// tenant and the server derives it (= `tenant_id`); the DB column is retained +/// for future multi-LE. +#[derive(Clone, Debug)] +pub struct PostEntry { + pub entry_id: Uuid, + pub tenant_id: Uuid, + pub period_id: String, + pub entry_currency: String, + pub source_doc_type: SourceDocType, + pub source_business_id: String, + pub effective_at: NaiveDate, + pub posted_by_actor_id: Uuid, + pub correlation_id: Uuid, + /// The original entry this post reverses, set for a `REVERSAL` / + /// `MAPPING_CORRECTION` post; `None` for an `INVOICE_POST`. Persisted on + /// the `journal_entry` header (`reverses_entry_id`). + pub reverses_entry_id: Option, + /// The original entry's `period_id`, paired with [`Self::reverses_entry_id`] + /// (a reversal may post into a later period than the original). + pub reverses_period_id: Option, + pub lines: Vec, +} + +/// Reference to a posted (or replayed) entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PostingRef { + pub entry_id: Uuid, + pub created_seq: i64, + /// True when the call was an idempotent replay of a prior post. + pub replayed: bool, +} + +/// A read-back journal entry header plus its lines. Returned by +/// `LedgerClientV1::get_entry`; the gear maps the `journal_entry` row + its +/// `journal_line` rows into this. Infra-free: a plain struct mirroring +/// [`PostEntry`] on the read side, carrying the DB-derived fields a caller +/// needs to audit (`posted_at_utc`, `posted_by_actor_id`, `origin`, +/// `created_seq`) and to build a reversal (`reverses_entry_id`). +#[derive(Clone, Debug)] +pub struct EntryView { + pub entry_id: Uuid, + pub tenant_id: Uuid, + pub period_id: String, + pub entry_currency: String, + pub source_doc_type: SourceDocType, + pub source_business_id: String, + pub reverses_entry_id: Option, + pub reverses_period_id: Option, + pub posted_at_utc: DateTime, + pub effective_at: NaiveDate, + pub posted_by_actor_id: Uuid, + pub origin: String, + pub correlation_id: Uuid, + pub created_seq: i64, + pub lines: Vec, +} + +/// A read-back journal line. Returned inside [`EntryView`] and by +/// `LedgerClientV1::list_lines`. Carries the posted dims a caller filters / +/// reconciles on (`payer_tenant_id`, `account_class`, `revenue_stream`, the +/// tax dims, `mapping_status`). +#[derive(Clone, Debug)] +pub struct LineView { + pub line_id: Uuid, + pub entry_id: Uuid, + pub payer_tenant_id: Uuid, + pub account_id: Uuid, + pub account_class: AccountClass, + pub gl_code: Option, + pub side: Side, + pub amount_minor: i64, + pub currency: String, + pub currency_scale: u8, + pub invoice_id: Option, + pub due_date: Option, + pub revenue_stream: Option, + pub mapping_status: MappingStatus, + /// Functional-currency translation stamped on a cross-currency line (Slice 5): + /// `Some` when a functional rate was locked at post time, `None` on a + /// single-currency line (equals `amount_minor` by identity). Exposed so a + /// reversal can reconstruct the original functional and net it to zero. + pub functional_amount_minor: Option, + /// Functional currency of `functional_amount_minor`; `None` single-currency. + pub functional_currency: Option, + pub tax_jurisdiction: Option, + pub tax_filing_period: Option, + /// AR dispute sub-class (`ACTIVE`/`DISPUTED`) snapshot on the line; `None` + /// on non-dispute lines. Lets a reversal land on the same AR sub-grain. + pub ar_status: Option, +} + +/// A read-back account-balance cache row. Returned by +/// `LedgerClientV1::list_balances`. The signed `balance_minor` is the cached +/// normal-side-positive balance at the `(tenant, account, currency)` grain. +#[derive(Clone, Debug)] +pub struct BalanceView { + pub account_id: Uuid, + pub account_class: AccountClass, + pub currency: String, + pub balance_minor: i64, + /// Functional-currency carried balance (Slice 5). `Some` only on a + /// cross-currency grain (a functional translation was stamped); `None` on a + /// single-currency grain, where the functional value equals `balance_minor` + /// by identity (P1 decision 8 — the `?valuation=functional` read falls back to + /// `balance_minor`). + pub functional_balance_minor: Option, + /// The functional currency of `functional_balance_minor`; `None` on a + /// single-currency grain (equals `currency` by identity). + pub functional_currency: Option, +} + +/// A read-back per-invoice AR-balance cache row. Returned by +/// `LedgerClientV1::list_ar_invoice_balances`; `due_date` drives AR-aging. +#[derive(Clone, Debug)] +pub struct ArInvoiceBalanceView { + pub payer_tenant_id: Uuid, + pub account_id: Uuid, + pub invoice_id: String, + pub currency: String, + pub balance_minor: i64, + pub due_date: Option, +} + +/// A settled payment to record (the **money-in** side). The gross is what the +/// payer was charged; `fee_minor` is the processor's withheld cut (`<= gross`). +/// `scale` is the payment's currency scale as known to the caller; the ledger +/// resolves the authoritative per-line scale from the provisioned currency +/// config, so this is advisory. `effective_at` `None` ⇒ the receipt is stamped +/// at post time. Consumed by `LedgerClientV1::settle_payment`. +#[derive(Clone, Debug)] +pub struct SettlePayment { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub payment_id: String, + pub gross_minor: i64, + pub fee_minor: i64, + pub currency: String, + pub scale: u8, + pub effective_at: Option>, +} + +/// A settlement to claw back (the **reversal of a money-in**). Records that the +/// PSP returned a previously-settled receipt: it removes `amount_minor` from the +/// payer's unallocated pool (`DR UNALLOCATED` / `CR CASH_CLEARING`) and +/// decrements the original payment's `settled_minor`. `psp_return_id` is the +/// idempotency key (a re-post replays). `scale` is advisory (as [`SettlePayment`]); +/// `effective_at` `None` ⇒ the return is stamped at post time. Consumed by +/// `LedgerClientV1::return_payment`. +#[derive(Clone, Debug)] +pub struct ReturnPayment { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + /// The original settled payment being clawed back. + pub payment_id: String, + /// External return identity — the idempotency key. + pub psp_return_id: String, + pub amount_minor: i64, + pub currency: String, + pub scale: u8, + pub effective_at: Option>, +} + +/// A chargeback dispute phase to record (the dispute state machine, §4.5). One +/// endpoint (`POST /disputes/{dispute_id}/phases`) records one phase of a +/// dispute; the LEDGER chooses the **variant** at `opened` from `funds_at_open` +/// (`"withheld"` ⇒ cash-hold, `"not_moved"` ⇒ AR-reclass) and the `won`/`lost` +/// outcomes branch on the recorded variant. `phase` is one of `"opened"`, +/// `"won"`, `"lost"`, `"partial"` (Group B implements `opened`). `cycle` +/// defaults to 1 and increments on a re-open. `invoice_id` is the disputed +/// `(payer, invoice)` AR grain — required for an AR-reclass `opened`, ignored +/// for cash-hold. `scale` is advisory (as [`SettlePayment`]); `effective_at` +/// `None` ⇒ the phase is stamped at post time. Consumed by +/// `LedgerClientV1::record_dispute_phase`. +#[derive(Clone, Debug)] +pub struct RecordDisputePhase { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + /// The disputed payment. + pub payment_id: String, + /// External dispute identity (the idempotency key's first token). + pub dispute_id: String, + /// The disputed `(payer, invoice)` AR grain — required for an AR-reclass + /// `opened`, ignored for cash-hold. + pub invoice_id: Option, + /// Re-entrancy counter (`>= 1`); defaults to 1 at the wire boundary. + pub cycle: i32, + /// The phase literal: `"opened" | "won" | "lost" | "partial"`. + pub phase: String, + /// The funds-movement fact the ledger reads at `opened` to choose the + /// variant: `"withheld"` (card rails) ⇒ cash-hold, `"not_moved"` + /// (invoice/ACH) ⇒ AR-reclass. + pub funds_at_open: String, + /// The disputed amount in minor units (`> 0`): the **gross** claim — the full + /// amount the buyer paid / the card network reverses, NOT net of the PSP fee. + /// A `CASH_HOLD` dispute's cash hold is sized at `net = settled − fee` by the + /// ledger itself; this gross value drives the AR-reclass legs + the dispute row. + pub disputed_amount_minor: i64, + pub currency: String, + /// Advisory currency scale; the ledger resolves the authoritative one. + pub scale: u8, + pub effective_at: Option>, +} + +/// The outcome of a dispute-phase record that POSTED inline (the dispute had its +/// `opened` cycle, or this IS the `opened`): the posting handle. The `Recorded` +/// arm of [`DisputeOutcome`]. (A struct rather than a bare `PostingRef` so a +/// later phase can carry extra fields without a breaking change — mirrors +/// [`AllocationApplied`].) +#[derive(Clone, Debug)] +pub struct DisputeRecorded { + pub posting: PostingRef, +} + +/// The outcome of a dispute-phase record that was DEFERRED because the `won`/ +/// `lost` arrived before its `opened` (§4.7 out-of-order): the request was +/// durably queued onto `ledger_pending_event_queue` and will be applied by the +/// drain once the `opened` lands. Carries the queue key (`flow` + `business_id`) +/// and the `queued_at` instant — the surface for the REST 202 +/// `dispute-phase-queued` body. No `PostingRef`: nothing has posted yet. The +/// `Queued` arm of [`DisputeOutcome`] (mirrors [`AllocationQueued`]). +#[derive(Clone, Debug)] +pub struct DisputeQueued { + /// The deferred-apply queue flow (the `CHARGEBACK` source-doc literal). + pub flow: String, + /// The queue/dedup business id — `dispute_id:cycle:phase`. + pub business_id: String, + /// When the intake durably enqueued the request. + pub queued_at: DateTime, +} + +/// The result of `LedgerClientV1::record_dispute_phase`: either the phase posted +/// inline (`Recorded`) or it was durably queued because its `opened` has not +/// landed yet (`Queued`, surfaced as HTTP 202 `dispute-phase-queued`). The two +/// arms drive the handler's 201/200-vs-202 split (mirrors [`AllocateOutcome`]). +#[derive(Clone, Debug)] +pub enum DisputeOutcome { + Recorded(DisputeRecorded), + Queued(DisputeQueued), +} + +/// One caller-computed allocation share (Mode B, §4.4 F-5): apply `amount_minor` +/// of the lump to `invoice_id`. Carried in [`AllocatePayment::splits`] when the +/// caller supplies the split instead of letting a precedence policy decide it. +#[derive(Clone, Debug)] +pub struct AllocationSplit { + pub invoice_id: String, + pub amount_minor: i64, +} + +/// An allocation of a settled payment's unallocated pool to the payer's open +/// receivables (the **money-out** side). `allocation_id` is the idempotency key. +/// As with [`SettlePayment`], `scale` is advisory — the ledger resolves the +/// authoritative per-line scale. Consumed by `LedgerClientV1::allocate_payment`. +/// +/// Two modes: when `splits` is `None` (Mode A/B precedence), `lump_minor` is +/// distributed by the tenant's precedence policy and `hint_invoice_id` jumps one +/// invoice to the front of that order. When `splits` is `Some` (Mode B escape +/// hatch), the precedence decision is skipped and the caller's explicit shares +/// are validated against the open receivables instead — they must name open +/// invoices, not over-allocate any invoice, and sum to at most `lump_minor`; +/// `hint_invoice_id` is then moot. +#[derive(Clone, Debug)] +pub struct AllocatePayment { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub payment_id: String, + pub allocation_id: Uuid, + pub lump_minor: i64, + pub currency: String, + pub scale: u8, + pub hint_invoice_id: Option, + /// Mode B caller-computed split; `None` ⇒ the precedence policy decides. + pub splits: Option>, +} + +/// One recorded `payment_allocation` row: how much of a payment was applied to +/// `invoice_id`, when, under which precedence policy. Returned inside +/// [`AllocationApplied`] and by `LedgerClientV1::list_payment_allocations`. +#[derive(Clone, Debug)] +pub struct AllocationView { + pub invoice_id: String, + pub amount_minor: i64, + pub currency: String, + pub allocated_at_utc: DateTime, + pub precedence_policy_ref: String, +} + +/// A read-back of the payer's unallocated pool for a currency: the still-undrained +/// portion of settled receipts. Returned by `LedgerClientV1::read_unallocated`. +#[derive(Clone, Debug)] +pub struct UnallocatedView { + pub payer_tenant_id: Uuid, + pub currency: String, + pub balance_minor: i64, +} + +/// The outcome of an allocate that posted inline (the payment was already +/// settled): the posting handle plus the per-invoice splits the allocation +/// applied. The `Applied` arm of [`AllocateOutcome`]. +#[derive(Clone, Debug)] +pub struct AllocationApplied { + pub posting: PostingRef, + pub allocations: Vec, +} + +/// The outcome of an allocate that was DEFERRED because the payment was not yet +/// settled (§4.7 allocation-before-settlement): the request was durably queued +/// onto `ledger_pending_event_queue` and will be applied by the drain once the +/// settlement lands. Carries the queue key (`flow` + `business_id`) and the +/// `queued_at` instant the intake stamped — the surface for the REST 202 +/// `allocation-queued` body. No `PostingRef`: nothing has posted yet (that +/// arrives on the drain, Group D). The `Queued` arm of [`AllocateOutcome`]. +#[derive(Clone, Debug)] +pub struct AllocationQueued { + /// The deferred-apply queue flow (the `PAYMENT_ALLOCATE` source-doc literal). + pub flow: String, + /// The queue/dedup business id — the allocation's `allocation_id`. + pub business_id: String, + /// When the intake durably enqueued the request. + pub queued_at: DateTime, +} + +/// The result of `LedgerClientV1::allocate_payment`: either the allocation posted +/// inline (`Applied`, the payment was settled) or it was durably queued for a +/// later drain (`Queued`, the payment was not yet settled — surfaced as HTTP 202 +/// `allocation-queued`). The two arms drive the handler's 201-vs-202 split. +#[derive(Clone, Debug)] +pub enum AllocateOutcome { + Applied(AllocationApplied), + Queued(AllocationQueued), +} + +/// A request to trigger an ASC 606 recognition run for one fiscal period (the +/// S6 release, design §5). `run_id` is the run-trigger idempotency key: the +/// orchestration layer dedups on `(tenant, period_id, run_id)`, so a replay +/// returns the prior run reference instead of starting a second run. `None` ⇒ +/// the ledger mints a fresh `run_id` (a first, un-keyed trigger); a caller that +/// wants idempotent retries supplies a stable one. Consumed by +/// `LedgerClientV1::trigger_recognition_run`. +#[derive(Clone, Debug)] +#[allow( + clippy::struct_field_names, + reason = "the *_id fields mirror the run-trigger identity tuple (tenant / period / run)" +)] +pub struct TriggerRecognitionRun { + /// The seller tenant whose ledger this releases revenue in (the PEP target). + pub tenant_id: Uuid, + /// The fiscal period to release due segments for (`YYYYMM`). + pub period_id: String, + /// The run-trigger idempotency key. `None` ⇒ the ledger mints a fresh one. + pub run_id: Option, +} + +/// The reference to a recognition run that EXECUTED (fresh or an idempotent +/// replay of a prior trigger): the run identity + a tally of how many due +/// segments it released this pass (`released` fresh + `replayed` already-done). +/// The `Ran` arm of [`RecognitionRunOutcome`]. (A struct rather than a bare +/// `run_id` so a later slice can carry more run facts without a breaking +/// change — mirrors [`AllocationApplied`].) +#[derive(Clone, Debug)] +pub struct RecognitionRunRef { + /// The run that executed (minted by the trigger, or replayed). + pub run_id: Uuid, + /// The period the run released for (`YYYYMM`). + pub period_id: String, + /// `true` when this trigger replayed a prior run with the same + /// `(tenant, period_id, run_id)` (no new run row was inserted). + pub replayed: bool, + /// Segments released on THIS pass (a fresh `DR CL / CR Revenue` post). + pub released: usize, + /// Segments that were already released (an idempotent `RECOGNITION` replay). + pub already_recognized: usize, +} + +/// The outcome of a recognition run that found work it could not release in +/// period order: at least one due segment's lower-`period_id` predecessor was +/// not yet `DONE`, so the segment was parked `QUEUED` (design §4.6 ordering) +/// rather than released early. The `Queued` arm of [`RecognitionRunOutcome`], +/// surfaced as HTTP 202 `recognition-period-queued` (a success/queued token, +/// NOT a rejection — uniform across Slices 2/3/4). A later run drains the +/// `QUEUED` segments once their predecessors commit. Mirrors [`AllocationQueued`]. +#[derive(Clone, Debug)] +pub struct RecognitionRunQueued { + /// The run that executed (it may still have released in-order segments; the + /// queued ones are the out-of-order tail). + pub run_id: Uuid, + /// The period the run was triggered for (`YYYYMM`). + pub period_id: String, + /// Segments released in order on this pass before/around the queued ones. + pub released: usize, + /// Segments parked `QUEUED` this pass (a predecessor period was not `DONE`). + pub queued: usize, +} + +/// The result of `LedgerClientV1::trigger_recognition_run`: either the run +/// executed and released its due segments in order (`Ran`), or it had to park +/// one or more out-of-order segments `QUEUED` for a later drain (`Queued`, +/// surfaced as HTTP 202 `recognition-period-queued`). The two arms drive the +/// handler's 200-vs-202 split (mirrors [`AllocateOutcome`] / [`DisputeOutcome`]). +#[derive(Clone, Debug)] +pub enum RecognitionRunOutcome { + Ran(RecognitionRunRef), + Queued(RecognitionRunQueued), +} + +/// The query for `LedgerClientV1::list_revenue_disaggregation` (the ASC 606 +/// revenue-recognition-by-stream report, design §3.5 / §4.5). Recognized revenue +/// is the **DONE** recognition segments (each is a posted `DR CONTRACT_LIABILITY +/// / CR REVENUE` release), grouped by `(period_id, revenue_stream)`. `tenant_id` +/// is the seller whose recognized revenue is reported (the PEP target); a `None` +/// `period_id` reports every period, a `Some(_)` narrows to that one. +#[derive(Clone, Debug)] +pub struct RevenueDisaggregationQuery { + /// The seller tenant whose recognized revenue is disaggregated. + pub tenant_id: Uuid, + /// Narrow to one fiscal period (`YYYYMM`); `None` ⇒ all periods. + pub period_id: Option, +} + +/// One disaggregated recognized-revenue grain: the revenue RECOGNIZED into +/// `revenue_stream` during `period_id`, in minor units of `currency`. The sum of +/// the DONE segments' `amount_minor` at the `(period_id, revenue_stream)` grain +/// (each DONE segment posted a `DR CONTRACT_LIABILITY / CR REVENUE` release, so +/// this is the recognized-Revenue credit that period for that stream). A row of +/// [`RevenueDisaggregation::entries`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RevenueDisaggregationEntry { + /// The fiscal period the revenue recognized in (`YYYYMM`). + pub period_id: String, + /// The revenue stream the recognized revenue books to. + pub revenue_stream: String, + /// Revenue recognized into this `(period, stream)` grain, in minor units + /// (`Σ amount_minor` of the DONE segments). + pub recognized_minor: i64, + /// ISO currency of the recognized amount (one account/schedule per currency). + pub currency: String, +} + +/// The result of `LedgerClientV1::list_revenue_disaggregation`: the recognized +/// revenue disaggregated by `(period_id, revenue_stream)`, ordered by +/// `(period_id, revenue_stream)`. Tenant-scoped (SQL-level BOLA): a foreign +/// tenant yields no entries. +#[derive(Clone, Debug)] +pub struct RevenueDisaggregation { + pub entries: Vec, +} + +/// A read-back of one ASC 606 recognition schedule's lifecycle view (design +/// §3.7 / §4, the `GET /recognition-schedules/{schedule_id}` response). The +/// schedule header (the deferred Contract-liability obligation + its +/// recognized-to-date counter + the immutable policy/PO/subscription refs + +/// the lineage `version` and durable `status`) plus its ordered +/// [`segments`](Self::segments). Returned as `Some` by +/// [`LedgerClientV1::get_recognition_schedule`]; `None` ⇒ absent or +/// foreign-owned (SQL-level BOLA, the handler renders a 404 either way). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecognitionScheduleView { + /// The schedule's business id (`(tenant, schedule_id)` PK tail). + pub schedule_id: String, + /// The durable lifecycle status (`ACTIVE` | `REPLACED` | `CANCELLED` | …). + pub status: String, + /// The lineage version (`0` for a freshly built schedule; `old + 1` for a + /// `replace` successor). + pub version: i64, + /// The revenue stream the obligation books to (one schedule per stream). + pub revenue_stream: String, + /// ISO-4217 currency (one schedule/account per currency). + pub currency: String, + /// The total deferred Contract-liability the schedule plans to release. + pub total_deferred_minor: i64, + /// The cumulative recognized-to-date (`<= total_deferred_minor`, the + /// per-obligation over-recognition cap). + pub recognized_minor: i64, + /// The originating posted invoice (`source_invoice_id`). + pub source_invoice_id: String, + /// The Contract-liability invoice line the schedule draws down + /// (`source_invoice_item_ref`, the §4.7 invoice-link anchor). + pub source_invoice_item_ref: String, + /// The PO / allocation group this obligation books under (audit); `None` + /// when the line carries no group. + pub po_allocation_group: Option, + /// The subscription / entitlement this obligation belongs to (audit). + pub subscription_ref: Option, + /// The immutable deferral/timing policy version stamped at build. + pub policy_ref: String, + /// The schedule's segments, ordered by `segment_no` (1:1 with `period_id`, + /// so also period order). + pub segments: Vec, +} + +/// A read-back of one recognition segment (a time- or milestone-slice of a +/// [`RecognitionScheduleView`]): the `segment_no` (immutable, 1:1 with +/// `period_id`), the period it recognizes into, its minor-unit amount, and its +/// release `status` (`PENDING` | `QUEUED` | `DONE`). A row of +/// [`RecognitionScheduleView::segments`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecognitionScheduleSegmentView { + /// The immutable segment number (1:1 with `period_id`). + pub segment_no: i32, + /// The fiscal period this segment recognizes into (`YYYYMM`). + pub period_id: String, + /// The segment's minor-unit amount. + pub amount_minor: i64, + /// The release status (`PENDING` | `QUEUED` | `DONE`). + pub status: String, +} + +/// The header view of a recognition schedule WITHOUT its segments — the row +/// shape of the `GET /recognition-schedules` list/discovery surface (and the +/// per-schedule reference echoed in the invoice-post response). The full +/// per-schedule view (incl. segments) is the by-id +/// `GET /recognition-schedules/{schedule_id}`. Fields mirror +/// [`RecognitionScheduleView`] minus [`RecognitionScheduleView::segments`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecognitionScheduleSummaryView { + /// The schedule's business id (`(tenant, schedule_id)` PK tail). + pub schedule_id: String, + /// The durable lifecycle status (`ACTIVE` | `REPLACED` | `CANCELLED` | …). + pub status: String, + /// The lineage version (`0` fresh; `old + 1` for a `replace` successor). + pub version: i64, + /// The revenue stream the obligation books to (one schedule per stream). + pub revenue_stream: String, + /// ISO-4217 currency (one schedule/account per currency). + pub currency: String, + /// The total deferred Contract-liability the schedule plans to release. + pub total_deferred_minor: i64, + /// The cumulative recognized-to-date (`<= total_deferred_minor`). + pub recognized_minor: i64, + /// The originating posted invoice (`source_invoice_id`). + pub source_invoice_id: String, + /// The Contract-liability invoice line the schedule draws down. + pub source_invoice_item_ref: String, + /// The PO / allocation group this obligation books under; `None` when absent. + pub po_allocation_group: Option, + /// The subscription / entitlement this obligation belongs to (audit). + pub subscription_ref: Option, + /// The immutable deferral/timing policy version stamped at build. + pub policy_ref: String, +} + +/// The result of [`LedgerClientV1::list_recognition_schedules`]: the matching +/// schedule headers plus `truncated` — `true` when the scan hit the server cap +/// and the tail was dropped, so a client can tell a complete list from a capped +/// one (the list surface is not paginated). `Default` is the empty, untruncated +/// list. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RecognitionScheduleList { + /// The matching schedule headers (`0..=cap`). + pub schedules: Vec, + /// `true` when the result was capped (more schedules exist than returned). + pub truncated: bool, +} + +/// One replacement recognition segment supplied on a `replace` change (design +/// §3.6 / Group H): the `(period_id, amount_minor)` slice the NEW schedule +/// version re-plans the remaining deferred over. `Σ amount_minor` of the supplied +/// segments is the new schedule's `total_deferred_minor` (= the OLD schedule's +/// remaining deferred, `total_deferred − recognized`). Carried in +/// [`ChangeRecognitionSchedule::new_segments`]; ignored on a `cancel`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChangeSegment { + /// Fiscal `period_id` (`YYYYMM`) this replacement segment recognizes into. + pub period_id: String, + /// Minor-unit amount of this segment (`>= 0`). + pub amount_minor: i64, +} + +/// A schedule change / cancel request (design §3.6 / §4.6, Group H). Targets one +/// ACTIVE `(tenant, schedule_id)` recognition schedule and either CANCELS it +/// (the unreleased deferred remainder stays as `CONTRACT_LIABILITY`; no +/// auto-reversal) or REPLACES it with a fresh schedule version that re-plans the +/// REMAINING deferred over `new_segments` (prospective — already-recognized +/// revenue is not unwound). `change_id` is the idempotency key (a replay returns +/// the prior result, minting no second schedule). `treatment` is the upstream +/// modification-accounting decision: `"prospective"` / `"separate_contract"` +/// apply directly; `"catch_up"` or any unknown value is surfaced as a +/// `MODIFICATION_TREATMENT_REVIEW` rejection (never silently prospective, §3.6) — +/// the ledger does not own the catch-up decision. `action` is `"cancel"` or +/// `"replace"`. Consumed by `LedgerClientV1::change_recognition_schedule`; the +/// target `schedule_id` is bound from the request PATH. +#[derive(Clone, Debug)] +pub struct ChangeRecognitionSchedule { + /// The seller tenant whose schedule this changes (the PEP gate target). + pub tenant_id: Uuid, + /// The ACTIVE schedule being cancelled / replaced (bound from the PATH). + pub schedule_id: String, + /// The change idempotency key — a replay returns the prior result. + pub change_id: String, + /// The change action literal: `"cancel"` | `"replace"`. + pub action: String, + /// The upstream modification-accounting treatment: `"prospective"` | + /// `"separate_contract"` (proceed) | `"catch_up"` / unknown (review). + pub treatment: String, + /// The replacement segments for a `replace` (the NEW schedule version's plan + /// of the remaining deferred); `None` on a `cancel`. + pub new_segments: Option>, +} + +/// The result of `LedgerClientV1::change_recognition_schedule`: a small reference +/// to the change's outcome. `schedule_id` is the original (now terminal) +/// schedule; `new_schedule_id` is the successor version's id on a `replace` +/// (`None` on a `cancel`); `status` is the original schedule's resulting durable +/// lifecycle status (`"REPLACED"` or `"CANCELLED"`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScheduleChangeRef { + /// The original schedule that was cancelled / replaced. + pub schedule_id: String, + /// The successor schedule version's id on a `replace`; `None` on a `cancel`. + pub new_schedule_id: Option, + /// The original schedule's resulting status (`"REPLACED"` | `"CANCELLED"`). + pub status: String, +} + +/// A reusable-credit operation (the wallet surface, architecture §5.2). ONE +/// endpoint (`POST /credit-applications`), two kinds: `Grant` parks unallocated +/// pool cash into the wallet; `Apply` spends the wallet against open AR. Consumed +/// by `LedgerClientV1::post_credit_application`. +#[derive(Clone, Debug)] +pub enum CreditApplication { + Grant(CreditGrant), + Apply(CreditApply), +} + +impl CreditApplication { + /// The seller tenant whose ledger the operation posts into — the authz gate's + /// target, read from whichever arm the enum carries (both grant and apply + /// name the same `tenant_id` field). + #[must_use] + pub fn tenant_id(&self) -> Uuid { + match self { + Self::Grant(g) => g.tenant_id, + Self::Apply(a) => a.tenant_id, + } + } +} + +/// Grant: park `amount_minor` of the payer's unallocated pool into the wallet +/// sub-grain `credit_grant_event_type` (`DR UNALLOCATED` / `CR REUSABLE_CREDIT`). +/// As with [`SettlePayment`], `scale` is advisory — the ledger resolves the +/// authoritative per-line scale from the provisioned currency config. +#[derive(Clone, Debug)] +// `credit_grant_event_type` is the canonical domain/DB term (matches the line +// field + the sub-grain column), not field-name noise — keep it as-is. +#[allow(clippy::struct_field_names)] +pub struct CreditGrant { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub credit_application_id: String, + pub currency: String, + pub scale: u8, + pub amount_minor: i64, + pub credit_grant_event_type: String, +} + +/// Apply: spend the payer's reusable-credit wallet against the named open +/// receivables (oldest-grant-first draw-down). The per-invoice receivable shares +/// reuse [`AllocationSplit`] (`invoice_id` + `amount_minor`). `scale` is advisory. +#[derive(Clone, Debug)] +pub struct CreditApply { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub credit_application_id: String, + pub currency: String, + pub scale: u8, + pub targets: Vec, +} + +/// One per-sub-grain wallet draw-down an apply posted: `amount_minor` drawn from +/// the `credit_grant_event_type` bucket. A row of [`CreditApplicationApplied::debits`]. +#[derive(Clone, Debug)] +pub struct CreditDebitView { + pub credit_grant_event_type: String, + pub amount_minor: i64, +} + +/// The outcome of a grant or apply: the posting handle plus — for an apply — the +/// per-sub-grain wallet draw-downs (`debits`) and the per-invoice receivable +/// shares (`applications`, reusing [`AllocationSplit`]). A grant moves no +/// wallet/AR splits, so both vecs are empty. Returned by +/// `LedgerClientV1::post_credit_application`. +#[derive(Clone, Debug)] +pub struct CreditApplicationApplied { + pub posting: PostingRef, + pub debits: Vec, + pub applications: Vec, +} + +// The journal-line / balance / account list endpoints take a canonical +// `toolkit_odata::ODataQuery` (parsed `$filter` / `$orderby` / `$select` + +// `limit` / `cursor`) instead of bespoke filter structs, and return the +// canonical `toolkit_odata::Page` envelope (`items` + `page_info` cursor +// metadata). The wire `$filter` field names are declared by the gear's +// `FilterField` enums (the gear binds them to columns); the SDK stays +// infra-free and only carries the parsed query + the page result. This is the +// platform list pattern (RBAC/AM/RG). The legacy `LineFilter` / `BalanceFilter` +// structs and the bespoke `Page { items, next_cursor }` are gone. +pub use toolkit_odata::{ODataQuery, Page}; diff --git a/gears/bss/ledger/ledger-sdk/src/provisioning.rs b/gears/bss/ledger/ledger-sdk/src/provisioning.rs new file mode 100644 index 000000000..4e07cff37 --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/provisioning.rs @@ -0,0 +1,120 @@ +//! Seller-provisioning request/response value types for the in-process +//! data-access API. Infrastructure-free: the REST DTOs (in the gear) own +//! serde/utoipa and map onto these. All amounts are `i64` minor units. + +use uuid::Uuid; + +use crate::enums::{AccountClass, Side}; + +/// Fiscal-calendar granularity. MVP supports monthly periods only. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Granularity { + Month, +} + +impl Granularity { + /// The stored literal (`Month => "MONTH"`). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Month => "MONTH", + } + } + + /// Parse a stored literal back to a granularity, or `None` if unknown. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "MONTH" => Some(Self::Month), + _ => None, + } + } +} + +/// The fiscal-calendar config seeded for a legal entity. +#[derive(Clone, Debug)] +pub struct FiscalCalendarSpec { + pub timezone: String, + pub granularity: Granularity, + pub fy_start_month: u8, + /// The legal entity's functional (books) currency, ISO-4217 (S5-F3). `None` + /// seeds a single-currency tenant (no FX); set it to enable cross-currency FX. + pub functional_currency: Option, +} + +/// One chart-of-accounts row to seed. +#[derive(Clone, Debug)] +pub struct ProvisionAccount { + pub account_class: AccountClass, + pub currency: String, + pub revenue_stream: Option, + pub normal_side: Side, + pub may_go_negative: bool, +} + +/// One non-ISO currency-scale row to seed. +#[derive(Clone, Debug)] +pub struct ProvisionCurrencyScale { + pub currency: String, + /// Minor-unit scale (digits after the decimal point), e.g. `2` for USD, + /// `8` for BTC. Always a small non-negative number — `u8` makes a negative + /// or absurdly large scale unrepresentable. + pub minor_units: u8, + pub source: String, + /// Per-currency plausible maximum in MAJOR units, governing the `i64` + /// headroom guard at registration. `None` requests the default `10^12` + /// (max scale 6); a higher-precision currency (e.g. BTC scale 8) passes + /// a smaller cap (e.g. `21_000_000`) so its scale fits the headroom. + pub plausible_max_major: Option, +} + +/// A full seller-provisioning request: the chart of accounts, non-ISO +/// currency scales, and the fiscal-calendar config to seed in one txn. The +/// legal entity is NOT supplied — v1 is one legal entity per tenant, derived +/// server-side (= `tenant_id`); the DB column is retained for future multi-LE. +#[derive(Clone, Debug)] +pub struct ProvisionRequest { + pub tenant_id: Uuid, + pub accounts: Vec, + pub currency_scales: Vec, + pub fiscal_calendar: FiscalCalendarSpec, +} + +/// A chart-of-accounts entry: its coordinate plus the persistent `account_id` +/// callers post to / read balances for. Returned both by provisioning (the +/// accounts a call created) and by `list_accounts` (the full chart). +#[derive(Clone, Debug)] +pub struct AccountInfo { + pub account_id: Uuid, + pub account_class: AccountClass, + pub currency: String, + pub revenue_stream: Option, + pub lifecycle_state: String, +} + +/// The accounts a provisioning call CREATED + per-grain created-vs-existing +/// counts. The full chart of accounts (with ids) is read via `list_accounts`. +#[derive(Clone, Debug)] +pub struct ProvisionOutcome { + /// The chart-of-accounts entries THIS call created (empty on a pure re-call). + pub accounts: Vec, + pub accounts_created: u32, + pub accounts_existing: u32, + pub scales_created: u32, + pub scales_existing: u32, + pub calendar_created: bool, + pub period_id: String, + pub period_created: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn granularity_parses_known_literal() { + assert_eq!(Granularity::parse("MONTH"), Some(Granularity::Month)); + assert!(Granularity::parse("WEEK").is_none()); + assert_eq!(Granularity::Month.as_str(), "MONTH"); + } +} diff --git a/gears/bss/ledger/ledger-sdk/src/psp_settlement_feed.rs b/gears/bss/ledger/ledger-sdk/src/psp_settlement_feed.rs new file mode 100644 index 000000000..fc3dd5d92 --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/psp_settlement_feed.rs @@ -0,0 +1,58 @@ +//! The PSP settlement-report control feed (`PspSettlementFeedV1`). +//! +//! One of the three launch-blocking control feeds of Slice 7 Phase 3 (design §4.3 / +//! N-recon-1): the PSP (or its adapter) reports the net settled amount for a +//! `(tenant, period)`, and the ledger's close gate reconciles it against recorded +//! settlements. A control feed ONLY — never a posting source (design §1.2). The +//! default [`UnconfiguredPspSettlementFeedV1`] is a fail-safe no-op (returns `None` ⇒ +//! the settlement reconciliation is inert until the feed lands; design §0 decision 3), +//! mirroring [`crate::UnconfiguredRateProviderV1`]. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::issued_invoice_manifest::ControlFeedError; + +/// A PSP settlement report for a `(tenant, period)` — the net settled amount the PSP +/// reconciled, as a control feed (never a posting source). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PspSettlementReport { + /// External PSP report identity (idempotency grain for ingest). + pub report_id: String, + /// Net settled amount in minor units the PSP reports for the period (net of refunds/returns). + pub settled_minor: i64, + /// ISO-4217 currency of the report. + pub currency: String, +} + +/// Read port for the PSP settlement report (call-driven; the ledger never pulls a +/// bus on the post path). The fail-safe default returns `None` ⇒ the settlement +/// reconciliation is inert (design §0 decision 3). +#[async_trait] +pub trait PspSettlementFeedV1: Send + Sync { + /// The PSP settlement report for `(tenant, period)`, or `None` when not available. + /// + /// # Errors + /// [`ControlFeedError`] on a configured-feed failure. + async fn settlement_report( + &self, + tenant: Uuid, + period: &str, + ) -> Result, ControlFeedError>; +} + +/// Fail-safe default: no report ⇒ `None` ⇒ settlement reconciliation inert (mirrors +/// `UnconfiguredRateProviderV1`). +#[derive(Debug, Default, Clone, Copy)] +pub struct UnconfiguredPspSettlementFeedV1; + +#[async_trait] +impl PspSettlementFeedV1 for UnconfiguredPspSettlementFeedV1 { + async fn settlement_report( + &self, + _tenant: Uuid, + _period: &str, + ) -> Result, ControlFeedError> { + Ok(None) + } +} diff --git a/gears/bss/ledger/ledger-sdk/src/rate_provider.rs b/gears/bss/ledger/ledger-sdk/src/rate_provider.rs new file mode 100644 index 000000000..e1bda3056 --- /dev/null +++ b/gears/bss/ledger/ledger-sdk/src/rate_provider.rs @@ -0,0 +1,116 @@ +//! The FX rate-provider plugin contract (`RateProviderV1`). +//! +//! A cross-gear, GTS-versioned SDK trait (`gts.cf.bss.ledger.rate-provider.v1`, +//! mirroring [`crate::LedgerClientV1`]): an external adapter-gear (ECB primary / +//! PSP-bank fallback) implements it and registers an `Arc` in +//! the `ClientHub`; a ledger-side `RateSyncJob` pulls `fetch_latest` into the +//! local rate store. The adapter ONLY fetches — translation, triangulation, and +//! staleness all stay in the ledger. The default [`UnconfiguredRateProviderV1`] +//! is a fail-safe no-op (the store stays empty → FX-needing posts block). + +use async_trait::async_trait; +use toolkit_security::SecurityContext; + +/// A currency pair to fetch a rate for (ISO 4217 codes). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CurrencyPair { + pub base: String, + pub quote: String, +} + +/// A rate as published by a provider at a point in time. `rate_micro` is the +/// fixed-precision multiplier (functional per unit transaction × 1e6). `as_of` +/// drives the ledger's staleness rule; the provider id is recorded separately. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProviderRate { + pub base: String, + pub quote: String, + pub rate_micro: i64, + pub as_of: chrono::DateTime, +} + +/// A rate-provider failure. Semantic (the ledger maps it to a sync-job alarm, or +/// at lock time to `FX_RATE_UNAVAILABLE`); never an HTTP status here. +#[derive(Debug, thiserror::Error)] +pub enum RateProviderError { + #[error("pair {base}->{quote} not published")] + PairUnavailable { base: String, quote: String }, + #[error("provider unreachable: {0}")] + Unreachable(String), + #[error("upstream status {0}")] + UpstreamStatus(u16), + #[error("invalid pair: {0}")] + InvalidPair(String), + #[error("internal: {0}")] + Internal(String), +} + +/// The FX rate-provider plugin contract — implemented out-of-gear, resolved from +/// `ClientHub` by GTS instance. See the module docs. +#[async_trait] +pub trait RateProviderV1: Send + Sync { + /// Stable id recorded verbatim on every `rate_snapshot.provider`; the + /// fallback-order key. E.g. "ecb", "bank-x", "psp-stripe". + fn provider_id(&self) -> &str; + + /// Fetch the latest published rates for the requested pairs — one round-trip. + /// An adapter that publishes a whole table returns everything it has and OMITS + /// pairs it cannot serve (the caller treats a missing pair as no acceptable + /// rate). MUST NOT be called on the posting path — only the background + /// `RateSyncJob` calls it (a provider outage fails the job, never a post). + /// + /// # Errors + /// [`RateProviderError`] on an upstream failure. + async fn fetch_latest( + &self, + ctx: &SecurityContext, + pairs: &[CurrencyPair], + request_id: &str, + ) -> Result, RateProviderError>; + + /// Liveness probe for the sync-job reachability alarm. Default = a trivial + /// `fetch_latest`; an adapter MAY override with a cheaper ping. + /// + /// # Errors + /// [`RateProviderError`] when the provider is unreachable. + async fn health( + &self, + ctx: &SecurityContext, + request_id: &str, + ) -> Result<(), RateProviderError> { + self.fetch_latest(ctx, &[], request_id).await.map(|_| ()) + } +} + +/// Fail-safe default until a real adapter is wired: every fetch fails, so the +/// local rate store stays empty and FX-needing posts block with +/// `FX_RATE_UNAVAILABLE` (never a silent wrong rate). Mirrors the gear's +/// `AlwaysSatisfiedObligationState` / `NoopLedgerMetrics` no-op ports. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnconfiguredRateProviderV1; + +#[async_trait] +impl RateProviderV1 for UnconfiguredRateProviderV1 { + // The trait returns `&str` (tied to `&self`) so a real adapter can return a + // borrowed `&self.id` field; this no-op default happens to return a `'static` + // literal, which clippy would prefer typed `&'static str` — but that would + // not match the trait method signature. Allow the literal bound here. + #[allow( + clippy::unnecessary_literal_bound, + reason = "trait signature is `-> &str` for adapters with a borrowed id; this default returns a literal" + )] + fn provider_id(&self) -> &str { + "none" + } + + async fn fetch_latest( + &self, + _ctx: &SecurityContext, + _pairs: &[CurrencyPair], + _request_id: &str, + ) -> Result, RateProviderError> { + Err(RateProviderError::Unreachable( + "no FX rate adapter configured".to_owned(), + )) + } +} diff --git a/gears/bss/ledger/ledger/Cargo.toml b/gears/bss/ledger/ledger/Cargo.toml new file mode 100644 index 000000000..ca1c1bd18 --- /dev/null +++ b/gears/bss/ledger/ledger/Cargo.toml @@ -0,0 +1,121 @@ +[package] +name = "bss-ledger" +edition.workspace = true +license.workspace = true +description = "BSS Billing Ledger gear: double-entry posting foundation, Postgres persistence in the bss schema" + +[lints] +workspace = true + +[features] +# Exposes the in-memory metrics harness (`infra::metrics::test_harness`) for +# asserting emitted OTel instruments. Gated so `opentelemetry_sdk` never ships +# in the release artifact; auto-enabled for `cargo test` via the +# self-referencing dev-dependency below (RBAC's pattern). +test-support = ["dep:opentelemetry_sdk"] + +[dependencies] +bss-ledger-sdk = { workspace = true } +# Shared BSS coordination primitive: the single-active recognition-run lease +# (`coord_leases` table + `LeaseManager`/`LeaseGuard`); its migration is added to +# this gear's `Migrator` below. +coord = { workspace = true } + +async-trait = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +# `FutureExt::catch_unwind`: flip a recognition-run row FAILED on a panic (not only +# on `Err`), so a panicked pass leaves no stuck `RUNNING` row a retry would replay. +futures = { workspace = true } +sea-orm = { workspace = true, features = ["sqlx-postgres", "sqlx-sqlite"] } +sea-orm-migration = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +# FIPS-validated SHA-256 for the idempotency payload fingerprint (same +# provider the platform installs at bootstrap); no non-FIPS hasher. +aws-lc-rs = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +# OTel metrics seam (`infra::metrics`): literal Prometheus names, no-op until an +# exporter is wired by the host. `metrics` feature only (the pin is +# `default-features = false`). +opentelemetry = { workspace = true, features = ["metrics"] } +# SDK is only needed for the in-memory test harness — gated behind +# `test-support` via `dep:opentelemetry_sdk` so it never ships in production. +opentelemetry_sdk = { workspace = true, features = [ + "metrics", + "testing", +], optional = true } + +# `db` enables the `DatabaseCapability` trait (gated behind `toolkit/db`). +# Set explicitly because this gear's minimal dep set doesn't pull toolkit's +# default features transitively the way larger gears (rbac) do. +# `db` enables the `DatabaseCapability` trait; the REST API layer is not +# feature-gated, so no toolkit feature bump is needed for `rest`. +toolkit = { workspace = true, features = ["db"] } +toolkit-db = { workspace = true, features = ["pg", "sqlite", "preview-outbox"] } +toolkit-db-macros = { workspace = true } +# Canonical OData list surface: the `FilterField` / `FieldKind` traits, the +# `OData` query, `Page` (with-utoipa for the `OpenAPI` response schema), and +# `paginate_odata` (re-exported from `toolkit-db`). Mirrors RBAC's list repos. +toolkit-odata = { workspace = true, features = ["with-utoipa"] } +# TODO(broker): `event-broker-sdk` (transactional-outbox `AsyncProducer` + the +# `TypedEvent` trait) is not yet in gears-rust; the events surface is parked +# (see `infra::events::publisher`). Re-add it here when the broker lands. +# `TypesRegistryClient` + `RegisterResult` to register the event-type GTS +# schemas at `init()` (the crate RBAC uses for the same purpose). Depend on the +# SDK contract crate — not the gear implementation — to preserve gear isolation. +types-registry-sdk = { workspace = true } +# `tokio::time::interval` drives the `serve` tick-loop; `macros`/`rt` for the +# loop's `tokio::select!` / `tokio::spawn`. +tokio = { workspace = true, features = ["time", "macros", "rt"] } +# `CancellationToken` for the `RunnableCapability` `serve` lifecycle (the dep AM uses). +tokio-util = { workspace = true } +# `#[domain_model]` marker on the domain types (enforced by dylint DE0309). +toolkit-macros = { workspace = true } +# `SecurityContext` in the in-process `LedgerClientV1` impl. +toolkit-security = { workspace = true } +# PEP (`PolicyEnforcer`, `ResourceType`, `AccessRequest`) for the authz gate. +authz-resolver-sdk = { workspace = true } +# AM client (`get_tenant` -> tenant_type) for the provisioning seller-type guard +# (only a tenant whose type owns a billing ledger may be provisioned, §4.12). +account-management-sdk = { workspace = true } +# `AuthzPermissionV1` + `gts_instance!` for the permission catalog. +toolkit-gts = { workspace = true } +# `gts_instance!`'s expansion references the `gts` crate directly (same as rbac). +gts = { workspace = true } +# Required as a direct dep by the `#[resource_error(...)]` macro expansion +# (see `src/api/rest/error.rs`). +toolkit-canonical-errors = { workspace = true } + +# REST surface (the gear's first): axum router/extractors + utoipa schemas. +axum = { workspace = true } +utoipa = { workspace = true } +# Holds the per-process REST `ApiState` populated in `init()`. +arc-swap = { workspace = true } + +[dev-dependencies] +# Re-imports the crate under test with `test-support` enabled so the in-file +# metrics-harness tests (and any integration test) can use +# `infra::metrics::test_harness`. Cargo unifies the lib-under-test edge with +# this dev-dep edge into a single `test-support = on` compilation (RBAC idiom). +bss-ledger = { path = ".", features = ["test-support"] } + +toolkit = { workspace = true, features = ["db"] } +toolkit-db = { workspace = true, features = ["pg", "sqlite"] } +# Integration tests build canonical `Page` / `ODataQuery` envelopes for the +# list-endpoint stubs (the lib re-exports these but integration tests need the +# crate as a direct dev-dep). +toolkit-odata = { workspace = true, features = ["with-utoipa"] } +tokio = { workspace = true } +# Postgres testcontainers harness; tests reach `testcontainers` items through +# the `testcontainers_modules::testcontainers` re-export, so the bare +# `testcontainers` crate is not a direct dep. Raw SQL in tests goes through +# sea-orm `Statement`, not `sqlx` directly. +testcontainers-modules = { workspace = true } +# `tower::ServiceExt` provides `.oneshot(...)` for the REST router tests. +tower = { workspace = true } +# TODO(broker): `jsonschema` validated event payloads against their vendored +# JSON-Schemas in the (now parked) event-schema tests; re-add with the broker. diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_credit_note_posted.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_credit_note_posted.v1.schema.json new file mode 100644 index 000000000..0bb113bb2 --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_credit_note_posted.v1.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.credit_note_posted.v1", + "title": "Billing Ledger Credit Note Posted", + "description": "Emitted on every successful credit-note post (never on replay). Internal identifiers + amount + recognized/deferred split parts only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "credit_note_id": { + "type": "string" + }, + "origin_invoice_id": { + "type": "string" + }, + "entry_id": { + "type": "string", + "format": "uuid" + }, + "currency": { + "type": "string" + }, + "amount_minor": { + "type": "integer" + }, + "recognized_part_minor": { + "type": "integer" + }, + "deferred_part_minor": { + "type": "integer" + }, + "posted_at_utc": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "tenant_id", + "credit_note_id", + "origin_invoice_id", + "entry_id", + "currency", + "amount_minor", + "recognized_part_minor", + "deferred_part_minor", + "posted_at_utc" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_debit_note_posted.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_debit_note_posted.v1.schema.json new file mode 100644 index 000000000..f80b8f4ef --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_debit_note_posted.v1.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.debit_note_posted.v1", + "title": "Billing Ledger Debit Note Posted", + "description": "Emitted on every successful debit-note post (an additional charge; never on replay). Internal identifiers + amount + recognized/deferred split parts only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "debit_note_id": { + "type": "string" + }, + "origin_invoice_id": { + "type": "string" + }, + "entry_id": { + "type": "string", + "format": "uuid" + }, + "currency": { + "type": "string" + }, + "amount_minor": { + "type": "integer" + }, + "recognized_part_minor": { + "type": "integer" + }, + "deferred_part_minor": { + "type": "integer" + }, + "posted_at_utc": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "tenant_id", + "debit_note_id", + "origin_invoice_id", + "entry_id", + "currency", + "amount_minor", + "recognized_part_minor", + "deferred_part_minor", + "posted_at_utc" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_dispute_recorded.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_dispute_recorded.v1.schema.json new file mode 100644 index 000000000..0f78c754e --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_dispute_recorded.v1.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.dispute_recorded.v1", + "title": "Billing Ledger Dispute Recorded", + "description": "Emitted on every successful chargeback dispute-phase post (opened/won/lost; never on replay). Internal identifiers + enum codes only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "dispute_id": { + "type": "string" + }, + "payment_id": { + "type": "string" + }, + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "cycle": { + "type": "integer" + }, + "phase": { + "type": "string", + "enum": [ + "OPENED", + "WON", + "LOST", + "PARTIAL" + ] + }, + "variant": { + "type": "string", + "enum": [ + "CASH_HOLD", + "AR_RECLASS" + ] + } + }, + "required": [ + "dispute_id", + "payment_id", + "tenant_id", + "cycle", + "phase", + "variant" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_entry_posted.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_entry_posted.v1.schema.json new file mode 100644 index 000000000..4db56d46b --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_entry_posted.v1.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.entry_posted.v1", + "title": "Billing Ledger Entry Posted", + "description": "Emitted when a balanced ledger entry is freshly posted (never on replay). Internal identifiers only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "entry_id": { + "type": "string", + "format": "uuid" + }, + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "legal_entity_id": { + "type": "string", + "format": "uuid" + }, + "period_id": { + "type": "string" + }, + "source_doc_type": { + "type": "string" + }, + "source_business_id": { + "type": "string" + }, + "posted_at_utc": { + "type": "string", + "format": "date-time" + }, + "created_seq": { + "type": "integer" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/$defs/line_summary" + } + } + }, + "$defs": { + "line_summary": { + "type": "object", + "additionalProperties": false, + "properties": { + "account_class": { + "type": "string" + }, + "side": { + "type": "string" + }, + "amount_minor": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "currency_scale": { + "type": "integer" + } + }, + "required": [ + "account_class", + "side", + "amount_minor", + "currency", + "currency_scale" + ] + } + }, + "required": [ + "entry_id", + "tenant_id", + "legal_entity_id", + "period_id", + "source_doc_type", + "source_business_id", + "posted_at_utc", + "created_seq", + "lines" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_entry_reversed.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_entry_reversed.v1.schema.json new file mode 100644 index 000000000..337640cf6 --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_entry_reversed.v1.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.entry_reversed.v1", + "title": "Billing Ledger Entry Reversed", + "description": "Emitted when a balanced ledger entry is reversed via the explicit reversal path (never on replay, never for a mapping-correction). Internal identifiers plus the audit reason only; no PII, no amounts.", + "type": "object", + "additionalProperties": false, + "properties": { + "entry_id": { + "type": "string", + "format": "uuid" + }, + "reverses_entry_id": { + "type": "string", + "format": "uuid" + }, + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "entry_id", + "reverses_entry_id", + "tenant_id", + "reason" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_fx_revaluation_completed.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_fx_revaluation_completed.v1.schema.json new file mode 100644 index 000000000..068d9f865 --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_fx_revaluation_completed.v1.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.fx_revaluation_completed.v1", + "title": "Billing Ledger FX Revaluation Completed", + "description": "Emitted on every successful unrealized-revaluation post (one functional-only FX_UNREALIZED entry per moved (period, scope, payer); never on replay). Internal identifiers + scope code + signed functional amount + grain count only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "entry_id": { + "type": "string", + "format": "uuid" + }, + "period_id": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "payer_id": { + "type": "string", + "format": "uuid" + }, + "functional_currency": { + "type": "string" + }, + "fx_unrealized_minor": { + "type": "integer" + }, + "grains_moved": { + "type": "integer" + }, + "posted_at_utc": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "tenant_id", + "entry_id", + "period_id", + "scope", + "payer_id", + "functional_currency", + "fx_unrealized_minor", + "grains_moved", + "posted_at_utc" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_fx_revaluation_reversed.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_fx_revaluation_reversed.v1.schema.json new file mode 100644 index 000000000..e789363be --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_fx_revaluation_reversed.v1.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.fx_revaluation_reversed.v1", + "title": "Billing Ledger FX Revaluation Reversed", + "description": "Emitted on every successful revaluation-reversal post (a fresh FX_REVAL_REVERSAL JE negating a prior FX_REVALUATION entry in the next OPEN period; never on replay). Internal identifiers + scope code + signed functional amount only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "entry_id": { + "type": "string", + "format": "uuid" + }, + "reverses_entry_id": { + "type": "string", + "format": "uuid" + }, + "reval_period_id": { + "type": "string" + }, + "reversal_period_id": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "payer_id": { + "type": "string", + "format": "uuid" + }, + "functional_currency": { + "type": "string" + }, + "fx_unrealized_minor": { + "type": "integer" + }, + "posted_at_utc": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "tenant_id", + "entry_id", + "reverses_entry_id", + "reval_period_id", + "reversal_period_id", + "scope", + "payer_id", + "functional_currency", + "fx_unrealized_minor", + "posted_at_utc" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_invariant_alarm.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_invariant_alarm.v1.schema.json new file mode 100644 index 000000000..95c75773a --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_invariant_alarm.v1.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.invariant_alarm.v1", + "title": "Billing Ledger Invariant Alarm", + "description": "Emitted out-of-band when a posting attempt trips a ledger invariant. Internal diagnostics only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "category": { + "type": "string", + "enum": [ + "IDEMPOTENCY_PAYLOAD_CONFLICT", + "NEGATIVE_BALANCE_VIOLATION", + "TIE_OUT_VARIANCE", + "ENTRY_IMBALANCE", + "TAMPER_VERIFY_FAILED", + "CHARGEBACK_CASH_NEGATIVE", + "AGED_ALLOCATION_QUEUE", + "DISPUTE_PHASE_QUEUED", + "AGED_UNALLOCATED", + "RECOGNITION_DOUBLE_CREDIT", + "OVER_RECOGNITION", + "RECOGNITION_PERIOD_QUEUED", + "CREDIT_NOTE_SPLIT_BLOCKED", + "CLAWBACK_UNDERFLOW", + "REFUND_QUARANTINED", + "REFUND_CLEARING_AGED", + "STAGE1_REFUND_ORPHAN", + "STUCK_REFUND_CLEARING", + "ATTEMPTED_WRITE_OFF", + "NEGATIVE_TAX_SUBBALANCE", + "FX_SNAPSHOT_MISSING", + "FX_SNAPSHOT_STALE_ALLOWED", + "FX_SNAPSHOT_STALE_BLOCKED", + "RECONCILIATION_VARIANCE", + "MISSED_POSTING" + ] + }, + "severity": { + "type": "string", + "enum": [ + "WARN", + "CRITICAL" + ] + }, + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "scope": { + "type": "string" + }, + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "affected": { + "type": "array", + "items": { + "$ref": "#/$defs/affected_item" + } + } + }, + "$defs": { + "affected_item": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "expected_minor": { + "type": "integer" + }, + "actual_minor": { + "type": "integer" + } + }, + "required": [ + "id", + "currency", + "expected_minor", + "actual_minor" + ] + } + }, + "required": [ + "category", + "severity", + "tenant_id", + "scope", + "code", + "detail", + "affected" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_manual_adjustment_posted.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_manual_adjustment_posted.v1.schema.json new file mode 100644 index 000000000..b30bdebec --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_manual_adjustment_posted.v1.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.manual_adjustment_posted.v1", + "title": "Billing Ledger Manual Adjustment Posted", + "description": "Emitted on every successful governed manual-adjustment post (never on replay). Internal identifiers + enum codes + amount only; no PII (actor_ref is an internal subject uuid, not a name).", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "adjustment_id": { + "type": "string" + }, + "entry_id": { + "type": "string", + "format": "uuid" + }, + "action": { + "type": "string" + }, + "reason_code": { + "type": "string" + }, + "actor_ref": { + "type": "string" + }, + "amount_minor": { + "type": "integer" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "tenant_id", + "adjustment_id", + "entry_id", + "action", + "reason_code", + "actor_ref", + "amount_minor", + "currency" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_period_closed.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_period_closed.v1.schema.json new file mode 100644 index 000000000..67475104b --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_period_closed.v1.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.period_closed.v1", + "title": "Billing Ledger Period Closed", + "description": "Emitted on every successful fiscal-period close (the two-phase gate passes and the close flips fiscal_period OPEN->CLOSED + period_close->CLOSED in one commit; never on an idempotent re-close). Internal identifiers + period code + actor + timestamp only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "legal_entity_id": { + "type": "string", + "format": "uuid" + }, + "period_id": { + "type": "string" + }, + "closed_by": { + "type": "string", + "format": "uuid" + }, + "closed_at_utc": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "tenant_id", + "legal_entity_id", + "period_id", + "closed_by", + "closed_at_utc" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_reconciliation_completed.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_reconciliation_completed.v1.schema.json new file mode 100644 index 000000000..c922b1c9f --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_reconciliation_completed.v1.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.reconciliation_completed.v1", + "title": "Billing Ledger Reconciliation Completed", + "description": "Emitted on every completed reconciliation check (the ReconciliationFramework finalizes a reconciliation_run: AR_DERIVED, PAYMENTS_PSP, or INVOICE_COMPLETENESS), carrying the check type + variance result. Internal identifiers + check-type code + signed variance + tolerance flag + timestamp only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "run_id": { + "type": "string", + "format": "uuid" + }, + "period_id": { + "type": "string" + }, + "check_type": { + "type": "string" + }, + "variance_minor": { + "type": "integer" + }, + "within_tolerance": { + "type": "boolean" + }, + "at_utc": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "tenant_id", + "run_id", + "period_id", + "check_type", + "variance_minor", + "within_tolerance", + "at_utc" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_refund_recorded.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_refund_recorded.v1.schema.json new file mode 100644 index 000000000..c599f6a2f --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_refund_recorded.v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.refund_recorded.v1", + "title": "Billing Ledger Refund Recorded", + "description": "Emitted on every successful refund-phase post (never on replay). Internal identifiers + enum codes + amount + clearing state only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "refund_id": { + "type": "string" + }, + "psp_refund_id": { + "type": "string" + }, + "entry_id": { + "type": "string", + "format": "uuid" + }, + "phase": { + "type": "string", + "enum": [ + "initiated", + "confirmed", + "rejected", + "voided", + "unknown_final" + ] + }, + "pattern": { + "type": "string", + "enum": [ + "A_UNALLOCATED", + "B_RESTORE_AR" + ] + }, + "payment_id": { + "type": "string" + }, + "amount_minor": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "clearing_state": { + "type": "string", + "enum": [ + "PENDING", + "SETTLED", + "REVERSED" + ] + } + }, + "required": [ + "tenant_id", + "refund_id", + "psp_refund_id", + "entry_id", + "phase", + "pattern", + "payment_id", + "amount_minor", + "currency", + "clearing_state" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_revenue_recognition_reversed.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_revenue_recognition_reversed.v1.schema.json new file mode 100644 index 000000000..7bc3bc4b0 --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_revenue_recognition_reversed.v1.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.revenue_recognition_reversed.v1", + "title": "Billing Ledger Revenue Recognition Reversed", + "description": "Emitted on every recognition reversal post (DR REVENUE / CR CONTRACT_LIABILITY; never on replay). Internal identifiers + amount + stream + period only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "schedule_id": { + "type": "string" + }, + "segment_no": { + "type": "integer" + }, + "period_id": { + "type": "string" + }, + "amount_minor": { + "type": "integer" + }, + "revenue_stream": { + "type": "string" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "tenant_id", + "schedule_id", + "segment_no", + "period_id", + "amount_minor", + "revenue_stream", + "currency" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_revenue_recognized.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_revenue_recognized.v1.schema.json new file mode 100644 index 000000000..fe62c62bb --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_revenue_recognized.v1.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.revenue_recognized.v1", + "title": "Billing Ledger Revenue Recognized", + "description": "Emitted on every recognition release post (DR CONTRACT_LIABILITY / CR REVENUE; never on replay). Internal identifiers + amount + stream + period only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "schedule_id": { + "type": "string" + }, + "segment_no": { + "type": "integer" + }, + "period_id": { + "type": "string" + }, + "amount_minor": { + "type": "integer" + }, + "revenue_stream": { + "type": "string" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "tenant_id", + "schedule_id", + "segment_no", + "period_id", + "amount_minor", + "revenue_stream", + "currency" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_schedule_changed.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_schedule_changed.v1.schema.json new file mode 100644 index 000000000..3550b550c --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_schedule_changed.v1.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.schedule_changed.v1", + "title": "Billing Ledger Schedule Changed", + "description": "Emitted when a recognition schedule is changed or cancelled (superseded by a new version, or cancelled). Internal identifiers + enum codes only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "schedule_id": { + "type": "string" + }, + "new_schedule_id": { + "type": [ + "string", + "null" + ] + }, + "treatment": { + "type": "string", + "enum": [ + "prospective", + "separate_contract" + ] + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "COMPLETED", + "REPLACED", + "CANCELLED" + ] + } + }, + "required": [ + "tenant_id", + "schedule_id", + "new_schedule_id", + "treatment", + "status" + ] +} diff --git a/gears/bss/ledger/ledger/schemas/billing_ledger_settlement_returned.v1.schema.json b/gears/bss/ledger/ledger/schemas/billing_ledger_settlement_returned.v1.schema.json new file mode 100644 index 000000000..36593459c --- /dev/null +++ b/gears/bss/ledger/ledger/schemas/billing_ledger_settlement_returned.v1.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gts://gts.cf.core.events.event.v1~cf.bss.ledger.settlement_returned.v1", + "title": "Billing Ledger Settlement Returned", + "description": "Emitted on every successful settlement-return post (never on replay). Internal identifiers + enum codes + the returned amount only; no PII.", + "type": "object", + "additionalProperties": false, + "properties": { + "payment_id": { + "type": "string" + }, + "psp_return_id": { + "type": "string" + }, + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "amount_minor": { + "type": "integer" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "payment_id", + "psp_return_id", + "tenant_id", + "amount_minor", + "currency" + ] +} diff --git a/gears/bss/ledger/ledger/src/api.rs b/gears/bss/ledger/ledger/src/api.rs new file mode 100644 index 000000000..a4fa29c09 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api.rs @@ -0,0 +1,7 @@ +//! In-process data-access API layer: the gear's local implementation of the +//! `bss-ledger-sdk` `ClientHub` contracts. + +pub mod local_client; +pub mod rest; + +pub use local_client::LedgerLocalClient; diff --git a/gears/bss/ledger/ledger/src/api/local_client.rs b/gears/bss/ledger/ledger/src/api/local_client.rs new file mode 100644 index 000000000..a135973f3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/local_client.rs @@ -0,0 +1,1294 @@ +//! `LedgerLocalClient` — the in-process implementation of +//! [`LedgerClientV1`] published in `ClientHub`. It maps the SDK +//! request DTOs (`PostEntry`/`PostLine`) onto the gear-internal +//! `NewEntry`/`NewLine`, resolving each line's `currency_scale` through the +//! [`CurrencyScaleResolver`], then delegates to [`PostingService`]. + +use std::str::FromStr; + +use bss_ledger_sdk::api::LedgerClientV1; +use bss_ledger_sdk::{ + AccountClass, AccountInfo, AllocateOutcome, AllocatePayment, AllocationApplied, + AllocationQueued, AllocationSplit, AllocationView, ArInvoiceBalanceView, BalanceView, + ChangeRecognitionSchedule, CloseOutcome, CreditApplication, CreditApplicationApplied, + CreditDebitView, DisputeOutcome, DisputeQueued, DisputeRecorded, EntryView, LineView, + MappingStatus, ODataQuery, Page, PostEntry, PostingRef, ProvisionOutcome, ProvisionRequest, + RecognitionRunOutcome, RecognitionScheduleList, RecognitionScheduleSegmentView, + RecognitionScheduleSummaryView, RecognitionScheduleView, RecordDisputePhase, ReturnPayment, + RevenueDisaggregation, RevenueDisaggregationEntry, RevenueDisaggregationQuery, + ScheduleChangeRef, SettlePayment, Side, SourceDocType, TriggerRecognitionRun, UnallocatedView, +}; +use chrono::Utc; +use sea_orm::{ColumnTrait, Condition, EntityTrait}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit_db::secure::SecureEntityExt; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::error::authz_error_to_canonical; +use crate::domain::model::{EntryRecord, LineRecord, NewEntry, NewLine}; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::period_close::PeriodCloseService; +use crate::infra::posting::service::PostingService; +use crate::infra::provisioning::service::ProvisioningService; +use crate::infra::storage::entity::{ + account_balance, ar_invoice_balance, journal_line, payment_allocation, recognition_schedule, + recognition_segment, +}; +use crate::infra::storage::repo::JournalRepo; +use crate::infra::storage::repo::journal_repo::OdataPageError; + +/// Origin literal stamped on posts made through the in-process client until a +/// caller-supplied origin is threaded from the security context. +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// In-process `LedgerClientV1` over the posting engine. +pub struct LedgerLocalClient { + posting: PostingService, + db: DBProvider, + resolver: CurrencyScaleResolver, + provisioning: ProvisioningService, + period_close: PeriodCloseService, + // Payment money-in (settle a receipt into the unallocated pool) and money-out + // (allocate the pool to open AR oldest-first). Each holds its own posting + // engine + publisher + metrics clones. + settle_service: crate::infra::payment::settle::SettlementService, + // Settlement return (money-in reversal): claws a settled receipt back out of + // the pool, decrementing `settled_minor`. Same deps as `settle_service`. + settlement_return_service: crate::infra::payment::settlement_return::SettlementReturnService, + // Chargeback dispute (open / win / lose): records a dispute phase, seeding / + // advancing the `ledger_dispute` state. Same deps as `settle_service`. + chargeback_service: crate::infra::payment::chargeback::ChargebackService, + allocate_service: crate::infra::payment::allocate::AllocationService, + // Reusable-credit wallet (architecture §5.2): grant parks pool cash into the + // wallet, apply spends it against open AR. Same deps as `allocate_service`. + credit_service: crate::infra::payment::credit::CreditApplicationService, + // ASC 606 recognition run (architecture §5, the S6 release): orchestrates one + // `RecognitionRunner` pass with the `recognition_run` row lifecycle. Same + // db.clone() + publisher clone as the payment services. + recognition_run_service: crate::infra::recognition::run_service::RecognitionRunService, + // ASC 606 schedule change / cancel (Group H, design §3.6): marks an ACTIVE + // schedule terminal (CANCELLED/REPLACED) and mints a prospective successor on + // a replace, in one serializable txn, emitting `schedule.changed` in-txn. Same + // db.clone() + publisher clone (a change posts no journal entry). + recognition_change_service: crate::infra::recognition::change_service::RecognitionChangeService, + // Held so the live producers stay alive for the client's lifetime; the + // posting engine clones its own `Arc` into `PostingService`. + #[allow(dead_code)] + publisher: std::sync::Arc, + // Platform PEP: derives the per-call `AccessScope` (reads) and gates + // writes/admin actions before the repository is touched. + enforcer: std::sync::Arc, + // Provisioning seller-type gate: only a tenant whose type owns a billing + // ledger may be provisioned (§4.12); reads AM + the Types Registry. + seller_guard: std::sync::Arc, + // OTel metrics handle. Stored now (the invoice-post domain emits + // `invoice_post` / `invoice_post_duration` from the post path); held so the + // process-global instruments are reachable from the client. + #[allow(dead_code)] + metrics: std::sync::Arc, +} + +impl LedgerLocalClient { + /// Build the client over one database provider, the event publisher + /// (built once at `init()`; a no-op publisher when the broker is absent), + /// and the platform PEP (used by the P7 gates to scope reads and gate + /// writes/admin actions). + #[must_use] + pub(crate) fn new( + db: DBProvider, + publisher: std::sync::Arc, + enforcer: std::sync::Arc, + seller_guard: std::sync::Arc, + metrics: std::sync::Arc, + // Slice 5: FX tunables (provider order + staleness) for the S2 settle lock. + fx_config: crate::config::FxConfig, + // Slice 3: payments tunables (the per-allocation touched-invoice cap). + payments_config: crate::config::PaymentsConfig, + // Slice 7 Phase 3: the pre-close control-feed gate inputs (manifest completeness + + // bill-run-finished, flag-gated). `CloseControlFeeds::inert()` disables both gates. + close_control: crate::infra::period_close::CloseControlFeeds, + ) -> Self { + let posting = PostingService::new(db.clone(), std::sync::Arc::clone(&publisher)); + let resolver = + CurrencyScaleResolver::new(crate::infra::storage::repo::ReferenceRepo::new(db.clone())); + let provisioning = ProvisioningService::new(db.clone()); + let period_close = PeriodCloseService::new( + db.clone(), + std::sync::Arc::clone(&publisher), + std::sync::Arc::new( + crate::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new(), + ), + ) + // Slice 7 Phase 3: the gated close consults the manifest / bill-run feeds (flag-gated). + .with_control_feeds(close_control); + let settle_service = crate::infra::payment::settle::SettlementService::new( + db.clone(), + std::sync::Arc::clone(&publisher), + std::sync::Arc::clone(&metrics), + ) + // Slice 5 S2: the settle FX lock (inert for a single-currency tenant — + // RateLocker short-circuits when the tenant has no functional currency). + .with_fx(crate::infra::fx::rate_locker::RateLocker::new( + crate::infra::fx::rate_source::RateSource::new( + crate::infra::storage::repo::FxRepo::new(db.clone()), + fx_config, + ) + .with_metrics(std::sync::Arc::clone(&metrics)), + crate::infra::storage::repo::FxRepo::new(db.clone()), + )); + // Slice 7 Phase 2: the exception router (additive close-blocking routing) is + // shared across the in-process client's stub-bearing services (chargeback + + // settlement-return). Built over the same db (stateless handle). + let exception_router = crate::infra::exception::ExceptionRouter::shared(db.clone()); + let settlement_return_service = + crate::infra::payment::settlement_return::SettlementReturnService::new( + db.clone(), + std::sync::Arc::clone(&publisher), + std::sync::Arc::clone(&metrics), + ) + .with_exceptions(std::sync::Arc::clone(&exception_router)); + let chargeback_service = crate::infra::payment::chargeback::ChargebackService::new( + db.clone(), + std::sync::Arc::clone(&publisher), + std::sync::Arc::clone(&metrics), + ) + .with_exceptions(std::sync::Arc::clone(&exception_router)); + let allocate_service = crate::infra::payment::allocate::AllocationService::new( + db.clone(), + std::sync::Arc::clone(&publisher), + std::sync::Arc::clone(&metrics), + ) + .with_max_invoices_per_allocation(payments_config.max_invoices_per_allocation); + let credit_service = crate::infra::payment::credit::CreditApplicationService::new( + db.clone(), + std::sync::Arc::clone(&publisher), + std::sync::Arc::clone(&metrics), + ); + // Recognition run-service: same db.clone() + publisher clone as the + // payment services (the runner's posting engine clones the publisher + // Arc), plus the metrics handle for the §9 recognition metrics (run + // duration, recognized-minor, over-recognition / double-credit, queue + // depth). + let recognition_run_service = + crate::infra::recognition::run_service::RecognitionRunService::new( + db.clone(), + std::sync::Arc::clone(&publisher), + std::sync::Arc::clone(&metrics), + ); + // Schedule change/cancel (Group H): a change posts no journal entry, so the + // service needs only the db + publisher (the in-txn `schedule.changed` + // emit) — no metrics handle. + let recognition_change_service = + crate::infra::recognition::change_service::RecognitionChangeService::new( + db.clone(), + std::sync::Arc::clone(&publisher), + ); + Self { + posting, + db, + resolver, + provisioning, + period_close, + settle_service, + settlement_return_service, + chargeback_service, + allocate_service, + credit_service, + recognition_run_service, + recognition_change_service, + publisher, + enforcer, + seller_guard, + metrics, + } + } + + /// Derive the caller's compiled `In` read scope on the `entry` data plane. + /// `require_constraints = true` so an unconstrained allow fail-closes + /// rather than leaking every tenant; the returned scope is the SQL filter + /// `SecureORM` binds to `tenant_id` (SQL-level BOLA). Shared by every ledger + /// read method. + async fn read_entry_scope( + &self, + ctx: &SecurityContext, + ) -> Result { + crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + None, + None, + true, + ) + .await + .map_err(authz_error_to_canonical) + } + + /// Derive the caller's compiled `In` read scope on the `recognition` resource + /// (runs / schedules / disaggregation). Mirrors [`Self::read_entry_scope`] but on + /// the recognition revenue plane, so a revenue-accountant grant reads recognition + /// without the `entry` data plane. + async fn read_recognition_scope( + &self, + ctx: &SecurityContext, + ) -> Result { + crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::READ, + None, + None, + true, + ) + .await + .map_err(authz_error_to_canonical) + } +} + +#[async_trait::async_trait] +impl LedgerClientV1 for LedgerLocalClient { + async fn post_balanced_entry( + &self, + ctx: &SecurityContext, + entry: PostEntry, + ) -> Result { + // Write gate + scope: authorize (entry, post) against the entry's + // target tenant; the returned scope threads into the scoped post. + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::POST, + Some(entry.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + let posted_at_utc = Utc::now(); + + let new_entry = NewEntry { + entry_id: entry.entry_id, + tenant_id: entry.tenant_id, + // v1: one legal entity per tenant — derived server-side, never + // taken from the caller (column retained for future multi-LE). + legal_entity_id: entry.tenant_id, + period_id: entry.period_id, + entry_currency: entry.entry_currency, + source_doc_type: entry.source_doc_type, + source_business_id: entry.source_business_id, + reverses_entry_id: entry.reverses_entry_id, + reverses_period_id: entry.reverses_period_id, + posted_at_utc, + effective_at: entry.effective_at, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: entry.posted_by_actor_id, + correlation_id: entry.correlation_id, + rounding_evidence: serde_json::Value::Null, + // Slice 5: this direct-post path is same-currency in v1 (no FX lock). + rate_snapshot_ref: None, + }; + + let mut new_lines: Vec = Vec::with_capacity(entry.lines.len()); + for line in entry.lines { + let scale = self + .resolver + .resolve(&scope, entry.tenant_id, &line.currency) + .await + .map_err(|e| { + CanonicalError::internal(format!("currency scale resolve: {e}")).create() + })?; + new_lines.push(NewLine { + line_id: line.line_id, + payer_tenant_id: line.payer_tenant_id, + seller_tenant_id: line.seller_tenant_id, + resource_tenant_id: line.resource_tenant_id, + account_id: line.account_id, + account_class: line.account_class, + gl_code: line.gl_code, + side: line.side, + amount_minor: line.amount_minor, + currency: line.currency, + currency_scale: scale, + invoice_id: line.invoice_id, + due_date: line.due_date, + revenue_stream: line.revenue_stream, + mapping_status: line.mapping_status, + functional_amount_minor: line.functional_amount_minor, + functional_currency: line.functional_currency, + tax_jurisdiction: line.tax_jurisdiction, + tax_filing_period: line.tax_filing_period, + tax_rate_ref: line.tax_rate_ref, + legal_entity_id: None, + invoice_item_ref: line.invoice_item_ref, + sku_or_plan_ref: line.sku_or_plan_ref, + price_id: line.price_id, + pricing_snapshot_ref: line.pricing_snapshot_ref, + po_allocation_group: line.po_allocation_group, + credit_grant_event_type: line.credit_grant_event_type, + ar_status: line.ar_status, + }); + } + + self.posting + .post(ctx, &scope, new_entry, new_lines, None) + .await + .map_err(CanonicalError::from) + } + + async fn read_account_balance( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + account_id: Uuid, + ) -> Result, CanonicalError> { + // Read scope: the PDP returns the caller's compiled `In` scope, which + // SecureORM binds to `tenant_id` (SQL-level BOLA). `require_constraints` + // is true so an unconstrained allow fail-closes rather than leaking. + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + None, + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + let conn = self + .db + .conn() + .map_err(|e| CanonicalError::internal(format!("conn: {e}")).create())?; + let row = account_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter( + // Single-currency account (v1: one account per currency), so the + // (tenant, account) grain identifies exactly one balance row. + Condition::all() + .add(account_balance::Column::TenantId.eq(tenant_id)) + .add(account_balance::Column::AccountId.eq(account_id)), + ) + .one(&conn) + .await + .map_err(|e| CanonicalError::internal(format!("read account_balance: {e}")).create())?; + Ok(row.map(|r| r.balance_minor)) + } + + async fn list_accounts( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, CanonicalError> { + // Read scope: list the chart of accounts on the `ledger` config plane. + // The PDP returns the caller's compiled `In` scope, which SecureORM binds + // to `tenant_id` (SQL-level BOLA). A tenant outside the caller's scope + // yields no rows — no existence leak, no 403. The user `$filter` ANDs + // this scope (never replaces it). + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::LEDGER, + crate::authz::actions::READ, + None, + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + let repo = crate::infra::storage::repo::ReferenceRepo::new(self.db.clone()); + let page = repo + .list_accounts(&scope, tenant_id, query) + .await + .map_err(map_odata_page_err)?; + let items = page + .items + .into_iter() + .map(|r| { + Ok(AccountInfo { + account_id: r.account_id, + account_class: AccountClass::from_str(&r.account_class).map_err(|e| { + CanonicalError::internal(format!("bad account_class: {e}")).create() + })?, + currency: r.currency, + revenue_stream: r.revenue_stream, + lifecycle_state: r.lifecycle_state, + }) + }) + .collect::, CanonicalError>>()?; + Ok(Page { + items, + page_info: page.page_info, + }) + } + + async fn get_entry( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + entry_id: Uuid, + ) -> Result, CanonicalError> { + // Read scope on the `entry` data plane: the PDP returns the caller's + // compiled `In` scope, which SecureORM binds to `tenant_id` (SQL-level + // BOLA). A foreign-owned entry resolves to `None` — no existence leak. + let scope = self.read_entry_scope(ctx).await?; + let record = JournalRepo::new(self.db.clone()) + .find_entry_with_lines(&scope, tenant_id, entry_id) + .await + .map_err(|e| CanonicalError::internal(format!("get entry: {e}")).create())?; + record.map(entry_record_to_view).transpose() + } + + async fn list_lines( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, CanonicalError> { + let scope = self.read_entry_scope(ctx).await?; + // The canonical `$filter` (payer / account_class / period / invoice_id) + // + cursor + limit are lowered by `paginate_odata` in the repo, additive + // over the SecureORM scope (SQL-level BOLA). The legacy + // `source_business_id` entry-header resolution is gone — a caller filters + // the line's own `invoice_id` directly. + let page = JournalRepo::new(self.db.clone()) + .list_lines(&scope, tenant_id, query) + .await + .map_err(map_odata_page_err)?; + let items = page + .items + .into_iter() + .map(line_model_to_view) + .collect::, _>>()?; + Ok(Page { + items, + page_info: page.page_info, + }) + } + + async fn list_balances( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, CanonicalError> { + let scope = self.read_entry_scope(ctx).await?; + let page = JournalRepo::new(self.db.clone()) + .list_balances(&scope, tenant_id, query) + .await + .map_err(map_odata_page_err)?; + let items = page + .items + .into_iter() + .map(balance_model_to_view) + .collect::, _>>()?; + Ok(Page { + items, + page_info: page.page_info, + }) + } + + async fn list_ar_invoice_balances( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + payer_tenant_id: Option, + ) -> Result, CanonicalError> { + let scope = self.read_entry_scope(ctx).await?; + let rows = JournalRepo::new(self.db.clone()) + .list_ar_invoice_balances(&scope, tenant_id, payer_tenant_id) + .await + .map_err(|e| { + CanonicalError::internal(format!("list ar invoice balances: {e}")).create() + })?; + Ok(rows.into_iter().map(ar_invoice_model_to_view).collect()) + } + + async fn provision( + &self, + ctx: &SecurityContext, + req: ProvisionRequest, + ) -> Result { + // In-process defence-in-depth: the REST layer already 403s on a denied + // (ledger, provision); gate again here against the request's tenant. + crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::LEDGER, + crate::authz::actions::PROVISION, + Some(req.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + // §4.12 predicate: only a tenant whose TYPE owns a billing ledger may be + // provisioned (a buyer/leaf type is rejected with FailedPrecondition). + self.seller_guard + .assert_owns_ledger(ctx, req.tenant_id) + .await?; + self.provisioning + .provision(req) + .await + .map_err(CanonicalError::from) + } + + async fn close_period( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + period_id: String, + ) -> Result { + // Authorize (fiscal_period, close) against the tenant before the + // OPEN→CLOSED transition. + crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::FISCAL_PERIOD, + crate::authz::actions::CLOSE, + Some(tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + // v1: one legal entity per tenant — the close key's LE is the tenant. The + // `ctx` threads the initiating Finance actor onto the `period_close` row + + // the `period.closed` event. + self.period_close + .close(ctx, tenant_id, tenant_id, period_id) + .await + .map_err(CanonicalError::from) + } + + async fn settle_payment( + &self, + ctx: &SecurityContext, + req: SettlePayment, + ) -> Result { + // Write gate + scope: authorize (payment, write) against the settlement's + // target tenant; the returned scope threads into the scoped post. + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::PAYMENT, + crate::authz::actions::WRITE, + Some(req.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + // `scale` is NOT threaded onto the domain input — the per-line currency + // scale resolver (over the provisioned currency config) is authoritative; + // the caller's `req.scale` is advisory only. + let input = crate::domain::payment::settlement::SettlementInput { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id, + gross_minor: req.gross_minor, + fee_minor: req.fee_minor, + currency: req.currency, + effective_at: req.effective_at, + }; + self.settle_service + .settle(ctx, &scope, input) + .await + .map_err(CanonicalError::from) + } + + async fn return_payment( + &self, + ctx: &SecurityContext, + req: ReturnPayment, + ) -> Result { + // Write gate + scope: authorize (payment, write) against the return's + // target tenant; the returned scope threads into the scoped post. + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::PAYMENT, + crate::authz::actions::WRITE, + Some(req.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + // `scale` is advisory (see `settle_payment`) — the per-line currency-scale + // resolver is authoritative; the caller's `req.scale` is not threaded. + let input = crate::domain::payment::settlement_return::SettlementReturnInput { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id, + psp_return_id: req.psp_return_id, + amount_minor: req.amount_minor, + currency: req.currency, + effective_at: req.effective_at, + }; + self.settlement_return_service + .return_settlement(ctx, &scope, input) + .await + .map_err(CanonicalError::from) + } + + async fn record_dispute_phase( + &self, + ctx: &SecurityContext, + req: RecordDisputePhase, + ) -> Result { + // Write gate + scope: authorize (dispute, write) against the dispute's + // target tenant; the returned scope threads into the scoped post. + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::DISPUTE, + crate::authz::actions::WRITE, + Some(req.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + // Parse the wire phase / funds-fact literals at the boundary (a bad + // literal is `InvalidArgument` ⇒ 400, not a deep post-path fault). + // `scale` is advisory (see `settle_payment`) — not threaded. + let phase = crate::domain::payment::chargeback::DisputePhase::parse(&req.phase) + .ok_or_else(|| { + CanonicalError::from(crate::domain::error::DomainError::InvalidRequest(format!( + "unknown dispute phase {:?} (expected opened/won/lost/partial)", + req.phase + ))) + })?; + let funds_at_open = crate::domain::payment::chargeback::FundsAtOpen::parse( + &req.funds_at_open, + ) + .ok_or_else(|| { + CanonicalError::from(crate::domain::error::DomainError::InvalidRequest(format!( + "unknown funds_at_open {:?} (expected withheld/not_moved)", + req.funds_at_open + ))) + })?; + let request = crate::infra::payment::chargeback::ChargebackRequest { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id, + dispute_id: req.dispute_id, + invoice_id: req.invoice_id, + cycle: req.cycle, + phase, + funds_at_open, + disputed_amount_minor: req.disputed_amount_minor, + currency: req.currency, + effective_at: req.effective_at, + }; + // The service returns either an inline post (`Recorded`) or a durable + // enqueue (`Queued`, §4.7 — an out-of-order `won`/`lost`). Map each onto + // the SDK outcome; the REST handler renders 201/200-vs-202 from the arm. + match self + .chargeback_service + .record_phase(ctx, &scope, request) + .await + .map_err(CanonicalError::from)? + { + crate::infra::payment::chargeback::ChargebackOutcome::Recorded(posting) => { + Ok(DisputeOutcome::Recorded(DisputeRecorded { posting })) + } + crate::infra::payment::chargeback::ChargebackOutcome::Queued(queued) => { + Ok(DisputeOutcome::Queued(DisputeQueued { + flow: queued.flow, + business_id: queued.business_id, + queued_at: queued.queued_at, + })) + } + } + } + + async fn allocate_payment( + &self, + ctx: &SecurityContext, + req: AllocatePayment, + ) -> Result { + // Write gate + scope: authorize (payment, write) against the allocation's + // target tenant. + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::PAYMENT, + crate::authz::actions::WRITE, + Some(req.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + // `scale` is advisory (see `settle_payment`) — not threaded onto the + // request; the resolver is authoritative. + let currency = req.currency.clone(); + // Mode B (§4.4 F-5): a caller-supplied split bypasses the precedence + // decision and is validated against the open candidates by the service. + let caller_splits = req.splits.map(|splits| { + splits + .into_iter() + .map(|s| crate::domain::payment::precedence::Allocated { + invoice_id: s.invoice_id, + amount_minor: s.amount_minor, + }) + .collect() + }); + let request = crate::infra::payment::allocate::AllocateRequest { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id, + allocation_id: req.allocation_id, + lump_minor: req.lump_minor, + currency: req.currency, + hint_invoice_id: req.hint_invoice_id, + caller_splits, + }; + // The service returns either an inline post (`Applied`, the payment was + // settled) or a durable enqueue (`Queued`, §4.7 — the payment was not yet + // settled). Map each onto the SDK outcome; the REST handler renders + // 201-vs-202 from the arm. + match self + .allocate_service + .allocate(ctx, &scope, request) + .await + .map_err(CanonicalError::from)? + { + crate::infra::payment::allocate::AllocationOutcome::Applied(applied) => { + // Positive-amount splits only; the per-invoice currency is the + // request currency and `allocated_at_utc` is the apply instant + // (the sidecar stamps the same `now` on the persisted rows). + // `policy_ref` is the ref the service stamped — a precedence policy + // id for the decided path, or `caller-split.v1` for a Mode B split. + let policy_ref = applied.policy_ref; + let allocations = applied + .splits + .into_iter() + .map(|s| AllocationView { + invoice_id: s.invoice_id, + amount_minor: s.amount_minor, + currency: currency.clone(), + allocated_at_utc: Utc::now(), + precedence_policy_ref: policy_ref.clone(), + }) + .collect(); + Ok(AllocateOutcome::Applied(AllocationApplied { + posting: applied.posting, + allocations, + })) + } + crate::infra::payment::allocate::AllocationOutcome::Queued(queued) => { + Ok(AllocateOutcome::Queued(AllocationQueued { + flow: queued.flow, + business_id: queued.business_id, + queued_at: queued.queued_at, + })) + } + } + } + + async fn list_payment_allocations( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + payment_id: String, + ) -> Result, CanonicalError> { + // Read scope on the `payment` data plane: the PDP returns the caller's + // compiled `In` scope, which SecureORM binds to `tenant_id` (SQL-level + // BOLA). A foreign-owned payment yields no rows — no existence leak. + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::PAYMENT, + crate::authz::actions::READ, + None, + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + let rows = crate::infra::storage::repo::PaymentRepo::new(self.db.clone()) + .list_payment_allocations(&scope, tenant_id, &payment_id) + .await + .map_err(|e| { + CanonicalError::internal(format!("list payment allocations: {e}")).create() + })?; + Ok(rows.into_iter().map(payment_allocation_to_view).collect()) + } + + async fn read_unallocated( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + payer_tenant_id: Uuid, + currency: String, + ) -> Result { + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::PAYMENT, + crate::authz::actions::READ, + None, + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + let balance_minor = crate::infra::storage::repo::PaymentRepo::new(self.db.clone()) + .read_unallocated(&scope, tenant_id, payer_tenant_id, ¤cy) + .await + .map_err(|e| CanonicalError::internal(format!("read unallocated: {e}")).create())?; + Ok(UnallocatedView { + payer_tenant_id, + currency, + balance_minor, + }) + } + + async fn post_credit_application( + &self, + ctx: &SecurityContext, + req: CreditApplication, + ) -> Result { + // Write gate + scope: authorize (credit_application, write) against the + // operation's target tenant (read from whichever arm the enum carries). + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::CREDIT_APPLICATION, + crate::authz::actions::WRITE, + Some(req.tenant_id()), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + // `scale` is advisory (see `allocate_payment`) — not threaded onto the + // request; the per-line currency-scale resolver is authoritative. + let outcome = match req { + CreditApplication::Grant(g) => { + self.credit_service + .grant_credit( + ctx, + &scope, + crate::infra::payment::credit::GrantRequest { + tenant_id: g.tenant_id, + payer_tenant_id: g.payer_tenant_id, + credit_application_id: g.credit_application_id, + currency: g.currency, + amount_minor: g.amount_minor, + credit_grant_event_type: g.credit_grant_event_type, + }, + ) + .await + } + CreditApplication::Apply(a) => { + self.credit_service + .apply_credit( + ctx, + &scope, + crate::infra::payment::credit::ApplyRequest { + tenant_id: a.tenant_id, + payer_tenant_id: a.payer_tenant_id, + credit_application_id: a.credit_application_id, + currency: a.currency, + // The per-invoice receivable shares (CR AR side), in + // the caller's order, validated by the service. + targets: a + .targets + .into_iter() + .map(|s| crate::domain::payment::precedence::Allocated { + invoice_id: s.invoice_id, + amount_minor: s.amount_minor, + }) + .collect(), + }, + ) + .await + } + } + .map_err(CanonicalError::from)?; + // Map the domain outcome to the SDK shape: `debits` → per-sub-grain wallet + // draw-downs, `targets` → per-invoice shares (reusing `AllocationSplit`). + // A grant leaves both empty. + Ok(CreditApplicationApplied { + posting: outcome.posting, + debits: outcome + .debits + .into_iter() + .map(|d| CreditDebitView { + credit_grant_event_type: d.credit_grant_event_type, + amount_minor: d.amount_minor, + }) + .collect(), + applications: outcome + .targets + .into_iter() + .map(|t| AllocationSplit { + invoice_id: t.invoice_id, + amount_minor: t.amount_minor, + }) + .collect(), + }) + } + + async fn trigger_recognition_run( + &self, + ctx: &SecurityContext, + req: TriggerRecognitionRun, + ) -> Result { + // Write gate + scope: a recognition run is a revenue-recognition mutation + // (posts `DR CL / CR Revenue`), so it authorizes `(recognition, write)` + // against the run's target tenant — recognition's OWN resource, so a + // revenue-accountant grant triggers runs without the `entry` post right; the + // returned scope threads into the runner's scoped posts + `recognition_run` writes. + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::WRITE, + Some(req.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + self.recognition_run_service + .trigger(ctx, &scope, req.tenant_id, &req.period_id, req.run_id) + .await + .map_err(CanonicalError::from) + } + + async fn list_revenue_disaggregation( + &self, + ctx: &SecurityContext, + query: RevenueDisaggregationQuery, + ) -> Result { + // Read scope on the `recognition` resource (revenue plane): the PDP returns + // the caller's compiled `In` scope, which SecureORM binds to `tenant_id` + // (SQL-level BOLA). A tenant outside the caller's scope yields no rows — no + // existence leak. A revenue-accountant grant reads this without `entry.read`. + let scope = self.read_recognition_scope(ctx).await?; + let rows = crate::infra::storage::repo::RecognitionRepo::new(self.db.clone()) + .list_revenue_disaggregation(&scope, query.tenant_id, query.period_id.as_deref()) + .await + .map_err(|e| { + CanonicalError::internal(format!("list revenue disaggregation: {e}")).create() + })?; + Ok(RevenueDisaggregation { + entries: rows + .into_iter() + .map(|r| RevenueDisaggregationEntry { + period_id: r.period_id, + revenue_stream: r.revenue_stream, + recognized_minor: r.recognized_minor, + currency: r.currency, + }) + .collect(), + }) + } + + async fn change_recognition_schedule( + &self, + ctx: &SecurityContext, + cmd: ChangeRecognitionSchedule, + ) -> Result { + // Write gate + scope: a schedule change marks/mints recognition-schedule + // state, so it authorizes `(recognition, write)` against the change's target + // tenant (mirrors `trigger_recognition_run`); the returned scope threads into + // the change service's scoped reads/writes. Defence-in-depth: the REST layer + // already gated, and this gates again before the repository is touched. + let scope = crate::authz::access_scope( + &self.enforcer, + ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::WRITE, + Some(cmd.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + self.recognition_change_service + .change(ctx, &scope, cmd) + .await + .map_err(CanonicalError::from) + } + + async fn get_recognition_schedule( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + schedule_id: String, + ) -> Result, CanonicalError> { + // Read scope on the `recognition` resource (revenue plane), like the + // disaggregation read: the PDP returns the caller's compiled `In` scope, which + // SecureORM binds to `tenant_id` (SQL-level BOLA). A schedule outside the + // caller's subtree resolves to `None` — no existence leak. + let scope = self.read_recognition_scope(ctx).await?; + let repo = crate::infra::storage::repo::RecognitionRepo::new(self.db.clone()); + let Some(schedule) = repo + .read_schedule(&scope, tenant_id, &schedule_id) + .await + .map_err(|e| { + CanonicalError::internal(format!("read recognition schedule: {e}")).create() + })? + else { + // Absent OR scoped-out — the handler renders a 404 either way. + return Ok(None); + }; + let segments = repo + .list_segments(&scope, tenant_id, &schedule_id) + .await + .map_err(|e| { + CanonicalError::internal(format!("list recognition segments: {e}")).create() + })?; + Ok(Some(recognition_schedule_to_view(schedule, segments))) + } + + async fn list_recognition_schedules( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + invoice_id: Option, + revenue_stream: Option, + ) -> Result { + // Same `recognition` read scope as the by-id read: SecureORM binds it to + // `tenant_id`, so schedules outside the caller's subtree are excluded + // (SQL-level BOLA). + let scope = self.read_recognition_scope(ctx).await?; + let repo = crate::infra::storage::repo::RecognitionRepo::new(self.db.clone()); + let (rows, truncated) = repo + .list_schedules( + &scope, + tenant_id, + invoice_id.as_deref(), + revenue_stream.as_deref(), + ) + .await + .map_err(|e| { + CanonicalError::internal(format!("list recognition schedules: {e}")).create() + })?; + Ok(RecognitionScheduleList { + schedules: rows + .into_iter() + .map(recognition_schedule_to_summary) + .collect(), + truncated, + }) + } +} + +/// Map a `recognition_schedule` row + its ordered `recognition_segment` rows into +/// the SDK [`RecognitionScheduleView`] lifecycle view. The segments arrive ordered +/// by `segment_no` from [`RecognitionRepo::list_segments`]. +fn recognition_schedule_to_view( + schedule: recognition_schedule::Model, + segments: Vec, +) -> RecognitionScheduleView { + RecognitionScheduleView { + schedule_id: schedule.schedule_id, + status: schedule.status, + version: schedule.version, + revenue_stream: schedule.revenue_stream, + currency: schedule.currency, + total_deferred_minor: schedule.total_deferred_minor, + recognized_minor: schedule.recognized_minor, + source_invoice_id: schedule.source_invoice_id, + source_invoice_item_ref: schedule.source_invoice_item_ref, + po_allocation_group: schedule.po_allocation_group, + subscription_ref: schedule.subscription_ref, + policy_ref: schedule.policy_ref, + segments: segments + .into_iter() + .map(|seg| RecognitionScheduleSegmentView { + segment_no: seg.segment_no, + period_id: seg.period_id, + amount_minor: seg.amount_minor, + status: seg.status, + }) + .collect(), + } +} + +/// Map a `recognition_schedule` row into the SDK [`RecognitionScheduleSummaryView`] +/// header (no segments) — the row shape of the list/discovery surface. +fn recognition_schedule_to_summary( + schedule: recognition_schedule::Model, +) -> RecognitionScheduleSummaryView { + RecognitionScheduleSummaryView { + schedule_id: schedule.schedule_id, + status: schedule.status, + version: schedule.version, + revenue_stream: schedule.revenue_stream, + currency: schedule.currency, + total_deferred_minor: schedule.total_deferred_minor, + recognized_minor: schedule.recognized_minor, + source_invoice_id: schedule.source_invoice_id, + source_invoice_item_ref: schedule.source_invoice_item_ref, + po_allocation_group: schedule.po_allocation_group, + subscription_ref: schedule.subscription_ref, + policy_ref: schedule.policy_ref, + } +} + +/// Stored `currency_scale` (`i16` at the DB boundary) → the SDK's `u8`. The +/// scale is always a small non-negative number (≤ the ISO headroom); a +/// negative or out-of-range stored value (impossible by construction) clamps +/// to `0` rather than panicking. +fn scale_to_u8(scale: i16) -> u8 { + u8::try_from(scale.max(0)).unwrap_or(0) +} + +/// Parse a stored enum literal into its SDK enum, mapping an unknown literal to +/// a canonical `Internal` (a stored value outside the enum is data corruption). +fn parse_enum(value: &str, parse: impl Fn(&str) -> Result) -> Result +where + E: std::fmt::Display, +{ + parse(value).map_err(|e| { + CanonicalError::internal(format!("bad stored enum literal {value:?}: {e}")).create() + }) +} + +/// Map a read-back [`LineRecord`] (from `get_entry`) into an SDK [`LineView`]. +fn line_record_to_view(r: LineRecord) -> Result { + Ok(LineView { + line_id: r.line_id, + entry_id: r.entry_id, + payer_tenant_id: r.payer_tenant_id, + account_id: r.account_id, + account_class: parse_enum(&r.account_class, AccountClass::from_str)?, + gl_code: r.gl_code, + side: parse_enum(&r.side, Side::from_str)?, + amount_minor: r.amount_minor, + currency: r.currency, + currency_scale: scale_to_u8(r.currency_scale), + invoice_id: r.invoice_id, + due_date: r.due_date, + revenue_stream: r.revenue_stream, + mapping_status: parse_enum(&r.mapping_status, MappingStatus::from_str)?, + functional_amount_minor: r.functional_amount_minor, + functional_currency: r.functional_currency, + tax_jurisdiction: r.tax_jurisdiction, + tax_filing_period: r.tax_filing_period, + ar_status: r.ar_status, + }) +} + +/// Map a read-back [`EntryRecord`] (header + lines) into an SDK [`EntryView`]. +fn entry_record_to_view(r: EntryRecord) -> Result { + let lines = r + .lines + .into_iter() + .map(line_record_to_view) + .collect::, _>>()?; + Ok(EntryView { + entry_id: r.entry_id, + tenant_id: r.tenant_id, + period_id: r.period_id, + entry_currency: r.entry_currency, + source_doc_type: parse_enum(&r.source_doc_type, SourceDocType::from_str)?, + source_business_id: r.source_business_id, + reverses_entry_id: r.reverses_entry_id, + reverses_period_id: r.reverses_period_id, + posted_at_utc: r.posted_at_utc, + effective_at: r.effective_at, + posted_by_actor_id: r.posted_by_actor_id, + origin: r.origin, + correlation_id: r.correlation_id, + created_seq: r.created_seq, + lines, + }) +} + +/// Map a `journal_line` row (from `list_lines`) into an SDK [`LineView`]. The +/// stored enum literals were written + validated by this gear on the way in; an +/// unknown one here is data corruption and fails loud (`Internal`), never a +/// silently wrong class/side/status. +fn line_model_to_view(m: journal_line::Model) -> Result { + Ok(LineView { + line_id: m.line_id, + entry_id: m.entry_id, + payer_tenant_id: m.payer_tenant_id, + account_id: m.account_id, + account_class: parse_enum(&m.account_class, AccountClass::from_str)?, + gl_code: m.gl_code, + side: parse_enum(&m.side, Side::from_str)?, + amount_minor: m.amount_minor, + currency: m.currency, + currency_scale: scale_to_u8(m.currency_scale), + invoice_id: m.invoice_id, + due_date: m.due_date, + revenue_stream: m.revenue_stream, + mapping_status: parse_enum(&m.mapping_status, MappingStatus::from_str)?, + functional_amount_minor: m.functional_amount_minor, + functional_currency: m.functional_currency, + tax_jurisdiction: m.tax_jurisdiction, + tax_filing_period: m.tax_filing_period, + ar_status: m.ar_status, + }) +} + +/// Map an `account_balance` row into an SDK [`BalanceView`]. +fn balance_model_to_view(m: account_balance::Model) -> Result { + Ok(BalanceView { + account_id: m.account_id, + account_class: parse_enum(&m.account_class, AccountClass::from_str)?, + currency: m.currency, + balance_minor: m.balance_minor, + functional_balance_minor: m.functional_balance_minor, + functional_currency: m.functional_currency, + }) +} + +/// Map an `ar_invoice_balance` row into an SDK [`ArInvoiceBalanceView`]. +fn ar_invoice_model_to_view(m: ar_invoice_balance::Model) -> ArInvoiceBalanceView { + ArInvoiceBalanceView { + payer_tenant_id: m.payer_tenant_id, + account_id: m.account_id, + invoice_id: m.invoice_id, + currency: m.currency, + balance_minor: m.balance_minor, + due_date: m.due_date, + } +} + +/// Map a `payment_allocation` row (from `list_payment_allocations`) into an SDK +/// [`AllocationView`]. +fn payment_allocation_to_view(m: payment_allocation::Model) -> AllocationView { + AllocationView { + invoice_id: m.invoice_id, + amount_minor: m.amount_minor, + currency: m.currency, + allocated_at_utc: m.allocated_at_utc, + precedence_policy_ref: m.precedence_policy_ref, + } +} + +/// Project an [`OdataPageError`] from an OData-paginated list read into a +/// [`CanonicalError`]. A malformed `$filter` / `$orderby` / cursor +/// (`Odata`) maps through the platform `From` projection +/// (canonical 400 for parse/cursor failures, 500 for an internal driver fault); +/// a connection / storage fault (`Db`) is an `Internal` (500) whose driver text +/// stays out of the wire `Problem`. +pub(crate) fn map_odata_page_err(err: OdataPageError) -> CanonicalError { + match err { + OdataPageError::Odata(e) => CanonicalError::from(e), + OdataPageError::Db(_) => { + CanonicalError::internal("list read: database error (driver text redacted)").create() + } + } +} + +#[cfg(test)] +#[path = "local_client_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/api/local_client_tests.rs b/gears/bss/ledger/ledger/src/api/local_client_tests.rs new file mode 100644 index 000000000..3a70c23e2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/local_client_tests.rs @@ -0,0 +1,1612 @@ +//! Pure mapper units for the in-process client — no DB. Build storage `Model` +//! / domain `Record` values by hand and assert the row→SDK-view projection, +//! the scale clamp, the unknown-enum fail-loud, and the OData error mapping. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::doc_markdown, + clippy::similar_names, + clippy::redundant_closure, + clippy::inconsistent_struct_constructor +)] + +use std::str::FromStr; + +use super::{ + ar_invoice_model_to_view, balance_model_to_view, entry_record_to_view, line_model_to_view, + line_record_to_view, map_odata_page_err, parse_enum, scale_to_u8, +}; +use crate::domain::model::{EntryRecord, LineRecord}; +use crate::infra::storage::entity::{account_balance, ar_invoice_balance, journal_line}; +use crate::infra::storage::repo::journal_repo::OdataPageError; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// scale_to_u8 +// --------------------------------------------------------------------------- + +#[test] +fn scale_to_u8_passes_small_value() { + assert_eq!(scale_to_u8(2), 2); + assert_eq!(scale_to_u8(0), 0); +} + +#[test] +fn scale_to_u8_clamps_negative_to_zero() { + // Impossible-by-construction stored value must not panic. + assert_eq!(scale_to_u8(-5), 0); +} + +// --------------------------------------------------------------------------- +// parse_enum +// --------------------------------------------------------------------------- + +#[test] +fn parse_enum_ok_known_literal() { + let ok = parse_enum("DR", |s| Side::from_str(s)).expect("known literal parses"); + assert_eq!(ok, Side::Debit); +} + +#[test] +fn parse_enum_unknown_literal_is_internal_500() { + let err = parse_enum("NONSENSE", |s| Side::from_str(s)).expect_err("unknown is rejected"); + assert_eq!(err.status_code(), 500, "data corruption must fail loud"); +} + +// --------------------------------------------------------------------------- +// balance_model_to_view +// --------------------------------------------------------------------------- + +fn sample_balance_row() -> account_balance::Model { + account_balance::Model { + tenant_id: Uuid::now_v7(), + account_id: Uuid::now_v7(), + currency: "USD".to_owned(), + account_class: AccountClass::Revenue.as_str().to_owned(), + normal_side: Side::Credit.as_str().to_owned(), + balance_minor: 1000, + functional_balance_minor: None, + functional_currency: None, + last_entry_seq: None, + version: 1, + } +} + +#[test] +fn balance_model_to_view_projects_fields_and_parses_class() { + let row = sample_balance_row(); + let view = balance_model_to_view(row).expect("valid class projects"); + assert_eq!(view.account_class, AccountClass::Revenue); + assert_eq!(view.balance_minor, 1000); + assert_eq!(view.currency, "USD"); +} + +#[test] +fn balance_model_to_view_unknown_class_fails_loud() { + let mut row = sample_balance_row(); + row.account_class = "WAT".to_owned(); + assert_eq!(balance_model_to_view(row).unwrap_err().status_code(), 500); +} + +// --------------------------------------------------------------------------- +// line_model_to_view +// --------------------------------------------------------------------------- + +fn sample_journal_line_row() -> journal_line::Model { + journal_line::Model { + line_id: Uuid::now_v7(), + entry_id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + period_id: "2025-01".to_owned(), + payer_tenant_id: Uuid::now_v7(), + seller_tenant_id: None, + resource_tenant_id: None, + account_id: Uuid::now_v7(), + account_class: AccountClass::Ar.as_str().to_owned(), + gl_code: None, + side: Side::Debit.as_str().to_owned(), + amount_minor: 5000, + currency: "EUR".to_owned(), + currency_scale: 2, + invoice_id: Some("INV-001".to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved.as_str().to_owned(), + functional_amount_minor: None, + functional_currency: None, + rate_snapshot_ref: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +#[test] +fn line_model_to_view_projects_fields() { + let row = sample_journal_line_row(); + let view = line_model_to_view(row).expect("valid row projects"); + assert_eq!(view.account_class, AccountClass::Ar); + assert_eq!(view.side, Side::Debit); + assert_eq!(view.amount_minor, 5000); + assert_eq!(view.currency, "EUR"); + assert_eq!(view.currency_scale, 2); + assert_eq!(view.mapping_status, MappingStatus::Resolved); + assert_eq!(view.invoice_id.as_deref(), Some("INV-001")); +} + +#[test] +fn line_model_to_view_unknown_side_fails_loud() { + let mut row = sample_journal_line_row(); + row.side = "NEITHER".to_owned(); + assert_eq!(line_model_to_view(row).unwrap_err().status_code(), 500); +} + +#[test] +fn line_model_to_view_unknown_class_fails_loud() { + let mut row = sample_journal_line_row(); + row.account_class = "BOGUS".to_owned(); + assert_eq!(line_model_to_view(row).unwrap_err().status_code(), 500); +} + +#[test] +fn line_model_to_view_unknown_mapping_status_fails_loud() { + let mut row = sample_journal_line_row(); + row.mapping_status = "UNKNOWN_STATUS".to_owned(); + assert_eq!(line_model_to_view(row).unwrap_err().status_code(), 500); +} + +// --------------------------------------------------------------------------- +// ar_invoice_model_to_view (infallible mapper, no enum parsing) +// --------------------------------------------------------------------------- + +fn sample_ar_invoice_row() -> ar_invoice_balance::Model { + ar_invoice_balance::Model { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + account_id: Uuid::now_v7(), + invoice_id: "INV-999".to_owned(), + currency: "USD".to_owned(), + balance_minor: 2500, + disputed_minor: 0, + functional_balance_minor: None, + functional_currency: None, + original_posted_at: Some(Utc::now()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 1, 31).unwrap()), + last_entry_seq: Some(7), + version: 3, + } +} + +#[test] +fn ar_invoice_model_to_view_projects_fields() { + let row = sample_ar_invoice_row(); + let payer = row.payer_tenant_id; + let account = row.account_id; + let view = ar_invoice_model_to_view(row); + assert_eq!(view.payer_tenant_id, payer); + assert_eq!(view.account_id, account); + assert_eq!(view.invoice_id, "INV-999"); + assert_eq!(view.currency, "USD"); + assert_eq!(view.balance_minor, 2500); + assert!(view.due_date.is_some()); +} + +// --------------------------------------------------------------------------- +// line_record_to_view and entry_record_to_view +// --------------------------------------------------------------------------- + +fn sample_line_record() -> LineRecord { + LineRecord { + line_id: Uuid::now_v7(), + entry_id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + period_id: "2025-01".to_owned(), + payer_tenant_id: Uuid::now_v7(), + seller_tenant_id: None, + resource_tenant_id: None, + account_id: Uuid::now_v7(), + account_class: AccountClass::Revenue.as_str().to_owned(), + gl_code: None, + side: Side::Credit.as_str().to_owned(), + amount_minor: 800, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: Some("SaaS".to_owned()), + mapping_status: MappingStatus::Resolved.as_str().to_owned(), + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +#[test] +fn line_record_to_view_projects_fields() { + let rec = sample_line_record(); + let view = line_record_to_view(rec).expect("valid record projects"); + assert_eq!(view.account_class, AccountClass::Revenue); + assert_eq!(view.side, Side::Credit); + assert_eq!(view.amount_minor, 800); + assert_eq!(view.currency, "USD"); + assert_eq!(view.currency_scale, 2); + assert_eq!(view.revenue_stream.as_deref(), Some("SaaS")); +} + +#[test] +fn line_record_to_view_unknown_side_fails_loud() { + let mut rec = sample_line_record(); + rec.side = "SIDEWAYS".to_owned(); + assert_eq!(line_record_to_view(rec).unwrap_err().status_code(), 500); +} + +fn sample_entry_record() -> EntryRecord { + EntryRecord { + entry_id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + legal_entity_id: Uuid::now_v7(), + period_id: "2025-01".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost.as_str().to_owned(), + source_business_id: "BIZ-42".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: Uuid::now_v7(), + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + created_seq: 1, + lines: vec![sample_line_record()], + } +} + +#[test] +fn entry_record_to_view_projects_header_fields() { + let rec = sample_entry_record(); + let tenant = rec.tenant_id; + let view = entry_record_to_view(rec).expect("valid record projects"); + assert_eq!(view.tenant_id, tenant); + assert_eq!(view.source_doc_type, SourceDocType::InvoicePost); + assert_eq!(view.source_business_id, "BIZ-42"); + assert_eq!(view.lines.len(), 1); +} + +#[test] +fn entry_record_to_view_unknown_source_doc_type_fails_loud() { + let mut rec = sample_entry_record(); + rec.source_doc_type = "ALIEN_DOC".to_owned(); + assert_eq!(entry_record_to_view(rec).unwrap_err().status_code(), 500); +} + +#[test] +fn entry_record_to_view_unknown_line_enum_fails_loud() { + let mut rec = sample_entry_record(); + rec.lines[0].account_class = "NOT_A_CLASS".to_owned(); + assert_eq!(entry_record_to_view(rec).unwrap_err().status_code(), 500); +} + +// --------------------------------------------------------------------------- +// map_odata_page_err +// --------------------------------------------------------------------------- + +#[test] +fn map_odata_page_err_db_is_internal_500_and_redacts() { + let err = map_odata_page_err(OdataPageError::Db("secret-dsn-info".to_owned())); + assert_eq!(err.status_code(), 500); + let problem = toolkit::api::canonical_prelude::Problem::from(err); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + !body.contains("secret-dsn-info"), + "driver text must not leak: {body}" + ); +} + +#[cfg(test)] +mod pg { + //! testcontainers-postgres integration over the REAL `LedgerLocalClient`. + //! The harness (`boot` / `authed_ctx` / `provision_req` / `balanced_post` / + //! `account_ids` + the authz / seller fakes) is the shared foundation Tasks + //! 8-11 call. The authz fakes + the authed-context builder are copied + //! verbatim from `tests/rest_journal_entries.rs` (those live in the + //! integration crate and can't be imported here); the seed helpers are + //! copied from `tests/postgres_provisioning.rs`. One container per test. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use std::sync::Arc; + + use async_trait::async_trait; + use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; + use authz_resolver_sdk::error::AuthZResolverError; + use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, + }; + use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; + use bss_ledger_sdk::api::LedgerClientV1; + use bss_ledger_sdk::{ + AccountClass, AllocateOutcome, AllocatePayment, FiscalCalendarSpec, Granularity, + MappingStatus, ODataQuery, PostEntry, PostLine, ProvisionAccount, ProvisionCurrencyScale, + ProvisionOutcome, ProvisionRequest, RecordDisputePhase, ReturnPayment, + RevenueDisaggregationQuery, SettlePayment, Side, SourceDocType, + }; + use chrono::NaiveDate; + use sea_orm::Database; + use sea_orm_migration::MigratorTrait; + use testcontainers_modules::postgres::Postgres; + use testcontainers_modules::testcontainers::runners::AsyncRunner; + use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; + use toolkit_security::SecurityContext; + use uuid::Uuid; + + use crate::api::local_client::LedgerLocalClient; + use crate::domain::ports::metrics::NoopLedgerMetrics; + use crate::infra::events::publisher::LedgerEventPublisher; + use crate::infra::seller_guard::{SellerGuard, TenantTypeReader}; + use crate::infra::storage::migrations::Migrator; + + // ── authz fakes (verbatim from tests/rest_journal_entries.rs) ────────────── + fn subject_tenant_id(request: &EvaluationRequest) -> Uuid { + request + .subject + .properties + .get("tenant_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(Uuid::nil) + } + + fn tenant_in_constraint(tenant_id: Uuid) -> Constraint { + Constraint { + predicates: vec![Predicate::In(InPredicate::new( + toolkit_security::pep_properties::OWNER_TENANT_ID, + [tenant_id], + ))], + } + } + + /// Always-allow fake that echoes the subject's tenant as an + /// `owner_tenant_id` `In` constraint (the scope the read/write paths bind). + pub(super) struct AllowAuthZ; + + #[async_trait] + impl AuthZResolverClient for AllowAuthZ { + async fn evaluate( + &self, + request: EvaluationRequest, + ) -> Result { + let tenant_id = subject_tenant_id(&request); + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![tenant_in_constraint(tenant_id)], + deny_reason: None, + }, + }) + } + } + + /// Always-deny fake — models the PDP refusing the action so the gate maps it + /// to a `PermissionDenied`. Held for Tasks 8-11 (the deny-path tests). + #[allow(dead_code)] + pub(super) struct DenyAuthZ; + + #[async_trait] + impl AuthZResolverClient for DenyAuthZ { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext { + constraints: vec![], + deny_reason: None, + }, + }) + } + } + + // ── seller-type fake (from infra/seller_guard_tests.rs) ──────────────────── + /// The tenant type the boot seller-guard is configured to accept; a + /// [`FakeReader`] returning it lets `provision` clear the seller gate. + pub(super) const SELLER_TYPE: &str = "gts.cf.core.am.tenant_type.v1~seller.v1~"; + + /// Canned tenant-type reader (stands in for the AM `get_tenant` adapter). + /// `Some(t)` resolves every tenant to type `t`; `None` resolves no type + /// (drives the seller-guard `FailedPrecondition` path for Tasks 8-11). + pub(super) struct FakeReader(pub Option); + + #[async_trait] + impl TenantTypeReader for FakeReader { + async fn tenant_type( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + ) -> Result, toolkit::api::canonical_prelude::CanonicalError> { + Ok(self.0.clone()) + } + } + + /// An authenticated `SecurityContext` whose subject tenant is `tenant` — + /// also the only tenant `AllowAuthZ` authorizes (its `In` constraint echoes + /// the subject tenant), so reads/writes must target `tenant`. + pub(super) fn authed_ctx(tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::now_v7()) + .subject_tenant_id(tenant) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext") + } + + /// Boot PG, migrate, and build the real client with an ALLOW enforcer + a + /// seller `FakeReader(Some(SELLER_TYPE))`. Returns the live container (keep + /// it in scope for the test's duration), the client, and a fresh tenant id + /// the caller seeds/authorizes against. + pub(super) async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + LedgerLocalClient, + Uuid, + ) { + boot_with(Some(SELLER_TYPE.to_owned())).await + } + + /// `boot` with the seller-type the `FakeReader` resolves made explicit: + /// `Some(t)` clears the seller gate for type `t`; `None` makes `provision` + /// fail the seller gate (`FailedPrecondition`). Tasks 8-11 use this to drive + /// the non-seller / unresolved-type rejection paths. + pub(super) async fn boot_with( + seller_type: Option, + ) -> ( + testcontainers_modules::testcontainers::ContainerAsync, + LedgerLocalClient, + Uuid, + ) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + // Raw sea-orm connection for the migrator. + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + // The client connection sets search_path=bss (as the gear config does in + // prod) so its unqualified entity queries resolve into the bss schema. + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let publisher = Arc::new(LedgerEventPublisher::noop()); + let enforcer = Arc::new(PolicyEnforcer::new(Arc::new(AllowAuthZ))); + let seller_guard = Arc::new(SellerGuard::new( + Arc::new(FakeReader(seller_type)), + [SELLER_TYPE.to_owned()], + )); + let metrics = Arc::new(NoopLedgerMetrics); + let client = LedgerLocalClient::new( + provider, + publisher, + enforcer, + seller_guard, + metrics, + crate::config::FxConfig::default(), + crate::config::PaymentsConfig::default(), + crate::infra::period_close::CloseControlFeeds::inert(), + ); + (container, client, Uuid::now_v7()) + } + + // ── seed helpers (from tests/postgres_provisioning.rs) ───────────────────── + fn account( + class: AccountClass, + currency: &str, + revenue_stream: Option<&str>, + side: Side, + ) -> ProvisionAccount { + ProvisionAccount { + account_class: class, + currency: currency.to_owned(), + revenue_stream: revenue_stream.map(str::to_owned), + normal_side: side, + may_go_negative: false, + } + } + + /// USD at scale 2 (ISO). Default headroom (`plausible_max_major` omitted). + fn usd2_scale() -> ProvisionCurrencyScale { + ProvisionCurrencyScale { + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: None, + source: "iso".to_owned(), + } + } + + fn utc_calendar() -> FiscalCalendarSpec { + FiscalCalendarSpec { + timezone: "UTC".to_owned(), + granularity: Granularity::Month, + fy_start_month: 1, + functional_currency: None, + } + } + + /// Seller-shaped provision request for `tenant`: AR plus + /// REVENUE(subscription) plus TAX_PAYABLE accounts, USD@2, and a UTC monthly + /// calendar. `provision` seeds the OPEN period for the CURRENT month and + /// returns its id in `ProvisionOutcome::period_id` — build the post against + /// that id, never a hard-coded period. + pub(super) fn provision_req(tenant: Uuid) -> ProvisionRequest { + ProvisionRequest { + tenant_id: tenant, + accounts: vec![ + account(AccountClass::Ar, "USD", None, Side::Debit), + account( + AccountClass::Revenue, + "USD", + Some("subscription"), + Side::Credit, + ), + account(AccountClass::TaxPayable, "USD", None, Side::Credit), + ], + currency_scales: vec![usd2_scale()], + fiscal_calendar: utc_calendar(), + } + } + + /// A payment-shaped provision for `tenant`: the four money-flow chart + /// accounts (CASH_CLEARING / UNALLOCATED / PSP_FEE_EXPENSE / AR) plus USD@2 + /// and a UTC monthly calendar. `provision` seeds the OPEN period for the + /// CURRENT month (settle/allocate derive `period_id` from `Utc::now()`). + pub(super) fn payment_provision_req(tenant: Uuid) -> ProvisionRequest { + ProvisionRequest { + tenant_id: tenant, + accounts: vec![ + account(AccountClass::CashClearing, "USD", None, Side::Debit), + account(AccountClass::Unallocated, "USD", None, Side::Credit), + account(AccountClass::PspFeeExpense, "USD", None, Side::Debit), + account(AccountClass::Ar, "USD", None, Side::Debit), + ], + currency_scales: vec![usd2_scale()], + fiscal_calendar: utc_calendar(), + } + } + + /// `(ar, revenue, tax)` `account_id`s from a [`ProvisionOutcome`], by class. + pub(super) fn account_ids(out: &ProvisionOutcome) -> (Uuid, Uuid, Uuid) { + let pick = |c: AccountClass| { + out.accounts + .iter() + .find(|a| a.account_class == c) + .expect("provisioned account") + .account_id + }; + ( + pick(AccountClass::Ar), + pick(AccountClass::Revenue), + pick(AccountClass::TaxPayable), + ) + } + + /// A balanced invoice post for `tenant` into `period_id` against the + /// provisioned chart: DR AR 1200 / CR REVENUE 1000 / CR TAX_PAYABLE 200. + /// `ar` / `rev` / `tax` are the `account_id`s from the prior `provision` + /// outcome (see [`account_ids`]); `payer` is the AR payer tenant. + pub(super) fn balanced_post( + tenant: Uuid, + payer: Uuid, + period_id: &str, + ar: Uuid, + rev: Uuid, + tax: Uuid, + ) -> PostEntry { + let line = |account_id, + class, + side, + amount, + invoice: Option<&str>, + rstream: Option<&str>, + taxj: Option<&str>| PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: payer, + seller_tenant_id: None, + resource_tenant_id: None, + account_id, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + invoice_id: invoice.map(str::to_owned), + due_date: invoice.map(|_| NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()), + revenue_stream: rstream.map(str::to_owned), + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: taxj.map(str::to_owned), + tax_filing_period: taxj.map(|_| "2026Q3".to_owned()), + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }; + PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + period_id: period_id.to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "INV-1".to_owned(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + posted_by_actor_id: Uuid::now_v7(), + correlation_id: Uuid::now_v7(), + reverses_entry_id: None, + reverses_period_id: None, + lines: vec![ + line( + ar, + AccountClass::Ar, + Side::Debit, + 1200, + Some("INV-1"), + None, + None, + ), + line( + rev, + AccountClass::Revenue, + Side::Credit, + 1000, + None, + Some("subscription"), + None, + ), + line( + tax, + AccountClass::TaxPayable, + Side::Credit, + 200, + None, + None, + Some("US-CA"), + ), + ], + } + } + + /// Boot with a DENY enforcer — the PEP refuses every action → PermissionDenied. + pub(super) async fn boot_deny() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + LedgerLocalClient, + Uuid, + ) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let publisher = Arc::new(LedgerEventPublisher::noop()); + let enforcer = Arc::new(PolicyEnforcer::new(Arc::new(DenyAuthZ))); + let seller_guard = Arc::new(SellerGuard::new( + Arc::new(FakeReader(Some(SELLER_TYPE.to_owned()))), + [SELLER_TYPE.to_owned()], + )); + let metrics = Arc::new(NoopLedgerMetrics); + let client = LedgerLocalClient::new( + provider, + publisher, + enforcer, + seller_guard, + metrics, + crate::config::FxConfig::default(), + crate::config::PaymentsConfig::default(), + crate::infra::period_close::CloseControlFeeds::inert(), + ); + (container, client, Uuid::now_v7()) + } + + /// Boot with an explicit `FakeReader` — use for non-seller gate tests. + pub(super) async fn boot_with_reader( + reader: FakeReader, + ) -> ( + testcontainers_modules::testcontainers::ContainerAsync, + LedgerLocalClient, + Uuid, + ) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let publisher = Arc::new(LedgerEventPublisher::noop()); + let enforcer = Arc::new(PolicyEnforcer::new(Arc::new(AllowAuthZ))); + let seller_guard = Arc::new(SellerGuard::new(Arc::new(reader), [SELLER_TYPE.to_owned()])); + let metrics = Arc::new(NoopLedgerMetrics); + let client = LedgerLocalClient::new( + provider, + publisher, + enforcer, + seller_guard, + metrics, + crate::config::FxConfig::default(), + crate::config::PaymentsConfig::default(), + crate::infra::period_close::CloseControlFeeds::inert(), + ); + (container, client, Uuid::now_v7()) + } + + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn post_balanced_entry_happy_path() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + + // Seed the chart + OPEN period, then post a balanced invoice entry into + // the period `provision` opened (CURRENT month — derive it, don't hard + // code, so the test is stable across months). + let out = client + .provision(&ctx, provision_req(tenant)) + .await + .expect("provision must succeed"); + let (ar, rev, tax) = account_ids(&out); + + let entry = balanced_post(tenant, Uuid::now_v7(), &out.period_id, ar, rev, tax); + let want = entry.entry_id; + + let posted = client + .post_balanced_entry(&ctx, entry) + .await + .expect("balanced post must succeed"); + assert!(!posted.replayed, "first post is not a replay"); + assert_eq!(posted.entry_id, want, "post returns the supplied entry id"); + } + + // ── Task 8: post deny + unbalanced ────────────────────────────────────────── + + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn post_denied_maps_to_permission_denied() { + let (_c, client, tenant) = boot_deny().await; + // Deny short-circuits before any DB read, so unprovisioned ids are fine. + let entry = balanced_post( + tenant, + Uuid::now_v7(), + "202606", + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + let err = client + .post_balanced_entry(&authed_ctx(tenant), entry) + .await + .unwrap_err(); + assert_eq!(err.status_code(), 403); + } + + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn post_unbalanced_maps_to_invalid_argument() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + let out = client.provision(&ctx, provision_req(tenant)).await.unwrap(); + let (ar, rev, tax) = account_ids(&out); + let mut entry = balanced_post(tenant, Uuid::now_v7(), &out.period_id, ar, rev, tax); + // DR 1199 ≠ CR 1200 → unbalanced + entry.lines[0].amount_minor = 1199; + let err = client.post_balanced_entry(&ctx, entry).await.unwrap_err(); + assert_eq!(err.status_code(), 400); + } + + // ── Task 9: reads + cross-tenant BOLA ─────────────────────────────────────── + + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn reads_return_seeded_ledger_and_are_tenant_scoped() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + let out = client.provision(&ctx, provision_req(tenant)).await.unwrap(); + let (ar, rev, tax) = account_ids(&out); + let entry = balanced_post(tenant, Uuid::now_v7(), &out.period_id, ar, rev, tax); + let entry_id = entry.entry_id; + client.post_balanced_entry(&ctx, entry).await.unwrap(); + + // Owner reads see the seeded ledger. + assert!( + client + .get_entry(&ctx, tenant, entry_id) + .await + .unwrap() + .is_some(), + "owner should see their entry" + ); + assert!( + client + .read_account_balance(&ctx, tenant, ar) + .await + .unwrap() + .is_some(), + "owner should see AR balance" + ); + assert!( + !client + .list_lines(&ctx, tenant, &ODataQuery::default()) + .await + .unwrap() + .items + .is_empty(), + "owner should see lines" + ); + assert!( + !client + .list_balances(&ctx, tenant, &ODataQuery::default()) + .await + .unwrap() + .items + .is_empty(), + "owner should see balances" + ); + assert!( + !client + .list_ar_invoice_balances(&ctx, tenant, None) + .await + .unwrap() + .is_empty(), + "owner should see AR invoice balances" + ); + assert!( + !client + .list_accounts(&ctx, tenant, &ODataQuery::default()) + .await + .unwrap() + .items + .is_empty(), + "owner should see accounts" + ); + + // A FOREIGN tenant ctx is SQL-scoped out → empty, never a 403 (BOLA). + let other = authed_ctx(Uuid::now_v7()); + assert!( + client + .get_entry(&other, tenant, entry_id) + .await + .unwrap() + .is_none(), + "foreign ctx must not see other tenant's entry (BOLA)" + ); + assert!( + client + .list_lines(&other, tenant, &ODataQuery::default()) + .await + .unwrap() + .items + .is_empty(), + "foreign ctx must not see other tenant's lines (BOLA)" + ); + } + + // ── Task 10: close_period ─────────────────────────────────────────────────── + + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn close_period_transitions_open_to_closed() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + let out = client.provision(&ctx, provision_req(tenant)).await.unwrap(); + let outcome = client + .close_period(&ctx, tenant, out.period_id.clone()) + .await + .unwrap(); + assert_eq!(outcome.period_id, out.period_id, "period_id echoed"); + assert!(!outcome.already_closed, "first close is not already_closed"); + + // A post into the now-closed period must fail 400 (PERIOD_CLOSED). + let (ar, rev, tax) = account_ids(&out); + let entry = balanced_post(tenant, Uuid::now_v7(), &out.period_id, ar, rev, tax); + let err = client.post_balanced_entry(&ctx, entry).await.unwrap_err(); + assert_eq!( + err.status_code(), + 400, + "posting into a closed period must be 400" + ); + } + + // ── Task 11: provision allow / deny / non-seller ──────────────────────────── + + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn provision_seller_succeeds() { + let (_c, client, tenant) = boot().await; + let out = client + .provision(&authed_ctx(tenant), provision_req(tenant)) + .await + .unwrap(); + assert!( + out.accounts_created >= 3 || out.accounts_existing >= 3, + "chart must be seeded with at least 3 accounts" + ); + } + + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn provision_denied_is_403() { + let (_c, client, tenant) = boot_deny().await; + let err = client + .provision(&authed_ctx(tenant), provision_req(tenant)) + .await + .unwrap_err(); + assert_eq!(err.status_code(), 403); + } + + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn provision_non_seller_is_failed_precondition() { + let (_c, client, tenant) = + boot_with_reader(FakeReader(Some("buyer-type".to_owned()))).await; + let err = client + .provision(&authed_ctx(tenant), provision_req(tenant)) + .await + .unwrap_err(); + // FailedPrecondition: TENANT_TYPE_NOT_LEDGER_OWNER → 400 + assert_eq!(err.status_code(), 400); + } + + // ── Payment money-in / money-out through the client ───────────────────────── + + /// The payment flow through the in-process client end-to-end: `settle_payment` + /// parks a receipt in the unallocated pool, `read_unallocated` reads it back as + /// an `UnallocatedView`, `allocate_payment` (precedence mode, `splits = None`) + /// drains it onto an open AR invoice, and `list_payment_allocations` returns the + /// persisted `payment_allocation` rows as `AllocationView`s. Exercises the four + /// payment client methods + the `caller_splits = None` mapping arm of + /// `allocate_payment`, the `AllocationApplied`/`AllocationView` shaping, the + /// `UnallocatedView` shaping, and `payment_allocation_to_view` — the + /// in-process surface other gears call, none of it reachable from the + /// out-of-crate tests (`LedgerLocalClient::new` is `pub(crate)`). + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn settle_read_allocate_list_through_client() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + let payer = Uuid::now_v7(); + + // Provision the payment chart (CASH/UNALLOCATED/PSP_FEE/AR) + the OPEN + // current-month period settle/allocate derive. + let out = client + .provision(&ctx, payment_provision_req(tenant)) + .await + .expect("payment provision must succeed"); + let ar = out + .accounts + .iter() + .find(|a| a.account_class == AccountClass::Ar) + .expect("AR provisioned") + .account_id; + let psp = out + .accounts + .iter() + .find(|a| a.account_class == AccountClass::PspFeeExpense) + .expect("PSP_FEE provisioned") + .account_id; + + // Settle gross=1000 fee=0 ⇒ the whole gross parks in UNALLOCATED. + let settled = client + .settle_payment( + &ctx, + SettlePayment { + tenant_id: tenant, + payer_tenant_id: payer, + payment_id: "PAY-LC-1".to_owned(), + gross_minor: 1000, + fee_minor: 0, + currency: "USD".to_owned(), + scale: 2, + effective_at: None, + }, + ) + .await + .expect("settle must succeed"); + assert!(!settled.replayed, "first settle is fresh"); + + // read_unallocated returns the pooled gross as an UnallocatedView. + let pool = client + .read_unallocated(&ctx, tenant, payer, "USD".to_owned()) + .await + .expect("read unallocated"); + assert_eq!(pool.balance_minor, 1000, "gross parked in UNALLOCATED"); + assert_eq!(pool.payer_tenant_id, payer); + assert_eq!(pool.currency, "USD"); + + // Seed an open AR invoice (DR AR 400 / CR PSP_FEE 400 — PSP_FEE is + // unguarded, so the CR from zero is allowed) into the same period. + let inv = PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + period_id: out.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "INV-LC".to_owned(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + posted_by_actor_id: Uuid::now_v7(), + correlation_id: Uuid::now_v7(), + reverses_entry_id: None, + reverses_period_id: None, + lines: vec![ + PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: payer, + seller_tenant_id: Some(tenant), + resource_tenant_id: None, + account_id: ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: 400, + currency: "USD".to_owned(), + invoice_id: Some("INV-LC".to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }, + PostLine { + line_id: Uuid::now_v7(), + account_id: psp, + account_class: AccountClass::PspFeeExpense, + side: Side::Credit, + invoice_id: None, + due_date: None, + payer_tenant_id: payer, + seller_tenant_id: Some(tenant), + resource_tenant_id: None, + gl_code: None, + amount_minor: 400, + currency: "USD".to_owned(), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }, + ], + }; + client + .post_balanced_entry(&ctx, inv) + .await + .expect("seed AR invoice"); + + // Allocate 400 (precedence mode) ⇒ inline-posted onto INV-LC. + let outcome = client + .allocate_payment( + &ctx, + AllocatePayment { + tenant_id: tenant, + payer_tenant_id: payer, + payment_id: "PAY-LC-1".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 400, + currency: "USD".to_owned(), + scale: 2, + hint_invoice_id: None, + splits: None, + }, + ) + .await + .expect("allocate must succeed"); + let applied = match outcome { + AllocateOutcome::Applied(a) => a, + AllocateOutcome::Queued(q) => panic!("expected an inline allocation, got {q:?}"), + }; + assert!(!applied.posting.replayed, "first allocate is fresh"); + assert_eq!(applied.allocations.len(), 1, "one invoice filled"); + assert_eq!(applied.allocations[0].invoice_id, "INV-LC"); + assert_eq!(applied.allocations[0].amount_minor, 400); + + // list_payment_allocations returns the persisted row as an AllocationView. + let listed = client + .list_payment_allocations(&ctx, tenant, "PAY-LC-1".to_owned()) + .await + .expect("list allocations"); + assert_eq!(listed.len(), 1, "one persisted allocation row"); + assert_eq!(listed[0].invoice_id, "INV-LC"); + assert_eq!(listed[0].amount_minor, 400); + assert_eq!(listed[0].currency, "USD"); + + // The pool drained by the allocated total (1000 - 400 = 600 left). + let pool_after = client + .read_unallocated(&ctx, tenant, payer, "USD".to_owned()) + .await + .expect("read unallocated after allocate"); + assert_eq!(pool_after.balance_minor, 600, "pool drained by 400"); + } + + /// `record_dispute_phase` with an unknown `phase` literal is rejected + /// `InvalidArgument` (400) at the boundary parse — BEFORE any post — exercising + /// the `DisputePhase::parse(...) None` arm of the client (a bad wire literal is + /// a 400, not a deep post-path fault). The authz gate passes (subject tenant = + /// target), so the parse is what trips. + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn record_dispute_phase_bad_literal_is_400() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + + let err = client + .record_dispute_phase( + &ctx, + RecordDisputePhase { + tenant_id: tenant, + payer_tenant_id: Uuid::now_v7(), + payment_id: "PAY-D".to_owned(), + dispute_id: "D-1".to_owned(), + invoice_id: None, + cycle: 1, + phase: "not-a-phase".to_owned(), + funds_at_open: "withheld".to_owned(), + disputed_amount_minor: 100, + currency: "USD".to_owned(), + scale: 2, + effective_at: None, + }, + ) + .await + .expect_err("an unknown dispute phase must be rejected"); + assert_eq!( + err.status_code(), + 400, + "a bad phase literal is InvalidArgument (400)" + ); + } + + /// The recognition read plane through the client: a freshly-provisioned tenant + /// with no schedules returns empty lists / `None`, exercising + /// `read_recognition_scope` (a DIFFERENT scope from the `entry` data plane the + /// other reads use) + the repo delegation + the empty-mapping arms of + /// `list_recognition_schedules`, `get_recognition_schedule`, and + /// `list_revenue_disaggregation`. (A populated recognition ledger is covered by + /// the dedicated recognition suites; this pins the client's recognition-scope + /// wiring + the empty read shape.) + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn recognition_reads_empty_on_fresh_tenant() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + client + .provision(&ctx, provision_req(tenant)) + .await + .expect("provision"); + + let schedules = client + .list_recognition_schedules(&ctx, tenant, None, None) + .await + .expect("list recognition schedules"); + assert!(schedules.schedules.is_empty(), "no schedules yet"); + assert!(!schedules.truncated, "an empty list is not truncated"); + + let one = client + .get_recognition_schedule(&ctx, tenant, "no-such-schedule".to_owned()) + .await + .expect("get recognition schedule"); + assert!(one.is_none(), "an absent schedule resolves to None"); + + let disagg = client + .list_revenue_disaggregation( + &ctx, + RevenueDisaggregationQuery { + tenant_id: tenant, + period_id: None, + }, + ) + .await + .expect("list revenue disaggregation"); + assert!(disagg.entries.is_empty(), "no recognized revenue yet"); + } + + // ── Full-flow integration (real client + real Postgres; only the PDP + + // tenant-type reader are fakes). NOT the project e2e suite — those are the + // pytest black-box tests under testing/e2e/gears/ledger/. ── + + /// Full ledger flow: provision → post → read → reverse → tie-out. + /// Drives the real `LedgerLocalClient` against a real database: provision a + /// seller, post a balanced invoice, read it back and see the AR balance, + /// post its reversal, watch every balance net to zero, and confirm the books + /// tie out (zero variance). Boots inline (not via `boot`) so it can KEEP the + /// provider for the closing `TieOutJob` — `boot` moves it into the client. + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn provision_post_read_reverse_ties_out() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + let publisher = Arc::new(LedgerEventPublisher::noop()); + let client = LedgerLocalClient::new( + provider.clone(), + Arc::clone(&publisher), + Arc::new(PolicyEnforcer::new(Arc::new(AllowAuthZ))), + Arc::new(SellerGuard::new( + Arc::new(FakeReader(Some(SELLER_TYPE.to_owned()))), + [SELLER_TYPE.to_owned()], + )), + Arc::new(NoopLedgerMetrics), + crate::config::FxConfig::default(), + crate::config::PaymentsConfig::default(), + crate::infra::period_close::CloseControlFeeds::inert(), + ); + let tenant = Uuid::now_v7(); + let ctx = authed_ctx(tenant); + let payer = Uuid::now_v7(); + + // provision → post (DR AR 1200 / CR REVENUE 1000 / CR TAX 200). + let out = client + .provision(&ctx, provision_req(tenant)) + .await + .expect("provision"); + let (ar, rev, tax) = account_ids(&out); + let entry = balanced_post(tenant, payer, &out.period_id, ar, rev, tax); + let entry_id = entry.entry_id; + client.post_balanced_entry(&ctx, entry).await.expect("post"); + + // read: the entry is visible and AR reflects the DR 1200. + assert!( + client + .get_entry(&ctx, tenant, entry_id) + .await + .expect("get_entry") + .is_some(), + "posted entry must be readable" + ); + assert_eq!( + client + .read_account_balance(&ctx, tenant, ar) + .await + .expect("read AR"), + Some(1200), + "AR reflects the posted debit" + ); + + // reverse: flip every side; the header points back at the original. + let mut reversal = balanced_post(tenant, payer, &out.period_id, ar, rev, tax); + reversal.source_doc_type = SourceDocType::Reversal; + reversal.source_business_id = "reverses=INV-1".to_owned(); + reversal.reverses_entry_id = Some(entry_id); + reversal.reverses_period_id = Some(out.period_id.clone()); + for l in &mut reversal.lines { + l.side = match l.side { + Side::Debit => Side::Credit, + Side::Credit => Side::Debit, + }; + } + client + .post_balanced_entry(&ctx, reversal) + .await + .expect("reversal post"); + + // every balance nets back to zero. + for (label, acct) in [("AR", ar), ("REVENUE", rev), ("TAX", tax)] { + assert_eq!( + client + .read_account_balance(&ctx, tenant, acct) + .await + .expect("read balance"), + Some(0), + "{label} nets to zero after the reversal" + ); + } + + // tie-out over the SAME database: recompute every grain from journal_line + // and confirm zero variance (clean books close the loop). + let report = crate::infra::jobs::tieout::TieOutJob::new(provider, publisher) + .tie_out_tenant(tenant) + .await + .expect("tie-out runs"); + assert!( + report.is_clean(), + "books must tie out after post+reverse: {}", + report.summary() + ); + } + + /// Payments E2E through the real client: provision the money-flow chart, + /// settle a payment, and see the net land in the payer's unallocated pool; + /// a replay of the same `payment_id` is idempotent (no double-credit). + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn settle_lands_in_unallocated_and_replays() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + client + .provision(&ctx, payment_provision_req(tenant)) + .await + .expect("provision payment chart"); + + let payer = Uuid::now_v7(); + let settle = SettlePayment { + tenant_id: tenant, + payer_tenant_id: payer, + payment_id: "PAY-1".to_owned(), + gross_minor: 1000, + fee_minor: 0, + currency: "USD".to_owned(), + scale: 2, + effective_at: None, + }; + + let first = client + .settle_payment(&ctx, settle.clone()) + .await + .expect("settle"); + assert!(!first.replayed, "first settle is a fresh post"); + + // The net (gross − fee) lands in the payer's unallocated pool. + let pool = client + .read_unallocated(&ctx, tenant, payer, "USD".to_owned()) + .await + .expect("read unallocated"); + assert_eq!(pool.balance_minor, 1000, "settled net sits in unallocated"); + + // A replay of the same payment is idempotent — same ref, no double-credit. + let replay = client + .settle_payment(&ctx, settle) + .await + .expect("settle replay"); + assert!(replay.replayed, "same payment_id replays"); + let pool_after = client + .read_unallocated(&ctx, tenant, payer, "USD".to_owned()) + .await + .expect("read unallocated after replay"); + assert_eq!( + pool_after.balance_minor, 1000, + "replay must not double-credit the pool" + ); + } + + /// Money-in lifecycle E2E: provision → post an invoice (opens AR) → settle a + /// payment (funds the pool) → allocate the pool onto the open invoice. Asserts + /// the pool drains to zero, the invoice's AR is paid off, and an allocation + /// row is recorded. Exercises the allocation path — including the + /// touched-invoice size bound — end-to-end through the real client. + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn settle_allocate_drains_pool_into_ar() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + let payer = Uuid::now_v7(); + + // A combined chart: the invoice classes (AR / REVENUE / TAX) plus the + // money-flow classes (CASH_CLEARING / UNALLOCATED / PSP_FEE_EXPENSE). + let req = ProvisionRequest { + tenant_id: tenant, + accounts: vec![ + account(AccountClass::Ar, "USD", None, Side::Debit), + account( + AccountClass::Revenue, + "USD", + Some("subscription"), + Side::Credit, + ), + account(AccountClass::TaxPayable, "USD", None, Side::Credit), + account(AccountClass::CashClearing, "USD", None, Side::Debit), + account(AccountClass::Unallocated, "USD", None, Side::Credit), + account(AccountClass::PspFeeExpense, "USD", None, Side::Debit), + ], + currency_scales: vec![usd2_scale()], + fiscal_calendar: utc_calendar(), + }; + let out = client.provision(&ctx, req).await.expect("provision"); + let (ar, rev, tax) = account_ids(&out); + + // Post an invoice for `payer`: DR AR 1200 (INV-1) / CR REVENUE 1000 / CR TAX 200. + let invoice = balanced_post(tenant, payer, &out.period_id, ar, rev, tax); + client + .post_balanced_entry(&ctx, invoice) + .await + .expect("invoice post"); + + // Settle a 1200 receipt for the same payer → pool holds 1200. + client + .settle_payment( + &ctx, + SettlePayment { + tenant_id: tenant, + payer_tenant_id: payer, + payment_id: "PAY-1".to_owned(), + gross_minor: 1200, + fee_minor: 0, + currency: "USD".to_owned(), + scale: 2, + effective_at: None, + }, + ) + .await + .expect("settle"); + + // Allocate the pool onto the open invoice (settled ⇒ applies inline). + let outcome = client + .allocate_payment( + &ctx, + AllocatePayment { + tenant_id: tenant, + payer_tenant_id: payer, + payment_id: "PAY-1".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 1200, + currency: "USD".to_owned(), + scale: 2, + hint_invoice_id: None, + splits: None, + }, + ) + .await + .expect("allocate"); + assert!( + matches!(outcome, AllocateOutcome::Applied(_)), + "a settled payment allocates inline" + ); + + // The pool is fully drained, the invoice's AR is paid off, and the + // allocation is recorded. + assert_eq!( + client + .read_unallocated(&ctx, tenant, payer, "USD".to_owned()) + .await + .expect("read unallocated") + .balance_minor, + 0, + "the pool drains into AR" + ); + assert_eq!( + client + .read_account_balance(&ctx, tenant, ar) + .await + .expect("read AR"), + Some(0), + "the invoice's AR is paid off" + ); + assert!( + !client + .list_payment_allocations(&ctx, tenant, "PAY-1".to_owned()) + .await + .expect("list allocations") + .is_empty(), + "the allocation is recorded" + ); + } + + /// Settlement-return E2E: settle a 1000 receipt, then record a PSP return of + /// 400 that claws part of it back out of the unallocated pool. Asserts the + /// pool drops by exactly the returned amount, and the return replays + /// idempotently on `psp_return_id` (no second claw-back). + #[tokio::test] + #[ignore = "requires Docker (testcontainers)"] + async fn settlement_return_claws_back_pool() { + let (_c, client, tenant) = boot().await; + let ctx = authed_ctx(tenant); + client + .provision(&ctx, payment_provision_req(tenant)) + .await + .expect("provision payment chart"); + let payer = Uuid::now_v7(); + + client + .settle_payment( + &ctx, + SettlePayment { + tenant_id: tenant, + payer_tenant_id: payer, + payment_id: "PAY-1".to_owned(), + gross_minor: 1000, + fee_minor: 0, + currency: "USD".to_owned(), + scale: 2, + effective_at: None, + }, + ) + .await + .expect("settle"); + + // A partial return of 400 (leaves settled_minor positive, so a replay can + // re-size its fee share and short-circuit on the dedup). + let ret = ReturnPayment { + tenant_id: tenant, + payer_tenant_id: payer, + payment_id: "PAY-1".to_owned(), + psp_return_id: "RET-1".to_owned(), + amount_minor: 400, + currency: "USD".to_owned(), + scale: 2, + effective_at: None, + }; + client + .return_payment(&ctx, ret.clone()) + .await + .expect("return"); + assert_eq!( + client + .read_unallocated(&ctx, tenant, payer, "USD".to_owned()) + .await + .expect("read unallocated") + .balance_minor, + 600, + "the return claws 400 back out of the 1000 pool" + ); + + // Idempotent on psp_return_id — a replay is a no-op on the pool. + let replay = client + .return_payment(&ctx, ret) + .await + .expect("return replay"); + assert!(replay.replayed, "same psp_return_id replays"); + assert_eq!( + client + .read_unallocated(&ctx, tenant, payer, "USD".to_owned()) + .await + .expect("read unallocated after replay") + .balance_minor, + 600, + "replay must not claw back a second time" + ); + } +} diff --git a/gears/bss/ledger/ledger/src/api/rest.rs b/gears/bss/ledger/ledger/src/api/rest.rs new file mode 100644 index 000000000..6a09467f1 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest.rs @@ -0,0 +1,28 @@ +//! REST API layer for the `bss-ledger` gear. +//! +//! Hosts the seller-provisioning handler plus the shared error mapping that +//! converts authz-gate and body-rejection errors into RFC 9457 `Problem` +//! responses (rendered by `toolkit::api::canonical_error_middleware`). + +pub mod adjustments; +pub mod approvals; +pub mod audit; +pub(crate) mod auth_context; +pub(crate) mod canonical_json; +pub mod closure; +pub mod control; +pub mod credit; +pub mod disputes; +pub mod dto; +pub mod error; +pub mod exceptions; +pub mod fx; +pub mod fx_revaluation_mode; +pub mod journal_entries; +pub mod payers; +pub mod payments; +pub mod posting_policy; +pub mod provisioning; +pub mod recognition; +pub mod reconciliation; +pub mod refunds; diff --git a/gears/bss/ledger/ledger/src/api/rest/adjustments.rs b/gears/bss/ledger/ledger/src/api/rest/adjustments.rs new file mode 100644 index 000000000..c8023366a --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/adjustments.rs @@ -0,0 +1,854 @@ +//! Axum handlers + router for the ledger's adjustment (credit-note / debit-note / +//! exposure) REST surface (Slice 3, design §4.2 / §4.3 / §4.7 / §5, Group E). +//! Three operations under `/bss-ledger/v1`, tenant-scoped WITHOUT a tenant in the +//! path (the vhp-core convention, matching the payment / dispute / recognition +//! surfaces): the writes carry `tenant_id` in the **body**, the exposure read +//! takes the invoice id in the path + the owning seller `tenant_id` in the query. +//! +//! - `POST /credit-notes` — post a compensating credit note against a posted +//! invoice (DR `CONTRA_REVENUE`/`GOODWILL` + per-stream DR `CONTRACT_LIABILITY` + +//! DR `TAX_PAYABLE`; CR `AR` capped at open AR + CR `REUSABLE_CREDIT` remainder). +//! Idempotent on `credit_note_id`: a re-post replays (`200`), a fresh post is +//! `201`. Rejected `CREDIT_NOTE_SPLIT_AMBIGUOUS` (indeterminable split) / +//! `CREDIT_NOTE_EXCEEDS_HEADROOM` (over the invoice's headroom cap). +//! - `POST /debit-notes` — post an additional charge (DIRECT split: DR `AR` / +//! CR `REVENUE` / CR `CONTRACT_LIABILITY` / CR `TAX_PAYABLE`), building the +//! releasing schedule when it defers (D4) and **raising** the invoice's +//! headroom. Idempotent on `debit_note_id`: re-post `200`, fresh `201`. A closed +//! payer is rejected `PAYER_CLOSED`. +//! - `GET /invoices/{invoice_id}/exposure` — read the invoice's remaining +//! credit-note headroom (`original + debit − credit`) + its true remaining open +//! AR. `404` when no note has touched the invoice yet (no exposure row) or it is +//! outside the caller's subtree (no existence leak). +//! - `POST /manual-adjustments` — post a GOVERNED manual adjustment (design §4.6): +//! the ledger's escape hatch for corrections the typed flows do not cover +//! (rounding residue, suspense / cash-clearing clean-up). The body's `action` +//! selects a code-owned allow-list of account classes; `REVENUE` / +//! `CONTRACT_LIABILITY` are off-limits and an unpaired `CONTRA_REVENUE` leg is +//! rejected as an attempted write-off (`400 MANUAL_ADJUSTMENT_NOT_ALLOWED`, +//! additionally captured + paged on the write-off path). A `reason_code` is +//! mandatory; the preparer is the AUTHENTICATED subject (never the body). A +//! governed adjustment whose gross (`Σ DR`) crosses the tenant's D2 threshold +//! routes to dual-control (`409 DUAL_CONTROL_REQUIRED`, via the canonical-error +//! ladder). Idempotent on `adjustment_id`: re-post `200`, fresh `201`. +//! +//! The three writes (credit note / debit note / manual adjustment) authorize under +//! `(entry, post)` — the SAME data-plane post action the recognition-run / +//! invoice-post writes use (each posts a balanced journal entry into the seller's +//! ledger); the exposure read under `(entry, read)` — the SAME action the balance / +//! schedule reads use (the exposure counters are drawn down from the `entry` +//! ledger). The credit / debit / manual handlers are concrete orchestrators (not +//! behind `LedgerClientV1`), so the `ApiState` carries them directly +//! (`Arc` / `Arc` / +//! `Arc`) + the `AdjustmentRepo` for the exposure read — +//! they re-gate nothing internally, so the handler-layer PEP gate is the authority +//! and threads the compiled scope into the post (the SQL-level BOLA filter), +//! mirroring `journal_entries::post_invoice`. The manual handler is the GATED +//! instance (dual-control over D2 → 409); the executor's un-gated replay handler is +//! a SEPARATE instance wired in `module`. +//! +//! The routes register through `OperationBuilder` so `/openapi.json` lists each +//! operation with its declared request / response schemas. Mirrors +//! [`crate::api::rest::recognition::router`]. +//! +//! (Events / metrics emit — `billing.ledger.credit_note.posted`, +//! `ledger_credit_note_total{,_blocked}` / `ledger_debit_note_total` — are wired in +//! Group F; this Group-E surface emits none yet.) + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::odata::OData; +use toolkit::api::operation_builder::OperationBuilderODataExt; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_odata::Page; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::local_client::map_odata_page_err; +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{ + CreditNoteRequest, CreditNoteResponse, CreditNoteView, DebitNoteRequest, DebitNoteResponse, + DebitNoteView, InvoiceExposureResponse, ManualAdjustmentRequest, ManualAdjustmentResponse, +}; +use crate::api::rest::error::{ + authz_error_to_canonical, credit_note_not_found, debit_note_not_found, + invoice_exposure_not_found, +}; +use crate::infra::adjustment::credit_note_service::CreditNoteHandler; +use crate::infra::adjustment::debit_note_service::DebitNoteHandler; +use crate::infra::adjustment::manual_adjustment_service::ManualAdjustmentHandler; +use crate::infra::storage::repo::AdjustmentRepo; +use crate::odata::{CreditNoteFilterField, DebitNoteFilterField}; + +/// `OpenAPI` tag applied to the adjustment operations. +const TAG: &str = "BSS Ledger Adjustments"; + +/// Shared per-request state for the adjustment routes. Constructed once at +/// `init()` and shared via `Extension>`. Carries the two concrete +/// in-transaction orchestrators (a credit/debit note posts through them) + the +/// `AdjustmentRepo` for the exposure read. Unlike the payment / recognition state +/// (which fronts the in-process `LedgerClientV1`), these handlers are concrete and +/// re-gate nothing — the handler-layer PEP gate is the authority. +#[derive(Clone)] +pub struct ApiState { + /// The credit-note orchestrator (posts the compensating entry + schedule + /// reduction + headroom bump + `credit_note` row, all in one txn). + pub credit: Arc, + /// The debit-note orchestrator (posts the direct-split charge + schedule build + /// + headroom raise + `debit_note` row, all in one txn). + pub debit: Arc, + /// The GATED governed manual-adjustment orchestrator (design §4.6): runs the pure + /// `govern` gate, the payer gate, the dual-control gate (over D2 → 409), and posts + /// the balanced legs + the in-txn event sidecar. A SEPARATE instance from the + /// executor's un-gated replay handler (which must never re-gate), mirroring the + /// refund surface's gated/un-gated split. + pub manual: Arc, + /// The adjustment repo — the `GET …/exposure` read source (the + /// `invoice_exposure` headroom row + the invoice's open AR). + pub exposure_repo: AdjustmentRepo, +} + +/// Build the Axum router for the adjustment surface and register every operation +/// with the supplied `OpenAPI` registry. `state` is attached via an `Extension` +/// layer at the end so the registry sees the route definitions before the +/// per-request state is bound. Mirrors [`crate::api::rest::recognition::router`]. +#[allow(clippy::too_many_lines)] // one OperationBuilder chain per route; splitting hurts readability +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/credit-notes") + .operation_id("bss_ledger.post_credit_note") + .summary("Post a credit note (compensating adjustment)") + .description( + "Posts a balanced compensating credit note against a posted invoice \ + for the seller named by the body's `tenant_id` (DR CONTRA_REVENUE — \ + or GOODWILL for an AR-only goodwill credit — + per-stream DR \ + CONTRACT_LIABILITY over the unreleased deferred + DR TAX_PAYABLE; CR \ + AR capped at the invoice's current open AR + CR REUSABLE_CREDIT for \ + any paid-invoice remainder, K-2). It never mutates the posted invoice \ + rows and, in the SAME txn, reduces the owning recognition schedule's \ + deferred total (so a later S6 run cannot re-recognize the credited- \ + back amount) and bumps the invoice's credit-note headroom counter. \ + `requested_deferred_minor` is the split INTENT (how much targets the \ + deferred balance). Idempotent on `credit_note_id`: a re-post returns \ + the prior posting reference (200) instead of a new one (201). Rejected \ + when the recognized-vs-deferred split is indeterminable \ + (CREDIT_NOTE_SPLIT_AMBIGUOUS — never a silent pro-rata) or the note \ + would push past the invoice's headroom \ + (CREDIT_NOTE_EXCEEDS_HEADROOM — route over-cap via goodwill/non- \ + revenue, never silently through S3).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The credit note (amounts incl-tax, split intent, goodwill flag) + the \ + idempotency `credit_note_id`.", + ) + .handler(post_credit_note) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference (201 fresh post / 200 idempotent replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior credit note", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/debit-notes") + .operation_id("bss_ledger.post_debit_note") + .summary("Post a debit note (additional charge)") + .description( + "Posts an additional charge against a posted invoice for the seller \ + named by the body's `tenant_id` — a DIRECT split mirroring the \ + invoice-post (DR AR incl-tax / CR REVENUE recognized-now / CR \ + CONTRACT_LIABILITY deferred per PO / CR TAX_PAYABLE). When the note \ + defers (`deferred_minor > 0`) it builds the releasing recognition \ + schedule in the SAME txn (D4 — the `recognition` spec is required) so \ + a later S6 run can release it, and it RAISES the invoice's headroom \ + (`debit_note_total += amount`), widening the room for later credit \ + notes (it can never trip the headroom cap). Idempotent on \ + `debit_note_id`: a re-post returns the prior posting reference (200) \ + instead of a new one (201). A closed payer cannot be charged \ + (PAYER_CLOSED).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The debit note (amount incl-tax, deferred part + recognition spec) + \ + the idempotency `debit_note_id`.", + ) + .handler(post_debit_note) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference (201 fresh post / 200 idempotent replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior debit note", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/manual-adjustments") + .operation_id("bss_ledger.post_manual_adjustment") + .summary("Post a governed manual adjustment (correction escape hatch)") + .description( + "Posts a GOVERNED manual adjustment for the seller named by the body's \ + `tenant_id` (design §4.6) — the ledger's escape hatch for corrections \ + the typed flows (invoice / settle / allocate / S3 notes / S4 \ + recognition) do not cover: rounding residue, suspense / cash-clearing \ + clean-up. The `action` selects a CODE-OWNED allow-list of account \ + classes the legs may touch; `REVENUE` and `CONTRACT_LIABILITY` are \ + globally off-limits (revenue changes route through S3/S4/S6) and an \ + unpaired `CONTRA_REVENUE` leg is rejected as an attempted (disguised \ + bad-debt) write-off — all `MANUAL_ADJUSTMENT_NOT_ALLOWED` (400), the \ + write-off additionally captured + paged. The legs MUST net to zero \ + (Σ DR == Σ CR) and a `reason_code` is mandatory (AC #14). The preparer \ + actor is the AUTHENTICATED subject (stamped server-side, never read from \ + the body); the approver is assigned by the dual-control flow. A governed \ + adjustment whose gross (Σ DR) crosses the tenant's D2 threshold routes \ + to dual-control (409 DUAL_CONTROL_REQUIRED) instead of posting inline. \ + Idempotent on `adjustment_id` (the `(tenant, MANUAL_ADJUSTMENT, \ + adjustment_id)` engine claim): a re-post returns the prior posting \ + reference (200) instead of a new one (201).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The governed adjustment (action, balanced legs, reason) + the \ + idempotency `adjustment_id`.", + ) + .handler(post_manual_adjustment) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference (201 fresh post / 200 idempotent replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior manual adjustment", + ) + // The over-D2 dual-control path (409 DUAL_CONTROL_REQUIRED) flows through the + // `From for CanonicalError` ladder (`DualControlRequired` → + // `aborted`), like the refund surface — the platform `OperationBuilder` has no + // `.error_409` helper, so it is not declared here (the 409 still returns). + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/invoices/{invoice_id}/exposure") + .operation_id("bss_ledger.get_invoice_exposure") + .summary("Read an invoice's credit-note headroom + remaining AR") + .description( + "Returns the invoice's credit-note HEADROOM (the `invoice_exposure` \ + counter: `original_total` seeded = posted AR incl-tax, plus the \ + running debit-note / credit-note totals, with \ + `remaining_headroom = original + debit − credit`, the slack in the AC \ + #24 CHECK) plus its TRUE remaining open AR (the payment-reduced \ + receivable a credit note's `CR AR` leg is capped at — SEPARATE from \ + the headroom, which never decreases with payments). The schedule PK is \ + `(tenant_id, invoice_id)`, so the owning seller `tenant_id` is required \ + in the query (like the recognition-schedule read). Tenant-scoped \ + (SQL-level BOLA): an invoice with no note posted yet (no exposure row) \ + — or one outside the caller's authorized subtree — yields a 404 (no \ + existence leak).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("invoice_id", "The posted invoice whose exposure to read.") + .query_param( + "tenant_id", + true, + "The invoice's owning seller tenant (the exposure PK's tenant half).", + ) + .handler(get_invoice_exposure) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The invoice's headroom counters + remaining open AR", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/credit-notes/{credit_note_id}") + .operation_id("bss_ledger.get_credit_note") + .summary("Read a recorded credit note") + .description( + "Returns the recorded credit note for `(tenant_id, credit_note_id)` — \ + its origin invoice (+ item ref), revenue stream, currency, incl-tax \ + amount, and the ex-tax recognized/deferred split parts (which do NOT \ + sum to the amount). The PK is `(tenant_id, credit_note_id)`, so the \ + owning seller `tenant_id` is required in the query. Tenant-scoped \ + (SQL-level BOLA): an unknown credit note — or one outside the caller's \ + authorized subtree — yields a 404 (no existence leak). Mirrors \ + `get_refund`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("credit_note_id", "The credit note whose record to read.") + .query_param( + "tenant_id", + true, + "The credit note's owning seller tenant (the credit_note PK's tenant half).", + ) + .handler(get_credit_note) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The recorded credit note", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/credit-notes") + .operation_id("bss_ledger.list_credit_notes") + .summary("List recorded credit notes (cursor-paginated)") + .description( + "Cursor-paginated list of the recorded credit notes for the `tenant_id` \ + query (the caller's own by default). Supports OData `$filter` over \ + `origin_invoice_id`, `revenue_stream`, and `reason_code`. The `$filter` \ + ANDs the caller's authorized subtree, so credit notes outside it are \ + never returned (SQL-level BOLA). Each item is the same `CreditNoteView` \ + the by-id read returns. Mirrors `list_refunds`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "The credit notes' owning seller tenant (defaults to the caller's own).", + ) + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_credit_notes) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of recorded credit notes", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/debit-notes/{debit_note_id}") + .operation_id("bss_ledger.get_debit_note") + .summary("Read a recorded debit note") + .description( + "Returns the recorded debit note (an additional charge) for `(tenant_id, \ + debit_note_id)` — its origin invoice, currency, incl-tax amount, and \ + the ex-tax recognized/deferred split parts (which do NOT sum to the \ + amount). The PK is `(tenant_id, debit_note_id)`, so the owning seller \ + `tenant_id` is required in the query. Tenant-scoped (SQL-level BOLA): an \ + unknown debit note — or one outside the caller's authorized subtree — \ + yields a 404 (no existence leak). Mirrors `get_credit_note`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("debit_note_id", "The debit note whose record to read.") + .query_param( + "tenant_id", + true, + "The debit note's owning seller tenant (the debit_note PK's tenant half).", + ) + .handler(get_debit_note) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The recorded debit note", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/debit-notes") + .operation_id("bss_ledger.list_debit_notes") + .summary("List recorded debit notes (cursor-paginated)") + .description( + "Cursor-paginated list of the recorded debit notes for the `tenant_id` \ + query (the caller's own by default). Supports OData `$filter` over \ + `origin_invoice_id`. The `$filter` ANDs the caller's authorized \ + subtree, so debit notes outside it are never returned (SQL-level BOLA). \ + Each item is the same `DebitNoteView` the by-id read returns. Mirrors \ + `list_credit_notes`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "The debit notes' owning seller tenant (defaults to the caller's own).", + ) + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_debit_notes) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of recorded debit notes", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) BEFORE +// the in-handler `require_authenticated` gate, so a malformed body yields 400 even +// for an unauthenticated caller (standard axum extractor ordering; no +// authenticated-only data is disclosed). Mirrors `journal_entries::post_invoice`. +async fn post_credit_note( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id` (tenant in body, not path). + let tenant_id = body.tenant_id; + // (entry, post) PEP gate against the TARGET tenant: a credit note posts a + // compensating journal entry into the seller's ledger (the SAME data-plane post + // action as the run trigger / invoice post). A target outside the caller's + // scope is a cross-tenant write and is denied. The returned scope threads into + // the scoped post (the SQL-level BOLA filter); the handler re-gates nothing. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::POST, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let req = body.into_domain().map_err(CanonicalError::from)?; + // The split-ambiguous (CREDIT_NOTE_SPLIT_AMBIGUOUS) + over-headroom + // (CREDIT_NOTE_EXCEEDS_HEADROOM) rejections flow through the single + // `From for CanonicalError` ladder (`infra::error_mapping`) via + // `?` — no REST-layer mapping needed. + let reference = state + .credit + .post_credit_note(&ctx, &scope, req) + .await + .map_err(CanonicalError::from)?; + Ok(credit_note_response(reference)) +} + +async fn post_debit_note( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = body.tenant_id; + // (entry, post) PEP gate against the TARGET tenant — same as the credit note. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::POST, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let req = body.into_domain().map_err(CanonicalError::from)?; + // `payer_open = true` at the REST seam: the gear has no payer-state reader here, + // and the foundation account-lifecycle invariant is the authority for a + // genuinely closed payer (a closed AR account is rejected at post time) — + // byte-identical to `journal_entries::post_invoice`'s `payer_open = true` seam. + // The gear-side fast-path payer gate (`!payer_open ⇒ PAYER_CLOSED`) is exercised + // by the Group D integration test driving `post_debit_note(..., false)` directly. + let reference = state + .debit + .post_debit_note(&ctx, &scope, req, /* payer_open */ true) + .await + .map_err(CanonicalError::from)?; + Ok(debit_note_response(reference)) +} + +async fn post_manual_adjustment( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = body.tenant_id; + // (entry, post) PEP gate against the TARGET tenant — same as the credit / debit + // note: a governed manual adjustment posts a balanced journal entry into the + // seller's ledger. A target outside the caller's scope is a cross-tenant write + // and is denied. The returned scope threads into the scoped post (the SQL-level + // BOLA filter); the handler re-gates nothing. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::POST, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // The preparer is the AUTHENTICATED subject (never the body); the approver is + // assigned by the dual-control flow (into_domain sets it `None`). + let req = body + .into_domain(ctx.subject_id()) + .map_err(CanonicalError::from)?; + // The governance reject (MANUAL_ADJUSTMENT_NOT_ALLOWED → 400) and the over-D2 + // dual-control route (DUAL_CONTROL_REQUIRED → 409) both flow through the single + // `From for CanonicalError` ladder (`infra::error_mapping`) via `?`. + let reference = state + .manual + .post_manual_adjustment(&ctx, &scope, req) + .await + .map_err(CanonicalError::from)?; + Ok(manual_adjustment_response(reference)) +} + +/// A credit/debit-note posting rendered with the right status: `201 Created` for a +/// fresh post, `200 OK` for an idempotent replay of a prior note. Mirrors +/// `journal_entries::posting_response`. +fn credit_note_response(reference: bss_ledger_sdk::PostingRef) -> Response { + let status = if reference.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + (status, Json(CreditNoteResponse::from(reference))).into_response() +} + +/// As [`credit_note_response`] but for the debit-note body. +fn debit_note_response(reference: bss_ledger_sdk::PostingRef) -> Response { + let status = if reference.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + (status, Json(DebitNoteResponse::from(reference))).into_response() +} + +/// As [`credit_note_response`] but for the manual-adjustment body: `201 Created` +/// for a fresh governed post, `200 OK` for an idempotent replay of a prior +/// adjustment (the dual-control 409 flows through the `From` ladder). +fn manual_adjustment_response(reference: bss_ledger_sdk::PostingRef) -> Response { + let status = if reference.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + (status, Json(ManualAdjustmentResponse::from(reference))).into_response() +} + +/// `GET /invoices/{invoice_id}/exposure` query parameters: the invoice's owning +/// seller `tenant_id` (the exposure PK is `(tenant_id, invoice_id)`, so the tenant +/// is REQUIRED in the query — like the by-id recognition-schedule read). +#[derive(Debug, serde::Deserialize)] +struct ExposureQuery { + tenant_id: Uuid, +} + +async fn get_invoice_exposure( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(invoice_id): Path, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id; + // (entry, read) PEP gate against the invoice's owning tenant — the SAME action + // the balance / schedule reads run under (the exposure counters are drawn down + // from the `entry` ledger). The returned scope is the SQL-level BOLA filter the + // repo binds, so a foreign-tenant invoice resolves to None ⇒ 404 (no existence + // leak), mirroring `recognition::get_recognition_schedule`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // The headroom row: `None` ⇒ absent (no note posted on this invoice yet) OR + // scoped-out — a canonical 404 either way (no existence leak), mirroring + // `recognition_schedule_not_found`. + let exposure = state + .exposure_repo + .read_exposure_out_of_txn(&scope, tenant_id, &invoice_id) + .await + // A scoped read failure is an infra fault ⇒ Internal (→ 500), mirroring how + // `CreditNoteHandler::read_ar_caps` maps its repo read errors. There is no + // `From for DomainError`, so map explicitly. + .map_err(|e| crate::domain::error::DomainError::Internal(format!("read exposure: {e}")))? + .ok_or_else(|| invoice_exposure_not_found(&invoice_id))?; + // The SEPARATE true remaining open AR (the `CR AR` cap). Scoped by the same + // gate; an invoice with no open AR reads 0 (a fully-paid / fully-credited + // invoice still has a headroom row). + let open_ar = state + .exposure_repo + .read_open_ar_for_invoice_out_of_txn(&scope, tenant_id, &invoice_id) + .await + .map_err(|e| crate::domain::error::DomainError::Internal(format!("read open AR: {e}")))?; + + // Remaining headroom = original + debit − credit (the slack in the AC #24 + // CHECK), floored at 0 (the CHECK guarantees `credit <= original + debit`, so + // this never goes negative; the saturating sub is defensive). + let remaining_headroom_minor = exposure + .original_total_minor + .saturating_add(exposure.debit_note_total_minor) + .saturating_sub(exposure.credit_note_total_minor) + .max(0); + Ok(Json(InvoiceExposureResponse { + invoice_id: exposure.invoice_id, + currency: exposure.currency, + original_total_minor: exposure.original_total_minor, + debit_note_total_minor: exposure.debit_note_total_minor, + credit_note_total_minor: exposure.credit_note_total_minor, + remaining_headroom_minor, + open_ar_minor: open_ar, + })) +} + +/// `GET /credit-notes/{credit_note_id}` query parameters: the credit note's owning +/// seller `tenant_id` (the `credit_note` PK is `(tenant_id, credit_note_id)`, so the +/// tenant is REQUIRED in the query — like the by-id refund / exposure reads). +#[derive(Debug, serde::Deserialize)] +struct CreditNoteQuery { + tenant_id: Uuid, +} + +async fn get_credit_note( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(credit_note_id): Path, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id; + // (entry, read) PEP gate against the credit note's owning tenant — the SAME + // action the exposure / balance reads run under. The returned scope is the + // SQL-level BOLA filter the repo binds, so a foreign-tenant credit note resolves + // to None ⇒ 404 (no existence leak), mirroring `refunds::get_refund`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let note = state + .exposure_repo + .read_credit_note_out_of_txn(&scope, tenant_id, &credit_note_id) + .await + .map_err(|e| crate::domain::error::DomainError::Internal(format!("read credit_note: {e}")))? + .ok_or_else(|| credit_note_not_found(&credit_note_id))?; + Ok(Json(CreditNoteView::from(note))) +} + +/// `GET /credit-notes` non-OData query: the credit notes' owning tenant (the +/// caller's own when omitted). The `OData` `$filter` / `$orderby` / `limit` / +/// `cursor` are parsed separately by the `OData` extractor from the same query +/// string; `tenant_id` stays a plain param alongside them (the list convention). +#[derive(Debug, serde::Deserialize)] +struct CreditNoteListQuery { + tenant_id: Option, +} + +async fn list_credit_notes( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + // (entry, read) PEP gate against the credit notes' owning tenant — the SAME + // action the by-id read / balances run under. The returned scope is the + // SQL-level BOLA filter the repo binds, so the page never contains a + // foreign-tenant credit note (no existence leak), mirroring `refunds::list_refunds`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let page = state + .exposure_repo + .list_credit_notes(&scope, tenant_id, &odata) + .await + .map_err(map_odata_page_err)?; + Ok(Json(Page { + items: page.items.into_iter().map(CreditNoteView::from).collect(), + page_info: page.page_info, + })) +} + +/// `GET /debit-notes/{debit_note_id}` query parameters: the debit note's owning +/// seller `tenant_id` (the `debit_note` PK is `(tenant_id, debit_note_id)`, so the +/// tenant is REQUIRED in the query — like the by-id credit-note read). +#[derive(Debug, serde::Deserialize)] +struct DebitNoteQuery { + tenant_id: Uuid, +} + +async fn get_debit_note( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(debit_note_id): Path, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id; + // (entry, read) PEP gate against the debit note's owning tenant — the SAME + // action the exposure / balance reads run under. The returned scope is the + // SQL-level BOLA filter the repo binds, so a foreign-tenant debit note resolves + // to None ⇒ 404 (no existence leak), mirroring `get_credit_note`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let note = state + .exposure_repo + .read_debit_note_out_of_txn(&scope, tenant_id, &debit_note_id) + .await + .map_err(|e| crate::domain::error::DomainError::Internal(format!("read debit_note: {e}")))? + .ok_or_else(|| debit_note_not_found(&debit_note_id))?; + Ok(Json(DebitNoteView::from(note))) +} + +/// `GET /debit-notes` non-OData query: the debit notes' owning tenant (the +/// caller's own when omitted). The `OData` `$filter` / `$orderby` / `limit` / +/// `cursor` are parsed separately by the `OData` extractor from the same query +/// string; `tenant_id` stays a plain param alongside them (the list convention). +#[derive(Debug, serde::Deserialize)] +struct DebitNoteListQuery { + tenant_id: Option, +} + +async fn list_debit_notes( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + // (entry, read) PEP gate against the debit notes' owning tenant — the SAME + // action the by-id read / balances run under. The returned scope is the + // SQL-level BOLA filter the repo binds, so the page never contains a + // foreign-tenant debit note (no existence leak), mirroring `list_credit_notes`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let page = state + .exposure_repo + .list_debit_notes(&scope, tenant_id, &odata) + .await + .map_err(map_odata_page_err)?; + Ok(Json(Page { + items: page.items.into_iter().map(DebitNoteView::from).collect(), + page_info: page.page_info, + })) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/approvals.rs b/gears/bss/ledger/ledger/src/api/rest/approvals.rs new file mode 100644 index 000000000..9712e9d98 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/approvals.rs @@ -0,0 +1,800 @@ +//! Axum handlers + router for the dual-control approval queue (VHP-1852, Group G). +//! All operations live under `/bss-ledger/v1/approvals`, tenant-scoped to the +//! caller's own tenant (no tenant in the path/body). The decision routes +//! (approve / reject / request-changes / get / list) gate on the `(entry, approve)` +//! PEP permission (`entry_approve.v1`); the preparer routes (resubmit / cancel / +//! comments) follow impl-design §6 — "the preparer's originating right" — resolving +//! the `(entry, read)` plane for a non-approver and enforcing `actor == prepared_by` +//! server-side (the `preparer ≠ approver` rule lives in [`ApprovalService`]). The +//! over-threshold mutations that CREATE pending approvals are the retrofit gates in +//! the journal-entry / credit / dispute surfaces. + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use chrono::{DateTime, Utc}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_db::secure::AccessScope; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::DualControlPolicyView; +use crate::api::rest::error::authz_error_to_canonical; +use crate::domain::approval::intent::ApprovalIntent; +use crate::domain::error::DomainError; +use crate::infra::approval::service::ApprovalService; +use crate::infra::storage::entity::{dual_control_approval, dual_control_comment}; + +/// `OpenAPI` tag applied to the approval operations. +const TAG: &str = "BSS Ledger Approvals"; + +/// Shared per-request state for the approval routes. Constructed once at `init()` +/// and shared via `Extension>`. +#[derive(Clone)] +pub struct ApiState { + /// The dual-control lifecycle engine. + pub service: Arc, +} + +// ─── DTOs ──────────────────────────────────────────────────────────────────── + +/// A dual-control approval rendered for the queue / detail views. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ApprovalDto { + pub approval_id: Uuid, + pub kind: String, + pub state: String, + pub revision: i32, + pub business_key: String, + pub reason_code: String, + pub prepared_by: Uuid, + pub prepared_at: DateTime, + pub approved_by: Option, + pub decided_at: Option>, + pub expires_at: DateTime, + pub amount_usd_eq_minor: Option, +} + +impl From for ApprovalDto { + fn from(m: dual_control_approval::Model) -> Self { + Self { + approval_id: m.approval_id, + kind: m.kind, + state: m.state, + revision: m.revision, + business_key: m.business_key, + reason_code: m.reason_code, + prepared_by: m.prepared_by, + prepared_at: m.prepared_at, + approved_by: m.approved_by, + decided_at: m.decided_at, + expires_at: m.expires_at, + amount_usd_eq_minor: m.amount_usd_eq_minor, + } + } +} + +/// One comment / decision-reason on an approval's thread. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ApprovalCommentDto { + pub comment_id: Uuid, + pub revision: i32, + pub author_actor: Uuid, + pub body: String, + pub created_at: DateTime, +} + +impl From for ApprovalCommentDto { + fn from(m: dual_control_comment::Model) -> Self { + Self { + comment_id: m.comment_id, + revision: m.revision, + author_actor: m.author_actor, + body: m.body, + created_at: m.created_at, + } + } +} + +/// The approval queue list. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ApprovalListResponse { + pub approvals: Vec, +} + +/// An approval's comment thread. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ApprovalThreadResponse { + pub comments: Vec, +} + +/// A mandatory-reason decision body (`reject` / `request-changes`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ReasonRequest { + pub reason: String, +} + +/// A free comment / question body. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct CommentRequest { + pub body: String, +} + +/// A resubmit body carrying the preparer's edited intent (the stored `intent` +/// jsonb shape). The kind cannot change; the approval returns to `PENDING`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ResubmitRequest { + pub intent: serde_json::Value, +} + +/// Optional `state` / `kind` filters for the queue list (query params). +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ListQuery { + pub state: Option, + pub kind: Option, +} + +/// A new dual-control threshold policy version to write (DC8). `effective_from` +/// defaults to now when omitted; out-of-range D2/A6/TTL is rejected (409, no clamp). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct SetDualControlPolicyRequest { + pub d2_threshold_minor: i64, + pub a6_backdating_biz_days: i32, + pub pending_ttl_seconds: i64, + pub effective_from: Option>, +} + +/// The written dual-control policy version (the minted `version` + the thresholds +/// it carries; the resolver picks the latest `effective_from`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct DualControlPolicyResponse { + pub version: i64, + pub effective_from: DateTime, + pub d2_threshold_minor: i64, + pub a6_backdating_biz_days: i32, + pub pending_ttl_seconds: i64, +} + +/// The `?tenant_id=` query for `GET /dual-control-policy` (read-surface): the +/// tenant whose effective policy to read — the caller's own when omitted. +#[derive(Debug, serde::Deserialize)] +struct PolicyQuery { + tenant_id: Option, +} + +// ─── Router ────────────────────────────────────────────────────────────────── + +/// Build the Axum router for the approval surface and register every operation +/// with the supplied `OpenAPI` registry. +#[allow(clippy::too_many_lines)] // one builder chain per operation; flat is clearer than helpers +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/approvals/{approval_id}/approve") + .operation_id("bss_ledger.approve_approval") + .summary("Approve a pending dual-control approval") + .description( + "Approves the PENDING approval `{approval_id}`: executes the stored \ + mutation idempotently, then marks APPROVED with a same-transaction \ + decision audit. The approver MUST differ from the preparer \ + (409 SELF_APPROVAL_FORBIDDEN).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("approval_id", "The approval being decided.") + .handler(approve) + .json_response_with_schema::(openapi, StatusCode::OK, "The approved approval.") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/approvals/{approval_id}/reject") + .operation_id("bss_ledger.reject_approval") + .summary("Reject a pending dual-control approval") + .description( + "Rejects the PENDING approval with a mandatory reason; the mutation never runs.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("approval_id", "The approval being decided.") + .json_request::(openapi, "The mandatory rejection reason.") + .handler(reject) + .json_response_with_schema::(openapi, StatusCode::OK, "The rejected approval.") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/approvals/{approval_id}/request-changes") + .operation_id("bss_ledger.request_changes_approval") + .summary("Return a pending approval to the preparer for rework") + .description( + "Returns the PENDING approval to the preparer (NEEDS_REWORK) with a \ + mandatory reason; the preparer edits the intent and resubmits.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("approval_id", "The approval being returned.") + .json_request::(openapi, "The mandatory rework reason.") + .handler(request_changes) + .json_response_with_schema::(openapi, StatusCode::OK, "The returned approval.") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/approvals/{approval_id}/resubmit") + .operation_id("bss_ledger.resubmit_approval") + .summary("Resubmit a returned approval with an edited intent") + .description( + "Preparer-only: edits the intent of a NEEDS_REWORK approval and returns \ + it to PENDING (kind cannot change). The approval is never auto-applied.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("approval_id", "The approval being resubmitted.") + .json_request::(openapi, "The edited intent.") + .handler(resubmit) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The resubmitted approval.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/approvals/{approval_id}/cancel") + .operation_id("bss_ledger.cancel_approval") + .summary("Cancel an active approval (preparer only)") + .description("Preparer-only: withdraws an active (PENDING/NEEDS_REWORK) approval.") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("approval_id", "The approval being cancelled.") + .handler(cancel) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The cancelled approval.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/approvals/{approval_id}/comments") + .operation_id("bss_ledger.comment_approval") + .summary("Add a comment / question to an approval thread") + .description( + "Appends a free comment (no state change) to the approval's append-only thread.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("approval_id", "The approval being commented on.") + .json_request::(openapi, "The comment body.") + .handler(add_comment) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "The updated thread.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/approvals/{approval_id}/comments") + .operation_id("bss_ledger.list_approval_comments") + .summary("Read an approval's comment thread") + .description("Returns the approval's comment thread, oldest-first.") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("approval_id", "The approval whose thread to read.") + .handler(thread) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The comment thread.", + ) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/approvals/{approval_id}") + .operation_id("bss_ledger.get_approval") + .summary("Read a single approval") + .description("Returns the approval `{approval_id}` for the caller's tenant.") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("approval_id", "The approval to read.") + .handler(get) + .json_response_with_schema::(openapi, StatusCode::OK, "The approval.") + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/approvals") + .operation_id("bss_ledger.list_approvals") + .summary("List the dual-control approval queue") + .description("Lists the caller tenant's approvals (newest-first), optionally filtered by state / kind.") + .tag(TAG) + .authenticated() + .no_license_required() + .handler(list) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The approval queue.", + ) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/dual-control-policy") + .operation_id("bss_ledger.set_dual_control_policy") + .summary("Set the tenant dual-control threshold policy") + .description( + "Writes a new effective-dated dual-control threshold version (the D2 \ + amount threshold, the A6 backdating window, the pending TTL) for the \ + caller's tenant (DC8). Append-only: a new version supersedes; the \ + resolver picks the latest effective_from (highest version on a tie). \ + Out-of-range D2/A6/TTL is rejected (409 DUAL_CONTROL_POLICY_OUT_OF_RANGE), \ + never clamped. Requires `dual_control_policy_write.v1`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "The threshold version to write.") + .handler(set_policy) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The written policy version.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/dual-control-policy") + .operation_id("bss_ledger.get_dual_control_policy") + .summary("Read the effective dual-control threshold policy") + .description( + "Returns the tenant's EFFECTIVE dual-control threshold policy (read-\ + surface): the version in force now (the latest `effective_from <= now`, \ + highest `version` on a tie) — its D2 amount threshold, A6 backdating \ + window, and pending TTL — or the ratified platform defaults when the \ + tenant has set no policy row (`is_default = true`, with `version` / \ + `effective_from` null). `tenant_id` defaults to the caller's own. Gates \ + on `(dual_control_policy, read)` — the policy is its OWN resource (a \ + governance-officer read; its writer gates on `dual_control_policy.write`), \ + not an `entry` data-plane read; tenant-scoped (SQL-level BOLA) so a tenant \ + outside the caller's subtree reads the platform defaults (the thresholds \ + are public constants — no existence/value leak). Always `200`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Tenant whose effective policy to read (the caller's own by default)", + ) + .handler(get_policy) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The effective dual-control threshold policy (configured version or platform defaults).", + ) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +// ─── Handlers ────────────────────────────────────────────────────────────── + +/// `(entry, approve)` PEP gate against the caller's own tenant; returns the +/// compiled scope every approval operation runs under. +async fn approve_scope( + enforcer: &authz_resolver_sdk::PolicyEnforcer, + ctx: &SecurityContext, +) -> Result { + crate::authz::access_scope( + enforcer, + ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::APPROVE, + Some(ctx.subject_tenant_id()), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical) +} + +/// Resolve the caller's access to the approval surface. An `entry_approve.v1` +/// holder is an **approver** (`is_approver = true`) — may act on any approval in the +/// tenant. A caller who lacks it falls back to the `(entry, read)` plane as a +/// **preparer** (`is_approver = false`) — may act only on an approval they prepared, +/// and the per-route check then enforces `actor == prepared_by`. Mirrors impl-design +/// §6: resubmit/cancel are the preparer's originating right, +/// comments are `entry_approve.v1`-or-preparer. A PDP outage (`Unavailable`) +/// propagates; only an authorization `Denied` demotes to the preparer path. +async fn approval_access( + enforcer: &authz_resolver_sdk::PolicyEnforcer, + ctx: &SecurityContext, +) -> Result<(AccessScope, bool), CanonicalError> { + let tenant = ctx.subject_tenant_id(); + match crate::authz::access_scope( + enforcer, + ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::APPROVE, + Some(tenant), + None, + /* require_constraints */ true, + ) + .await + { + Ok(scope) => Ok((scope, true)), + Err(crate::authz::AuthzError::Denied(_)) => { + let scope = crate::authz::access_scope( + enforcer, + ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + Ok((scope, false)) + } + Err(e) => Err(authz_error_to_canonical(e)), + } +} + +/// Authorize a comment-thread route (`entry_approve.v1`-or-preparer): an approver +/// may touch any approval in the tenant; a preparer (non-approver) must have +/// prepared THIS one. Reads the row under the caller's (tenant-isolated) scope and +/// checks `prepared_by` — so a preparer cannot read or post on another preparer's +/// thread. +async fn authorize_thread_participant( + state: &ApiState, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, + is_approver: bool, +) -> Result<(), CanonicalError> { + if is_approver { + return Ok(()); + } + let row = state + .service + .get(ctx, scope, approval_id) + .await? + .ok_or_else(|| { + CanonicalError::from(DomainError::ApprovalNotFound(format!( + "approval {approval_id}" + ))) + })?; + if row.prepared_by != ctx.subject_id() { + return Err(CanonicalError::from(DomainError::ApprovalNotActionable( + "only the preparer or an entry_approve.v1 holder may participate in this \ + approval's comment thread" + .to_owned(), + ))); + } + Ok(()) +} + +/// `(dual_control_policy, write)` PEP gate against the caller's own tenant — the +/// policy's OWN resource (a governance-officer grant), distinct from `ledger` +/// provisioning and from `entry_approve` so a per-operation approver cannot rewrite +/// the thresholds that gate them. +async fn policy_scope( + enforcer: &authz_resolver_sdk::PolicyEnforcer, + ctx: &SecurityContext, +) -> Result { + crate::authz::access_scope( + enforcer, + ctx, + &crate::authz::resource_types::DUAL_CONTROL_POLICY, + crate::authz::actions::WRITE, + Some(ctx.subject_tenant_id()), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical) +} + +/// Write a new tenant dual-control threshold policy version (DC8). `200` with the +/// minted version; `409 DUAL_CONTROL_POLICY_OUT_OF_RANGE` on an out-of-range value. +async fn set_policy( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let scope = policy_scope(&enforcer, &ctx).await?; + let effective_from = body.effective_from.unwrap_or_else(Utc::now); + let version = state + .service + .set_policy( + &ctx, + &scope, + body.d2_threshold_minor, + body.a6_backdating_biz_days, + body.pending_ttl_seconds, + effective_from, + ) + .await?; + Ok(( + StatusCode::OK, + Json(DualControlPolicyResponse { + version, + effective_from, + d2_threshold_minor: body.d2_threshold_minor, + a6_backdating_biz_days: body.a6_backdating_biz_days, + pending_ttl_seconds: body.pending_ttl_seconds, + }), + ) + .into_response()) +} + +/// `GET /dual-control-policy` (read-surface): read the tenant's EFFECTIVE +/// dual-control threshold policy. Gates on `(dual_control_policy, read)` — the +/// policy is its OWN resource (a governance-officer read; its writer gates on +/// `dual_control_policy.write`), NOT an `entry` data-plane read — and binds the +/// compiled scope as the +/// SQL-level BOLA filter, so a tenant outside the caller's subtree reads as no +/// rows ⇒ the ratified platform defaults (the thresholds are public constants — no +/// existence/value leak). `tenant_id` defaults to the caller's own. Always `200`: +/// the effective policy is the configured version in force or, absent a row, the +/// ratified platform defaults. +async fn get_policy( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::DUAL_CONTROL_POLICY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let effective = state + .service + .read_effective_policy(&scope, tenant_id, Utc::now()) + .await?; + Ok(Json(DualControlPolicyView::from_effective(effective))) +} + +/// Re-read the approval after a state action and render it (`200`). +async fn approval_response( + state: &ApiState, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, +) -> Result { + let row = state + .service + .get(ctx, scope, approval_id) + .await? + .ok_or_else(|| { + CanonicalError::from(DomainError::ApprovalNotFound(format!( + "approval {approval_id}" + ))) + })?; + Ok((StatusCode::OK, Json(ApprovalDto::from(row))).into_response()) +} + +async fn approve( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(approval_id): Path, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let scope = approve_scope(&enforcer, &ctx).await?; + state.service.approve(&ctx, &scope, approval_id).await?; + approval_response(&state, &ctx, &scope, approval_id).await +} + +async fn reject( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(approval_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let scope = approve_scope(&enforcer, &ctx).await?; + state + .service + .reject(&ctx, &scope, approval_id, body.reason) + .await?; + approval_response(&state, &ctx, &scope, approval_id).await +} + +async fn request_changes( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(approval_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let scope = approve_scope(&enforcer, &ctx).await?; + state + .service + .request_changes(&ctx, &scope, approval_id, body.reason) + .await?; + approval_response(&state, &ctx, &scope, approval_id).await +} + +async fn resubmit( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(approval_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // resubmit is the preparer's originating right (impl-design §6): a non-approver + // resolves the read plane, and `ApprovalService::resubmit` enforces + // `actor == prepared_by`, so an approver who is not the preparer is rejected. + let (scope, _) = approval_access(&enforcer, &ctx).await?; + let intent: ApprovalIntent = serde_json::from_value(body.intent).map_err(|e| { + CanonicalError::from(DomainError::InvalidRequest(format!( + "resubmit intent is not a valid approval intent: {e}" + ))) + })?; + // The threshold snapshot is recomputed inside `resubmit` (DC17) against the + // edited intent + the policy in force — the handler no longer fabricates one. + state + .service + .resubmit(&ctx, &scope, approval_id, intent) + .await?; + approval_response(&state, &ctx, &scope, approval_id).await +} + +async fn cancel( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(approval_id): Path, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // cancel is the preparer's originating right (impl-design §6): the service + // enforces `actor == prepared_by`, so the read plane suffices for the gate. + let (scope, _) = approval_access(&enforcer, &ctx).await?; + state.service.cancel(&ctx, &scope, approval_id).await?; + approval_response(&state, &ctx, &scope, approval_id).await +} + +async fn add_comment( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(approval_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // Comments are "entry_approve.v1 OR preparer" (impl-design §6): an approver may + // post on any approval; a preparer only on one they prepared. + let (scope, is_approver) = approval_access(&enforcer, &ctx).await?; + authorize_thread_participant(&state, &ctx, &scope, approval_id, is_approver).await?; + state + .service + .add_comment(&ctx, &scope, approval_id, body.body) + .await?; + let comments = state.service.thread(&ctx, &scope, approval_id).await?; + let dto = ApprovalThreadResponse { + comments: comments.into_iter().map(ApprovalCommentDto::from).collect(), + }; + Ok((StatusCode::CREATED, Json(dto)).into_response()) +} + +async fn thread( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(approval_id): Path, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // Reading the thread is "entry_approve.v1 OR preparer": the preparer must see + // the request-changes reason (it lives only as a thread comment) to rework. + let (scope, is_approver) = approval_access(&enforcer, &ctx).await?; + authorize_thread_participant(&state, &ctx, &scope, approval_id, is_approver).await?; + let comments = state.service.thread(&ctx, &scope, approval_id).await?; + let dto = ApprovalThreadResponse { + comments: comments.into_iter().map(ApprovalCommentDto::from).collect(), + }; + Ok((StatusCode::OK, Json(dto)).into_response()) +} + +async fn get( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(approval_id): Path, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let scope = approve_scope(&enforcer, &ctx).await?; + approval_response(&state, &ctx, &scope, approval_id).await +} + +async fn list( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let scope = approve_scope(&enforcer, &ctx).await?; + let rows = state + .service + .list(&ctx, &scope, query.state.as_deref(), query.kind.as_deref()) + .await?; + let dto = ApprovalListResponse { + approvals: rows.into_iter().map(ApprovalDto::from).collect(), + }; + Ok((StatusCode::OK, Json(dto)).into_response()) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/audit.rs b/gears/bss/ledger/ledger/src/api/rest/audit.rs new file mode 100644 index 000000000..dba07f444 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/audit.rs @@ -0,0 +1,982 @@ +//! Axum handlers + router for the ledger's audit-retrieval REST surface (Slice +//! 6 Phase 2 Group 2C, architecture AC #8). Three reads under +//! `/bss-ledger/v1/ledger/audit`, all gated by PEP `(entry, audit_read)`: +//! +//! - `GET …/journal-entries/{entryId}` — the who/when/source/correlation dims of +//! one posted entry (tenant-scoped to the caller's home tenant; 404 if absent +//! or outside the caller's scope — no existence leak). +//! - `GET …/documents/{sourceDocType}/{sourceBusinessId}/history` — every +//! `journal_entry` for that document plus any reversal / mapping-correction +//! that links to one of them, ordered by `created_seq` (tenant-scoped). +//! - `GET …/tamper-status?targetScope=&reasonCode=` (+ `X-Investigation-Reason` +//! header) — a scope's freeze rows + a derived `verified` flag. This endpoint +//! carries the **cross-tenant elevation contract**: the read scope is resolved +//! via [`CrossTenantGateway::resolve_read_scope`] INSIDE the transaction, so a +//! cross-tenant read writes its `cross-tenant-access` forensic record in the +//! SAME transaction as the read (or both roll back together). +//! +//! Routes register through `OperationBuilder` so `/openapi.json` lists each +//! operation; the router is merged in `register_rest` alongside the journal / +//! provisioning routers. + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::http::{HeaderMap, header}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use toolkit::api::OpenApiRegistry; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::operation_builder::OperationBuilder; +use toolkit_db::DbError; +use toolkit_db::secure::{AccessScope, DbTx, TxConfig}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{ + AuditEntryDto, AuditPackExportDto, AuditPackRequestDto, DocumentHistoryDto, ErasureRequestDto, + ReidentifyRequestDto, ReidentifyResponseDto, TamperStatusDto, +}; +use crate::api::rest::error::{authz_error_to_canonical, entry_not_found, pack_export_not_found}; +use crate::infra::audit::retrieval::AuditRetrievalReader; +use crate::infra::authz::cross_tenant::{CrossTenantGateway, TargetScope}; +use crate::infra::inquiry::AuditPackExporter; +use crate::infra::pii::ErasureService; +use crate::infra::storage::entity::audit_pack_export; + +/// `OpenAPI` tag applied to the audit-retrieval operations. +const TAG: &str = "BSS Ledger Audit"; + +/// The HTTP header carrying the forensic investigation reason for a cross-tenant +/// tamper-status read (free text; the machine `reasonCode` is a query param). +const INVESTIGATION_REASON_HEADER: &str = "X-Investigation-Reason"; +/// Request header carrying the caller's correlation / trace id (a UUID). Threaded +/// into the secured-audit record's `correlation_id` (S-1) on the SCOPE-level +/// forensic paths (tamper-status, erasure, reidentify, audit-pack), which act on +/// a whole tenant / payer / many entries and so have no single `journal_entry` to +/// anchor on — here `correlation_id` is the request trace id, grouping all +/// forensic events of one investigation request, and makes +/// `idx_secured_audit_correlation` usable. (The annotation `metadata-change` +/// record does NOT use this header: it anchors on the annotated entry's own +/// `journal_entry.correlation_id`, so it joins back to that entry by +/// construction — see `journal_entries::set_entry_annotation`.) Optional: absent +/// or unparseable → `None` (the record is still written, just without a trace +/// key). +const CORRELATION_ID_HEADER: &str = "X-Correlation-Id"; + +/// Shared per-request state for the audit-retrieval routes. Built once at +/// `init()` and shared via `Extension>`. Carries the scoped +/// reader (entry / document-history / tamper-status reads) and the cross-tenant +/// elevation gateway (the tamper-status forensic gate). +#[derive(Clone)] +pub struct ApiState { + /// Scoped audit reader (over the same `DBProvider` the gear uses). + pub reader: AuditRetrievalReader, + /// Cross-tenant elevation gateway (writes the `cross-tenant-access` forensic + /// record in the tamper-status / audit-pack transaction). + pub gateway: CrossTenantGateway, + /// Audit-pack CSV exporter (Group 4A): the scoped filtered-inquiry read + + /// the by-hand RFC-4180 CSV encoder, over the same `DBProvider`. + pub exporter: AuditPackExporter, + /// PII erasure + forensic re-identification service (Group 3A): tombstones a + /// payer's PII map + records the `erasure` / `re-identification` secured-audit + /// record over the same `DBProvider` the gear uses. + pub erasure: ErasureService, + /// Database provider the erasure / re-identify services open their own + /// `SERIALIZABLE` transaction over (the audit reader carries one too, but the + /// erasure path holds its own to keep the dependency explicit). + pub db: toolkit_db::DBProvider, +} + +/// Build the Axum router for the audit-retrieval surface and register every +/// operation with the supplied `OpenAPI` registry. `state` is attached via an +/// `Extension` layer at the end (the registry sees the routes first). +#[allow( + clippy::too_many_lines, + reason = "flat operation-by-operation router registration — each endpoint is a self-contained OperationBuilder chain; splitting would obscure the surface" +)] +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::get("/bss-ledger/v1/ledger/audit/journal-entries/{entryId}") + .operation_id("bss_ledger.audit_entry") + .summary("Read the audit dims of a posted entry") + .description( + "Returns the who/when/source/correlation dims of the journal entry \ + `{entryId}` for the caller's own tenant (AC #8). An entry outside \ + the caller's authorized scope yields 404 (no existence leak). PEP \ + gate `(entry, audit_read)`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .handler(audit_entry) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The entry's audit dims", + ) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get( + "/bss-ledger/v1/ledger/audit/documents/{sourceDocType}/{sourceBusinessId}/history", + ) + .operation_id("bss_ledger.audit_document_history") + .summary("Read a source document's full posting history") + .description( + "Returns every journal entry for `(sourceDocType, sourceBusinessId)` in \ + the caller's own tenant plus any reversal / mapping-correction that \ + links to one of them, ordered by `created_seq`. PEP gate `(entry, \ + audit_read)`; tenant-scoped (SQL-level BOLA).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .handler(audit_document_history) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The document's posting history", + ) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/ledger/audit/tamper-status") + .operation_id("bss_ledger.audit_tamper_status") + .summary("Read a scope's tamper-status (cross-tenant elevation-gated)") + .description( + "Returns the scope-freeze rows + a derived `verified` flag for the \ + resolved scope. Routine (no `targetScope`, or the caller's own \ + tenant) reads the caller's own tenant. A cross-tenant `targetScope` \ + is forensic-gated: it requires the `(entry, audit_read)` role, an \ + `X-Investigation-Reason` header, and a `reasonCode` query, and \ + writes a `cross-tenant-access` secured-audit record in the SAME \ + transaction as the read. A reason-less or role-less cross-tenant \ + request is refused (400 MISSING_INVESTIGATION_REASON / 403 \ + CROSS_TENANT_ACCESS_DENIED) before any foreign row is read.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "target_scope", + false, + "Target tenant to open (defaults to the caller's own; a different \ + tenant triggers the forensic elevation gate)", + ) + .query_param( + "reason_code", + false, + "Machine-readable investigation reason code (required for a \ + cross-tenant read)", + ) + .handler(audit_tamper_status) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The resolved scope's tamper-status", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/ledger/audit/erasure") + .operation_id("bss_ledger.audit_erasure") + .summary("Erase a payer's PII (GDPR right-to-erasure)") + .description( + "Tombstones the `payer_pii_map` row for `payer_tenant_id` \ + (`erased = true`) and records ONE `erasure` secured-audit record, in \ + one SERIALIZABLE transaction. NO journal row is touched — the \ + financial truth and its tamper-evidence chain stay byte-identical. \ + Idempotent: re-erasing an already-tombstoned payer is a no-op that \ + still records the audit event. PEP gate `(entry, erase)` \ + (DPO-scoped). A cross-tenant `target_scope` opens a different \ + tenant's PII map (§5): it requires a reason and an `(entry, erase)` \ + authorization for the target, else 400 MISSING_INVESTIGATION_REASON \ + / 403 CROSS_TENANT_ACCESS_DENIED.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The payer to erase + optional cross-tenant target_scope (the reason \ + is the X-Investigation-Reason header).", + ) + .handler(audit_erasure) + .no_content_response(StatusCode::NO_CONTENT, "The PII was erased (no body)") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/ledger/audit/reidentify") + .operation_id("bss_ledger.audit_reidentify") + .summary("Re-identify a payer's PII reference (forensic)") + .description( + "Returns the opaque `pii_ref` for `payer_tenant_id` (even of a \ + tombstoned payer — the documented investigator path) AFTER recording \ + ONE `re-identification` secured-audit record, in one SERIALIZABLE \ + transaction. Forensic-gated: both a `reason` and a `reason_code` are \ + required, else 400 MISSING_INVESTIGATION_REASON (before any read or \ + write). An absent map row is 404. PEP gate `(entry, reidentify)`. A \ + cross-tenant `target_scope` re-identifies against a different \ + tenant's PII map (§5), requiring an `(entry, reidentify)` \ + authorization for the target, else 403 CROSS_TENANT_ACCESS_DENIED.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The payer to re-identify + reason_code + optional cross-tenant \ + target_scope (the free-text reason is the X-Investigation-Reason \ + header).", + ) + .handler(audit_reidentify) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The recovered PII reference", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/ledger/audit/packs") + .operation_id("bss_ledger.audit_pack") + .summary("Export a filtered audit pack as CSV (cross-tenant elevation-gated)") + .description( + "Filters posted entries by the supplied axes (payer / period / \ + account class / legal entity) and returns a CSV audit pack: a \ + header row plus one row per (entry, line) with the full linkage \ + columns, RFC-4180 quoted. Routine (no `target_scope`, or the \ + caller's own tenant) reads the caller's own tenant. A cross-tenant \ + `target_scope` is forensic-gated: it requires the `(entry, \ + audit_read)` role, an `X-Investigation-Reason` header, and a \ + `reason_code`, and writes a `cross-tenant-access` secured-audit \ + record in the SAME transaction as the read. A reason-less or \ + role-less cross-tenant request is refused (400 \ + MISSING_INVESTIGATION_REASON / 403 CROSS_TENANT_ACCESS_DENIED) \ + before any foreign row is read. PEP gate `(entry, audit_read)`. \ + Responds 202 Accepted with a `Location` to \ + `GET …/audit/packs/{exportId}` (the async export contract, §5/§10); \ + poll that resource for the job status and, once `succeeded`, the \ + materialized CSV.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The inquiry filter + optional cross-tenant target + reason_code.", + ) + .handler(audit_pack) + .json_response_with_schema::( + openapi, + StatusCode::ACCEPTED, + "Export accepted — poll the Location for the job status + CSV", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/ledger/audit/packs/{exportId}") + .operation_id("bss_ledger.audit_pack_get") + .summary("Poll an audit-pack export job") + .description( + "Returns the audit-pack export `{exportId}` in the caller's own \ + tenant: the job `status` and, once `succeeded`, the materialized CSV \ + + data-row count. An export outside the caller's authorized scope \ + yields 404 (no existence leak). PEP gate `(entry, audit_read)`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .handler(audit_pack_get) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The export job status (+ CSV when succeeded)", + ) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// PEP `(entry, audit_read)` gate against the caller's HOME tenant, returning +/// the caller's compiled scope. The returned scope is the SQL-level BOLA filter +/// for the per-row audit reads. A deny maps to 403; an unreachable PDP to 503. +async fn audit_read_scope( + enforcer: &authz_resolver_sdk::PolicyEnforcer, + ctx: &SecurityContext, +) -> Result { + crate::authz::access_scope( + enforcer, + ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::AUDIT_READ, + // Reads pass `owner_tenant_id = None`: the PDP derives the scope from the + // subject + role, and the returned scope is the SQL filter. + None, + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical) +} + +/// Authorize a cross-tenant elevation against the **target** tenant for `action` +/// (`audit_read` for packs/tamper-status, `erase` / `reidentify` for the PII +/// surfaces). +/// +/// The routine home-tenant PEP gate only proves the caller may act on its OWN +/// tenant — it never validates the caller-supplied `targetScope`. Opening a +/// different tenant therefore runs a second, target-anchored PEP decision +/// (`owner_tenant_id = Some(target)`), reusing the write-path target-membership +/// assertion in [`crate::authz::access_scope`] (a target outside the caller's +/// authorized scope is denied there). +/// +/// Returns `true` for the routine path (no target, or the target is the home +/// tenant) and for an authorized cross-tenant target. A PDP *deny* yields +/// `Ok(false)` — the caller then maps it to `CROSS_TENANT_ACCESS_DENIED` (403). +/// A PDP *unavailable* propagates as 503 (fail-closed: the elevation never +/// proceeds on an unreachable authority). +/// +/// # Errors +/// [`CanonicalError`] (503) when the PDP is unreachable. +async fn cross_tenant_role_authorized( + enforcer: &authz_resolver_sdk::PolicyEnforcer, + ctx: &SecurityContext, + home_tenant: Uuid, + target: Option, + action: &str, +) -> Result { + let Some(target) = target.filter(|t| t.tenant_id != home_tenant) else { + return Ok(true); + }; + match crate::authz::access_scope( + enforcer, + ctx, + &crate::authz::resource_types::ENTRY, + action, + // owner_tenant_id = the target tenant being opened: `access_scope` + // denies a target outside the caller's compiled scope. + Some(target.tenant_id), + None, + /* require_constraints */ true, + ) + .await + { + Ok(_) => Ok(true), + Err(crate::authz::AuthzError::Denied(_)) => Ok(false), + Err(e @ crate::authz::AuthzError::Unavailable(_)) => Err(authz_error_to_canonical(e)), + } +} + +/// The free-text investigation reason for a cross-tenant elevation — ALWAYS the +/// `X-Investigation-Reason` request header (§5), the single source of truth for +/// the reason written to the forensic record, across all four cross-tenant +/// endpoints (packs / tamper-status / erasure / reidentify). The machine +/// `reasonCode` travels separately (request body / query param). +fn investigation_reason(headers: &HeaderMap) -> Option { + headers + .get(INVESTIGATION_REASON_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) +} + +/// The caller's correlation / trace id from the `X-Correlation-Id` request header +/// (S-1), parsed as a UUID. `None` when the header is absent or not a valid UUID — +/// the forensic record is still written, just without the trace key. Used by the +/// SCOPE-level forensic write paths in this module (tamper-status, erasure, +/// reidentify, audit-pack), which have no single `journal_entry` to anchor on. The +/// annotation path does NOT use this — it anchors on the annotated entry's own +/// `correlation_id` instead. +fn correlation_id_header(headers: &HeaderMap) -> Option { + headers + .get(CORRELATION_ID_HEADER) + .and_then(|v| v.to_str().ok()) + .and_then(|s| Uuid::parse_str(s.trim()).ok()) +} + +async fn audit_entry( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(entry_id): Path, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let scope = audit_read_scope(&enforcer, &ctx).await?; + let tenant_id = ctx.subject_tenant_id(); + let record = state + .reader + .audit_entry(&scope, tenant_id, entry_id) + .await + .map_err(repo_to_canonical)? + .ok_or_else(|| entry_not_found(entry_id))?; + Ok(Json(AuditEntryDto::from(record))) +} + +async fn audit_document_history( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path((source_doc_type, source_business_id)): Path<(String, String)>, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let scope = audit_read_scope(&enforcer, &ctx).await?; + let tenant_id = ctx.subject_tenant_id(); + let records = state + .reader + .document_history(&scope, tenant_id, &source_doc_type, &source_business_id) + .await + .map_err(repo_to_canonical)?; + Ok(Json(DocumentHistoryDto { + entries: records.into_iter().map(AuditEntryDto::from).collect(), + })) +} + +/// `GET …/tamper-status` query params (the `X-Investigation-Reason` reason is a +/// header, not a query param). `target_scope` is the tenant to open; absent or +/// equal to the caller's own ⇒ routine. +#[derive(Debug, serde::Deserialize)] +struct TamperStatusQuery { + target_scope: Option, + reason_code: Option, +} + +async fn audit_tamper_status( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + headers: HeaderMap, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + // PEP `(entry, audit_read)` gate against the caller's HOME tenant (a deny is a + // 403 here, before any read). + let _home_scope = audit_read_scope(&enforcer, &ctx).await?; + let home_tenant = ctx.subject_tenant_id(); + + // The investigation reason is the `X-Investigation-Reason` request header + // (read via the `HeaderMap` extractor — the toolkit `OperationBuilder` has no + // header-param builder, so the header is read directly in the handler). + let reason = investigation_reason(&headers); + + let target = query + .target_scope + .map(|tenant_id| TargetScope { tenant_id }); + // Cross-tenant elevation MUST authorize the caller for the TARGET tenant, not + // just its own home tenant. A PDP deny becomes `CROSS_TENANT_ACCESS_DENIED` + // (403) inside the gateway; the routine (home) path stays `true`. + let role_authorized = cross_tenant_role_authorized( + &enforcer, + &ctx, + home_tenant, + target, + crate::authz::actions::AUDIT_READ, + ) + .await?; + let reason_code = query.reason_code.clone(); + let actor_ref = ctx.subject_id().to_string(); + let correlation_id = correlation_id_header(&headers); + + // The read scope is resolved + the forensic record written INSIDE one + // transaction with the tamper-status read, so a cross-tenant read's + // `cross-tenant-access` record and the read commit (or roll back) together. + let gateway = state.gateway.clone(); + let reader = state.reader.clone(); + let result: Result = reader + .db() + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let gateway = gateway.clone(); + let reader = reader.clone(); + let reason = reason.clone(); + let reason_code = reason_code.clone(); + let actor_ref = actor_ref.clone(); + Box::pin(async move { + tamper_status_in_txn( + txn, + &gateway, + &reader, + home_tenant, + target, + role_authorized, + actor_ref.as_str(), + reason.as_deref(), + reason_code.as_deref(), + correlation_id, + ) + .await + }) + }) + .await; + + let record = result.map_err(decode_audit_error)?; + Ok(Json(TamperStatusDto::from(record))) +} + +/// `POST …/audit/erasure` — erase a payer's PII map. PEP `(entry, erase)` gates +/// the caller's capability against their own tenant. A cross-tenant +/// `target_scope` (§5) erases a DIFFERENT tenant's PII map: it requires a reason +/// (else `MISSING_INVESTIGATION_REASON`) and an `(entry, erase)` authorization +/// for the target (else `CROSS_TENANT_ACCESS_DENIED`). The `erasure` +/// secured-audit record is the forensic trail; it is written onto the actor's +/// HOME tenant chain (the map tombstone is scoped to the target tenant), the +/// same split [`crate::infra::authz::cross_tenant::CrossTenantGateway`] uses. +/// The free-text `reason` comes from the `X-Investigation-Reason` header. 204 on +/// success. +async fn audit_erasure( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + headers: HeaderMap, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let caller_tenant = ctx.subject_tenant_id(); + + // (entry, erase) PEP capability gate against the caller's OWN tenant (a write + // path: owner_tenant_id = Some, require_constraints). Yields the routine + // same-tenant scope. + let home_scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::ERASE, + Some(caller_tenant), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // The free-text reason is the `X-Investigation-Reason` header (§5), the same + // source as packs / tamper-status / reidentify. + let reason = investigation_reason(&headers).unwrap_or_default(); + let actor_ref = ctx.subject_id().to_string(); + + // Resolve the tenant whose PII map is erased: the caller's own (routine), or + // a forensic-gated cross-tenant target (§5). The cross-tenant elevation + // decision (routine → deny-on-role → missing-reason → target scope) is the + // shared `resolve_action_scope` contract, so this path can never drift from + // re-identify or the read gateway's branch order. + let target = body.target_scope.map(|tenant_id| TargetScope { tenant_id }); + let role_authorized = cross_tenant_role_authorized( + &enforcer, + &ctx, + caller_tenant, + target, + crate::authz::actions::ERASE, + ) + .await?; + let (data_tenant, data_scope) = crate::infra::authz::cross_tenant::resolve_action_scope( + caller_tenant, + &home_scope, + target, + role_authorized, + Some(reason.as_str()), + ) + .map_err(CanonicalError::from)?; + + // The `erasure` forensic record is written onto the actor's HOME chain + // (`caller_tenant`/`home_scope`); the map tombstone is scoped to the target + // (`data_tenant`/`data_scope`). For a routine erasure the two coincide. + state + .erasure + .erase( + &state.db, + &ctx, + &home_scope, + caller_tenant, + &data_scope, + data_tenant, + body.payer_tenant_id, + actor_ref, + reason, + correlation_id_header(&headers), + ) + .await + .map_err(CanonicalError::from)?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +/// `POST …/audit/reidentify` — re-identify a payer's PII ref. PEP `(entry, +/// reidentify)` gates the caller's capability against their own tenant; both a +/// `reason` and a `reason_code` are always required (the forensic gate inside +/// the service → `MISSING_INVESTIGATION_REASON`). A cross-tenant `target_scope` +/// (§5) re-identifies against a DIFFERENT tenant's PII map: it requires an +/// `(entry, reidentify)` authorization for the target (else +/// `CROSS_TENANT_ACCESS_DENIED`). The `re-identification` secured-audit record +/// is the forensic trail; it is written onto the actor's HOME tenant chain (the +/// map read is scoped to the target tenant), the same split +/// [`crate::infra::authz::cross_tenant::CrossTenantGateway`] uses. Absent map row +/// → 404. 200 `{ pii_ref }`. +async fn audit_reidentify( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + headers: HeaderMap, + CanonicalJson(body): CanonicalJson, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let caller_tenant = ctx.subject_tenant_id(); + // The free-text reason is the `X-Investigation-Reason` header (§5), the same + // source as the other three cross-tenant endpoints; `reason_code` is in the + // body. The reason/reason_code forensic gate runs inside the service. + let reason = investigation_reason(&headers).unwrap_or_default(); + + // (entry, reidentify) PEP capability gate against the caller's OWN tenant. + let home_scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::REIDENTIFY, + Some(caller_tenant), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // Resolve the tenant to re-identify against: the caller's own (routine), or a + // forensic-gated cross-tenant target (§5), via the shared + // `resolve_action_scope` contract (same branch order as erasure / the read + // gateway). The reason/reason_code forensic gate is also enforced inside the + // service on both paths. + let target = body.target_scope.map(|tenant_id| TargetScope { tenant_id }); + let role_authorized = cross_tenant_role_authorized( + &enforcer, + &ctx, + caller_tenant, + target, + crate::authz::actions::REIDENTIFY, + ) + .await?; + let (data_tenant, data_scope) = crate::infra::authz::cross_tenant::resolve_action_scope( + caller_tenant, + &home_scope, + target, + role_authorized, + Some(reason.as_str()), + ) + .map_err(CanonicalError::from)?; + + // The `re-identification` forensic record is written onto the actor's HOME + // chain (`caller_tenant`/`home_scope`); the map read is scoped to the target + // (`data_tenant`/`data_scope`). For a routine re-identify the two coincide. + let actor_ref = ctx.subject_id().to_string(); + let pii_ref = state + .erasure + .reidentify( + &state.db, + &ctx, + &home_scope, + caller_tenant, + &data_scope, + data_tenant, + body.payer_tenant_id, + actor_ref, + reason, + body.reason_code, + correlation_id_header(&headers), + ) + .await + .map_err(CanonicalError::from)?; + Ok(Json(ReidentifyResponseDto { pii_ref })) +} + +/// `POST …/audit/packs` — export a filtered audit pack as CSV. PEP `(entry, +/// audit_read)` gate (a passing gate IS the role authorization for the +/// cross-tenant elevation). The read scope is resolved + the forensic record +/// written INSIDE one transaction with the export read, so a cross-tenant pack's +/// `cross-tenant-access` record and the read commit (or roll back) together. +/// Async export contract (§5/§10): responds `202 Accepted` + a `Location` +/// header pointing at `…/audit/packs/{exportId}`, with an `AuditPackExportDto` +/// summary body (`csv` is `None` here). The client polls that `Location` with +/// `GET …/audit/packs/{exportId}` to retrieve the materialized CSV once the +/// export is `succeeded`. The build is synchronous for now (MVP), so the row is +/// already `succeeded` and the first poll returns the CSV — but the wire shape +/// is the durable async contract a future background worker slots behind. +async fn audit_pack( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + headers: HeaderMap, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // PEP `(entry, audit_read)` gate against the caller's HOME tenant (a deny is a + // 403 here, before any read). + let _home_scope = audit_read_scope(&enforcer, &ctx).await?; + let home_tenant = ctx.subject_tenant_id(); + + // The free-text investigation reason is the `X-Investigation-Reason` header + // (the machine `reason_code` is in the body — same split as tamper-status). + let reason = investigation_reason(&headers); + + let target = body.target_scope.map(|tenant_id| TargetScope { tenant_id }); + // Cross-tenant elevation MUST authorize the caller for the TARGET tenant, not + // just its own home tenant. A PDP deny becomes `CROSS_TENANT_ACCESS_DENIED` + // (403) inside the gateway; the routine (home) path stays `true`. + let role_authorized = cross_tenant_role_authorized( + &enforcer, + &ctx, + home_tenant, + target, + crate::authz::actions::AUDIT_READ, + ) + .await?; + let reason_code = body.reason_code.clone(); + let filter: crate::infra::inquiry::InquiryFilter = body.filter.into(); + let actor_ref = ctx.subject_id().to_string(); + let correlation_id = correlation_id_header(&headers); + + let gateway = state.gateway.clone(); + let exporter = state.exporter.clone(); + let result: Result = state + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let gateway = gateway.clone(); + let exporter = exporter.clone(); + let reason = reason.clone(); + let reason_code = reason_code.clone(); + let actor_ref = actor_ref.clone(); + let filter = filter.clone(); + Box::pin(async move { + audit_pack_in_txn( + txn, + &gateway, + &exporter, + home_tenant, + target, + role_authorized, + actor_ref.as_str(), + reason.as_deref(), + reason_code.as_deref(), + correlation_id, + &filter, + ) + .await + }) + }) + .await; + + let model = result.map_err(decode_audit_error)?; + + // Async contract (§5/§10): 202 + Location to the export resource. The build + // is synchronous for now (MVP, contract-only), so the row is already + // `succeeded` and the polled GET returns the CSV immediately — but the wire + // shape is the durable async contract a future background worker slots behind + // without a change. + let location = format!("/bss-ledger/v1/ledger/audit/packs/{}", model.export_id); + Ok(( + StatusCode::ACCEPTED, + [(header::LOCATION, location)], + Json(AuditPackExportDto::summary(&model)), + ) + .into_response()) +} + +/// `GET …/audit/packs/{exportId}` — poll an audit-pack export job. Reads the +/// export row in the caller's own tenant (PEP `(entry, audit_read)` gate; SQL +/// BOLA via the scoped read), returning the job status and, once `succeeded`, +/// the materialized CSV. The forensic cross-tenant-access record (if any) was +/// written at create time, so this poll is a routine home-tenant read. 404 if +/// absent or scoped-out (no existence leak). +async fn audit_pack_get( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(export_id): Path, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let scope = audit_read_scope(&enforcer, &ctx).await?; + let tenant = ctx.subject_tenant_id(); + let model = state + .exporter + .find_export(&scope, tenant, export_id) + .await + .map_err(repo_to_canonical)? + .ok_or_else(|| pack_export_not_found(export_id))?; + Ok(Json(AuditPackExportDto::from(model))) +} + +/// In-transaction body for the audit-pack elevation: resolve the read scope +/// (writing the forensic record on the cross-tenant path), then build the CSV +/// pack under it in the SAME transaction. +#[allow( + clippy::too_many_arguments, + reason = "the full elevation contract (home/target/actor/reason/reason_code/correlation) + the filter + the export, in one txn" +)] +async fn audit_pack_in_txn( + txn: &DbTx<'_>, + gateway: &CrossTenantGateway, + exporter: &AuditPackExporter, + home_tenant: Uuid, + target: Option, + role_authorized: bool, + actor_ref: &str, + reason: Option<&str>, + reason_code: Option<&str>, + correlation_id: Option, + filter: &crate::infra::inquiry::InquiryFilter, +) -> Result { + // `role_authorized` is the target-anchored PEP decision computed at the REST + // seam (`cross_tenant_role_authorized`); `false` makes the gateway reject the + // elevation with `CROSS_TENANT_ACCESS_DENIED` before any foreign read. + let read_scope = gateway + .resolve_read_scope( + txn, + home_tenant, + target, + role_authorized, + actor_ref, + reason, + reason_code, + correlation_id, + ) + .await?; + let (csv, row_count) = exporter + .export_csv_in_txn(txn, &read_scope, filter) + .await + .map_err(|e| crate::infra::posting::service::infra(format!("audit-pack export: {e}")))?; + + // Persist the materialized pack as an export row owned by the HOME tenant + // (the same tenant the forensic record is written under), so the requester + // polls it under its own scope. Born `succeeded` — the build is synchronous + // (MVP, contract-only). The export row + the forensic record + the foreign + // read all commit (or roll back) together in this transaction. + // Surface an overflow as an error rather than silently saturating the + // persisted count (a saturated `i64::MAX` would be a believable lie). + let row_count = i64::try_from(row_count).map_err(|_| { + crate::infra::posting::service::infra(format!( + "audit-pack row_count {row_count} exceeds i64::MAX" + )) + })?; + let now = chrono::Utc::now(); + let model = audit_pack_export::Model { + export_id: Uuid::now_v7(), + tenant_id: home_tenant, + target_tenant_id: target.map_or(home_tenant, |t| t.tenant_id), + status: "succeeded".to_owned(), + reason_code: reason_code.map(str::to_owned), + actor_ref: actor_ref.to_owned(), + csv: Some(csv.into_bytes()), + row_count, + error_detail: None, + created_at_utc: now, + completed_at_utc: Some(now), + }; + exporter + .insert_export_in_txn(txn, &AccessScope::for_tenant(home_tenant), &model) + .await + .map_err(|e| crate::infra::posting::service::infra(format!("audit-pack persist: {e}")))?; + Ok(model) +} + +/// In-transaction body for the tamper-status elevation: resolve the read scope +/// (writing the forensic record on the cross-tenant path), then read the +/// resolved scope's tamper-status under it. +#[allow( + clippy::too_many_arguments, + reason = "the full elevation contract (home/target/actor/reason/reason_code/correlation) + the read, in one txn" +)] +async fn tamper_status_in_txn( + txn: &DbTx<'_>, + gateway: &CrossTenantGateway, + reader: &AuditRetrievalReader, + home_tenant: Uuid, + target: Option, + role_authorized: bool, + actor_ref: &str, + reason: Option<&str>, + reason_code: Option<&str>, + correlation_id: Option, +) -> Result { + // `role_authorized` is the target-anchored PEP decision computed at the REST + // seam (`cross_tenant_role_authorized`); `false` makes the gateway reject the + // elevation with `CROSS_TENANT_ACCESS_DENIED` before any foreign read. + let read_scope = gateway + .resolve_read_scope( + txn, + home_tenant, + target, + role_authorized, + actor_ref, + reason, + reason_code, + correlation_id, + ) + .await?; + + // The tenant the resolved scope authorizes: the target on the cross-tenant + // path, the home tenant on the routine path. + let read_tenant = target.map_or(home_tenant, |t| t.tenant_id); + reader + .tamper_status_in_txn(txn, &read_scope, read_tenant) + .await + .map_err(|e| crate::infra::posting::service::infra(format!("tamper-status read: {e}"))) +} + +/// Retry-extractor for the `SERIALIZABLE` tamper-status txn (mirrors +/// `infra::posting::service::as_db_err`): only a genuine `DbErr` is retryable; +/// the business-rejection sentinel is a non-retryable `DbErr::Custom`. +fn as_db_err(e: &DbError) -> Option<&sea_orm::DbErr> { + match e { + DbError::Sea(db_err) => Some(db_err), + _ => None, + } +} + +/// Decode a `DbError` from the tamper-status txn into a `CanonicalError`: a +/// sentinel-tagged business rejection (`CrossTenantAccessDenied` / +/// `MissingInvestigationReason`) projects through the canonical ladder; any +/// other `DbError` is an infrastructure fault (500). +#[allow( + clippy::needless_pass_by_value, + reason = "error-map target for .map_err; the value is mapped, not retained" +)] +fn decode_audit_error(db_err: DbError) -> CanonicalError { + CanonicalError::from(crate::infra::posting::service::decode_business_error( + &db_err, + )) +} + +/// Map a [`RepoError`](crate::domain::model::RepoError) from a non-txn audit +/// read to a `CanonicalError` (an infrastructure fault ⇒ 500; the diagnostic +/// stays server-side). +#[allow( + clippy::needless_pass_by_value, + reason = "error-map target for .map_err" +)] +fn repo_to_canonical(e: crate::domain::model::RepoError) -> CanonicalError { + CanonicalError::from(crate::domain::error::DomainError::Internal(e.to_string())) +} + +#[cfg(test)] +#[path = "audit_tests.rs"] +mod audit_tests; diff --git a/gears/bss/ledger/ledger/src/api/rest/audit_tests.rs b/gears/bss/ledger/ledger/src/api/rest/audit_tests.rs new file mode 100644 index 000000000..2ced39535 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/audit_tests.rs @@ -0,0 +1,363 @@ +//! Unit tests for the pure parts of the audit-retrieval surface: the DTO +//! projections. The handlers read through a `DBProvider`, so the end-to-end +//! audit-retrieval + tamper-status behavior (who/when/source dims of a posted +//! entry; tamper-status reflecting an inserted freeze) is exercised against a +//! real database in `tests/postgres_cross_tenant.rs`. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use axum::http::HeaderMap; +use chrono::Utc; +use uuid::Uuid; + +use crate::api::rest::audit::correlation_id_header; +use crate::api::rest::dto::{AuditEntryDto, AuditPackExportDto, FreezeDto, TamperStatusDto}; +use crate::infra::audit::retrieval::{AuditEntryRecord, FreezeRecord, TamperStatusRecord}; +use crate::infra::storage::entity::audit_pack_export; + +/// S-1: a valid `X-Correlation-Id` header is parsed into the audit record's +/// `correlation_id`; absent or unparseable yields `None` (the record is still +/// written, just without the cross-trace key). +#[test] +fn correlation_id_header_parses_uuid_or_none() { + let id = Uuid::now_v7(); + + let mut valid = HeaderMap::new(); + valid.insert("X-Correlation-Id", id.to_string().parse().unwrap()); + assert_eq!( + correlation_id_header(&valid), + Some(id), + "valid UUID header → Some(id)" + ); + + assert_eq!( + correlation_id_header(&HeaderMap::new()), + None, + "absent header → None" + ); + + let mut garbage = HeaderMap::new(); + garbage.insert("X-Correlation-Id", "not-a-uuid".parse().unwrap()); + assert_eq!( + correlation_id_header(&garbage), + None, + "unparseable header → None" + ); +} + +#[test] +fn audit_entry_dto_carries_who_when_source_correlation() { + let actor = Uuid::now_v7(); + let corr = Uuid::now_v7(); + let now = Utc::now(); + let record = AuditEntryRecord { + entry_id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + period_id: "202606".to_owned(), + posted_by_actor_id: actor, + origin: "API".to_owned(), + posted_at_utc: now, + source_doc_type: "INVOICE_POST".to_owned(), + source_business_id: "INV-1".to_owned(), + correlation_id: corr, + reverses_entry_id: None, + created_seq: 7, + }; + let dto = AuditEntryDto::from(record); + assert_eq!(dto.posted_by_actor_id, actor); + assert_eq!(dto.correlation_id, corr); + assert_eq!(dto.origin, "API"); + assert_eq!(dto.source_doc_type, "INVOICE_POST"); + assert_eq!(dto.source_business_id, "INV-1"); + assert_eq!(dto.posted_at_utc, now); +} + +#[test] +fn tamper_status_dto_reflects_active_freeze() { + let now = Utc::now(); + let record = TamperStatusRecord { + scope_frozen: true, + freezes: vec![FreezeRecord { + scope: "tenant".to_owned(), + period_id: "ALL".to_owned(), + reason: "broken chain".to_owned(), + frozen_at: now, + set_by: "verifier".to_owned(), + cleared_by: None, + cleared_at: None, + }], + verified: false, + }; + let dto = TamperStatusDto::from(record); + assert!( + dto.scope_frozen, + "an active freeze must surface scope_frozen" + ); + assert!(!dto.verified, "a frozen scope derives verified=false"); + assert_eq!(dto.freezes.len(), 1); + let f: &FreezeDto = &dto.freezes[0]; + assert_eq!(f.reason, "broken chain"); + assert!(f.cleared_at.is_none(), "an active freeze has no cleared_at"); +} + +#[test] +fn tamper_status_dto_unfrozen_is_verified() { + let record = TamperStatusRecord { + scope_frozen: false, + freezes: Vec::new(), + verified: true, + }; + let dto = TamperStatusDto::from(record); + assert!(!dto.scope_frozen); + assert!( + dto.verified, + "an unfrozen scope derives verified=true (MVP)" + ); + assert!(dto.freezes.is_empty()); +} + +/// Build a succeeded export model for the DTO-projection tests. +fn succeeded_export(csv: &str) -> audit_pack_export::Model { + let now = Utc::now(); + audit_pack_export::Model { + export_id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + target_tenant_id: Uuid::now_v7(), + status: "succeeded".to_owned(), + reason_code: Some("DISPUTE_INVESTIGATION".to_owned()), + actor_ref: Uuid::now_v7().to_string(), + csv: Some(csv.as_bytes().to_vec()), + row_count: 3, + error_detail: None, + created_at_utc: now, + completed_at_utc: Some(now), + } +} + +/// The full (polled) projection decodes the stored CSV bytes and carries every +/// job field. +#[test] +fn audit_pack_export_dto_from_model_includes_csv() { + let model = succeeded_export("h1,h2\na,b\n"); + let export_id = model.export_id; + let target = model.target_tenant_id; + let dto = AuditPackExportDto::from(model); + assert_eq!(dto.export_id, export_id); + assert_eq!(dto.status, "succeeded"); + assert_eq!(dto.target_tenant_id, target); + assert_eq!(dto.row_count, 3); + assert_eq!(dto.csv.as_deref(), Some("h1,h2\na,b\n")); + assert!(dto.completed_at_utc.is_some()); +} + +/// The 202-create summary carries the job identity + state but NOT the CSV body +/// (the client polls the Location for the materialized pack). +#[test] +fn audit_pack_export_dto_summary_omits_csv() { + let model = succeeded_export("h1,h2\na,b\n"); + let dto = AuditPackExportDto::summary(&model); + assert_eq!(dto.export_id, model.export_id); + assert_eq!(dto.status, "succeeded"); + assert_eq!(dto.row_count, 3); + assert!(dto.csv.is_none(), "the 202 summary must omit the CSV body"); +} + +// ── cross_tenant_role_authorized — the cross-tenant elevation authorization ── +// +// These pin the fix for the BOLA where the cross-tenant audit read trusted a +// hardcoded `role_authorized = true`: opening a tenant other than the caller's +// home now runs a target-anchored PEP decision, and only an authorized target +// elevates. + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, AuthZResolverError, PolicyEnforcer}; +use toolkit_security::{SecurityContext, pep_properties}; + +use crate::infra::authz::cross_tenant::TargetScope; + +/// Degraded flat-`In` PDP fake authorizing exactly one tenant (mirrors +/// `authz_tests::FlatInResolver` — the shape the production PDP returns for a +/// PEP advertising no subtree capability). +struct FlatInResolver { + allowed: Uuid, +} + +#[async_trait] +impl AuthZResolverClient for FlatInResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::In(InPredicate::new( + pep_properties::OWNER_TENANT_ID, + vec![self.allowed], + ))], + }], + deny_reason: None, + }, + }) + } +} + +/// PDP fake that always fails to evaluate (models an unreachable PDP). +struct FailingResolver; + +#[async_trait] +impl AuthZResolverClient for FailingResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Err(AuthZResolverError::Internal("pdp unreachable".to_owned())) + } +} + +fn flat_in_enforcer(allowed: Uuid) -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(FlatInResolver { allowed })) +} + +fn ctx_for(tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::now_v7()) + .subject_tenant_id(tenant) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// A cross-tenant target INSIDE the caller's authorized scope elevates +/// (`Ok(true)`). +#[tokio::test] +async fn cross_tenant_authorized_when_target_in_scope() { + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + // The caller is authorized for the TARGET tenant (e.g. a self-managed child). + let enforcer = flat_in_enforcer(target); + let ctx = ctx_for(home); + let ok = super::cross_tenant_role_authorized( + &enforcer, + &ctx, + home, + Some(TargetScope { tenant_id: target }), + crate::authz::actions::AUDIT_READ, + ) + .await + .expect("an authorized target must not error"); + assert!(ok, "a target inside the caller's scope must elevate"); +} + +/// A cross-tenant target OUTSIDE the caller's authorized scope is denied +/// (`Ok(false)` → `CROSS_TENANT_ACCESS_DENIED` in the gateway). This is the +/// BOLA regression guard. +#[tokio::test] +async fn cross_tenant_denied_when_target_outside_scope() { + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + // The caller is authorized for its HOME tenant only — not the target. + let enforcer = flat_in_enforcer(home); + let ctx = ctx_for(home); + let ok = super::cross_tenant_role_authorized( + &enforcer, + &ctx, + home, + Some(TargetScope { tenant_id: target }), + crate::authz::actions::AUDIT_READ, + ) + .await + .expect("a PDP deny is Ok(false), not an error"); + assert!( + !ok, + "a target outside the caller's scope must NOT elevate (BOLA guard)" + ); +} + +/// The routine path (no target, or the target IS the home tenant) returns +/// `Ok(true)` WITHOUT calling the PDP — proven by a resolver that would error if +/// consulted. +#[tokio::test] +async fn routine_home_target_skips_pdp() { + let home = Uuid::now_v7(); + let enforcer = PolicyEnforcer::new(Arc::new(FailingResolver)); + let ctx = ctx_for(home); + + let no_target = super::cross_tenant_role_authorized( + &enforcer, + &ctx, + home, + None, + crate::authz::actions::AUDIT_READ, + ) + .await + .expect("the no-target path must not consult the PDP"); + assert!(no_target, "no target is a routine read"); + + let self_target = super::cross_tenant_role_authorized( + &enforcer, + &ctx, + home, + Some(TargetScope { tenant_id: home }), + crate::authz::actions::AUDIT_READ, + ) + .await + .expect("the home-target path must not consult the PDP"); + assert!(self_target, "target == home is a routine read"); +} + +/// An unreachable PDP on the cross-tenant path fails closed (propagates an +/// error → 503), never silently elevating. +#[tokio::test] +async fn cross_tenant_pdp_unavailable_propagates_error() { + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + let enforcer = PolicyEnforcer::new(Arc::new(FailingResolver)); + let ctx = ctx_for(home); + let res = super::cross_tenant_role_authorized( + &enforcer, + &ctx, + home, + Some(TargetScope { tenant_id: target }), + crate::authz::actions::AUDIT_READ, + ) + .await; + assert!( + res.is_err(), + "an unreachable PDP must fail closed, got {res:?}" + ); +} + +// ── investigation_reason — the single header source for the forensic reason ── + +/// The free-text reason is read from the `X-Investigation-Reason` header (the +/// standardized source across all four cross-tenant endpoints). +#[test] +fn investigation_reason_reads_the_header() { + use axum::http::{HeaderMap, HeaderValue}; + let mut headers = HeaderMap::new(); + headers.insert( + super::INVESTIGATION_REASON_HEADER, + HeaderValue::from_static("Dispute #4821 chargeback review"), + ); + assert_eq!( + super::investigation_reason(&headers).as_deref(), + Some("Dispute #4821 chargeback review") + ); +} + +/// An absent header yields `None` (the handlers then treat it as an empty +/// reason, which the cross-tenant gate rejects with `MISSING_INVESTIGATION_REASON`). +#[test] +fn investigation_reason_absent_is_none() { + use axum::http::HeaderMap; + let headers = HeaderMap::new(); + assert!(super::investigation_reason(&headers).is_none()); +} diff --git a/gears/bss/ledger/ledger/src/api/rest/auth_context.rs b/gears/bss/ledger/ledger/src/api/rest/auth_context.rs new file mode 100644 index 000000000..be7ec3ea1 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/auth_context.rs @@ -0,0 +1,35 @@ +//! Authentication-context extraction for the REST handlers. +//! +//! The provisioning endpoint requires an authenticated [`SecurityContext`]; +//! requests without one MUST be refused with 401 — never silently treated as +//! an unauthenticated identity. The `billing-setup` PEP gate (scope / RBAC +//! action) is layered on top in P7. + +use axum::extract::Extension; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit_security::SecurityContext; + +use crate::api::rest::error::unauthenticated; + +/// Extract the [`SecurityContext`] from the request extensions, returning 401 +/// when it is missing, carries the anonymous all-zero placeholder, or is +/// missing the positive `subject_type` marker that a real `AuthN` resolver +/// always populates. +/// +/// # Errors +/// [`CanonicalError`] (401 unauthenticated) when no authenticated context is +/// present on the request. +pub(crate) fn require_authenticated( + extension_ctx: Option>, +) -> Result { + let Some(Extension(ctx)) = extension_ctx else { + return Err(unauthenticated()); + }; + if ctx.subject_id().is_nil() || ctx.subject_tenant_id().is_nil() { + return Err(unauthenticated()); + } + if ctx.subject_type().is_none() { + return Err(unauthenticated()); + } + Ok(ctx) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/canonical_json.rs b/gears/bss/ledger/ledger/src/api/rest/canonical_json.rs new file mode 100644 index 000000000..00cc6ea38 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/canonical_json.rs @@ -0,0 +1,78 @@ +//! `CanonicalJson` — drop-in wrapper around `axum::Json` that +//! converts `JsonRejection` into a canonical `Problem+json` 400. +//! +//! Without this, axum's default `JsonRejection` serialises as +//! `text/plain; charset=utf-8`, which the canonical-error middleware at +//! `toolkit::api::canonical_error_middleware` does not enrich — it gates on +//! `Content-Type: application/problem+json` and passes plain-text responses +//! through verbatim. The result is a 400 without `trace_id` / `instance`, +//! violating the RFC 9457 contract every other 4xx in this module honours and +//! contradicting the `.error_400(openapi)` declaration on each affected +//! handler. +//! +//! Pattern mirrors `toolkit::api::odata::OData`, which converts its own +//! rejections into `CanonicalError` the same way. + +use axum::Json; +use axum::extract::{FromRequest, Request, rejection::JsonRejection}; +use serde::de::DeserializeOwned; +use toolkit::api::canonical_prelude::CanonicalError; + +/// Drop-in replacement for `axum::Json` in handler extractor position. +/// Identical behaviour on success; on rejection produces a canonical +/// Problem-JSON 400 with `field=body` and a reason code derived from the +/// underlying [`JsonRejection`] variant. +#[derive(Debug, Clone)] +pub(crate) struct CanonicalJson(pub T); + +impl FromRequest for CanonicalJson +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = CanonicalError; + + async fn from_request(req: Request, state: &S) -> Result { + match Json::::from_request(req, state).await { + Ok(Json(body)) => Ok(CanonicalJson(body)), + Err(rejection) => Err(json_rejection_to_canonical(&rejection)), + } + } +} + +/// Map an axum `JsonRejection` to the canonical Problem-JSON 400. +/// The reason `code` is a `snake_case` machine identifier the caller can +/// branch on; the `message` is human-readable and may embed the underlying +/// axum diagnostic. +fn json_rejection_to_canonical(rej: &JsonRejection) -> CanonicalError { + let (code, message) = classify_json_rejection(rej); + crate::api::rest::error::json_rejection_canonical(code, message) +} + +/// Classify a `JsonRejection` into a `(code, message)` pair. `code` matches +/// one of the well-known `snake_case` reasons documented for body failures so +/// clients can dispatch without parsing the human message. +fn classify_json_rejection(rej: &JsonRejection) -> (&'static str, String) { + match rej { + JsonRejection::JsonSyntaxError(_) => ( + "json_syntax_error", + format!("request body is not valid JSON: {rej}"), + ), + JsonRejection::JsonDataError(_) => ( + "invalid_json_body", + format!("request body could not be deserialized: {rej}"), + ), + JsonRejection::MissingJsonContentType(_) => ( + "missing_json_content_type", + "expected request to have `Content-Type: application/json`".to_owned(), + ), + JsonRejection::BytesRejection(_) => ( + "json_body_read_error", + format!("failed to read request body: {rej}"), + ), + // `JsonRejection` is `#[non_exhaustive]`; any future variant falls + // through to the generic invalid-body bucket so clients still get a + // Problem envelope. + _ => ("invalid_json_body", format!("invalid request body: {rej}")), + } +} diff --git a/gears/bss/ledger/ledger/src/api/rest/closure.rs b/gears/bss/ledger/ledger/src/api/rest/closure.rs new file mode 100644 index 000000000..a4853c38a --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/closure.rs @@ -0,0 +1,217 @@ +//! Axum handler + router for fiscal-period closure (Slice 7 Group C). +//! `POST /bss-ledger/v1/legal-entities/{legal_entity_id}/periods/{period_id}/closure` +//! — Finance-initiated period close / reopen for the caller's own seller ledger. +//! +//! The body's `action` discriminates: +//! - `"close"` runs the two-phase gated close (`OPEN→CLOSED`); a clean period +//! closes inline (200), a gate-blocked period returns 409 `PERIOD_CLOSE_BLOCKED` +//! (the blocked reasons are recorded on the `period_close` row). +//! - `"reopen"` is **always** dual-control (design §7 / N-core-3): it never reopens +//! inline — it creates a PENDING `PERIOD_REOPEN` approval and returns +//! 409 `DUAL_CONTROL_REQUIRED`; a distinct approver's `POST /approvals/{id}/approve` +//! then drives the actual `CLOSED→REOPENED` flip through the executor. +//! +//! A `…/periods/{period_id}/closure` **sub-resource** (not a `{period_id}:close` +//! colon custom method — those don't route on axum 0.8 / matchit 0.8.4; design F-4). + +use std::sync::Arc; + +use axum::extract::{Extension, Path}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::error::authz_error_to_canonical; +use crate::domain::approval::ApprovalKind; +use crate::domain::approval::intent::{ApprovalIntent, PeriodReopenIntent}; +use crate::domain::approval::policy::OperationFacts; +use crate::domain::error::DomainError; +use crate::domain::status::PERIOD_STATUS_CLOSED; +use crate::infra::approval::service::ApprovalService; + +/// `OpenAPI` tag applied to the period-closure operation. +const TAG: &str = "BSS Ledger Period Close"; + +/// Body `action` discriminator literals. +const ACTION_CLOSE: &str = "close"; +const ACTION_REOPEN: &str = "reopen"; + +/// Shared per-request state for the closure route. +#[derive(Clone)] +pub struct ApiState { + /// In-process ledger client — `close_period` runs its own `(fiscal_period, + /// close)` PEP gate + the two-phase gated close (+ emits `period.closed`). + pub client: Arc, + /// Dual-control engine — a `reopen` routes through `gate()` (always over + /// threshold for `PeriodReopen`, so always 409 `DUAL_CONTROL_REQUIRED`). + pub approval: Arc, +} + +/// Period-closure request body. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct PeriodClosureRequest { + /// `"close"` (gated `OPEN→CLOSED`) or `"reopen"` (dual-control `CLOSED→REOPENED`). + pub action: String, + /// Free-text reason recorded with the operation (Finance audit context). + pub reason: Option, +} + +/// Period-closure response (the inline `close` path; a `reopen` returns 409, never +/// this body). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PeriodClosureResponse { + pub legal_entity_id: Uuid, + pub period_id: String, + /// The period's lifecycle status after the close (`CLOSED`). + pub status: String, + /// `true` when the period was already `CLOSED` (idempotent re-close). + pub already_closed: bool, +} + +/// Build the Axum router for the period-closure surface. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + router = OperationBuilder::post( + "/bss-ledger/v1/legal-entities/{legal_entity_id}/periods/{period_id}/closure", + ) + .operation_id("bss_ledger.period_closure") + .summary("Close or reopen a fiscal period") + .description( + "Finance-initiated period close / reopen for the caller's own seller \ + ledger. `action=close` runs the two-phase gated close (a clean period \ + closes inline 200; a gate-blocked period returns 409 PERIOD_CLOSE_BLOCKED). \ + `action=reopen` is always dual-control: it never reopens inline — it \ + creates a PENDING PERIOD_REOPEN approval and returns 409 \ + DUAL_CONTROL_REQUIRED, and a distinct approver's approval drives the \ + CLOSED→REOPENED flip.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param( + "legal_entity_id", + "The seller legal-entity that owns the period.", + ) + .path_param("period_id", "The accounting period (YYYYMM).") + .json_request::(openapi, "The close/reopen action + reason.") + .handler(period_closure) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The period was closed inline (clean books).", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + // 409: a gate-blocked close (PERIOD_CLOSE_BLOCKED) and a reopen that always routes + // through dual-control (DUAL_CONTROL_REQUIRED) both emit Aborted → 409. + .error_409(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// `POST …/legal-entities/{le}/periods/{period}/closure`: dispatch on `action`. +/// +/// The seller ledger is the caller's own tenant (`ctx.subject_tenant_id()`, +/// mirroring `close_payer`); the path `legal_entity_id` MUST equal it (v1 — one +/// legal entity per tenant). A path LE that is not the caller's own tenant is +/// another seller's books and resolves to a 404 (BOLA, no existence leak), never a +/// close of the caller's own period under a foreign label. +async fn period_closure( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path((legal_entity_id, period_id)): Path<(Uuid, String)>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = ctx.subject_tenant_id(); + // v1: one legal entity per tenant. A path LE outside the caller's own tenant is + // a foreign seller's period — fail closed as not-found (no existence leak). + if legal_entity_id != tenant_id { + return Err(CanonicalError::from(DomainError::PeriodNotFound(format!( + "{legal_entity_id}/{period_id}" + )))); + } + + match body.action.as_str() { + ACTION_CLOSE => { + // `close_period` runs its own `(fiscal_period, close)` PEP gate against + // `tenant_id`, then the two-phase gated close (emitting `period.closed`). + let outcome = state + .client + .close_period(&ctx, tenant_id, period_id.clone()) + .await?; + Ok(( + StatusCode::OK, + Json(PeriodClosureResponse { + legal_entity_id, + period_id: outcome.period_id, + status: PERIOD_STATUS_CLOSED.to_owned(), + already_closed: outcome.already_closed, + }), + ) + .into_response()) + } + ACTION_REOPEN => { + // Reopen authority = `(fiscal_period, close)` (the preparer must be able + // to close); the compiled scope is the dual-control gate's BOLA filter. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::FISCAL_PERIOD, + crate::authz::actions::CLOSE, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // Reopen is ALWAYS dual-control (policy `requires_dual_control` returns + // `true` for `PeriodReopen`), so `gate()` always creates a PENDING + // approval and we always 409. The actual `CLOSED→REOPENED` flip is driven + // later by a distinct approver's `approve` → executor — never inline here. + let intent = ApprovalIntent::PeriodReopen(PeriodReopenIntent { + tenant_id, + legal_entity_id, + period_id: period_id.clone(), + }); + let facts = OperationFacts { + kind: ApprovalKind::PeriodReopen, + amount_usd_eq_minor: None, + effective_at: None, + has_outstanding_balance: false, + }; + let approval_id = state + .approval + .gate(&ctx, &scope, intent, facts, "period-reopen".to_owned()) + .await + .map_err(CanonicalError::from)? + .ok_or_else(|| { + // Unreachable (the policy always requires dual-control for a + // reopen); fail closed rather than reopen without sign-off. + CanonicalError::from(DomainError::Internal( + "period reopen must route through dual-control but the policy \ + did not require it" + .to_owned(), + )) + })?; + Err(CanonicalError::from(DomainError::DualControlRequired( + format!("period reopen requires dual-control approval: {approval_id}"), + ))) + } + other => Err(CanonicalError::from(DomainError::InvalidRequest(format!( + "unknown closure action {other:?} (expected \"{ACTION_CLOSE}\" or \"{ACTION_REOPEN}\")" + )))), + } +} diff --git a/gears/bss/ledger/ledger/src/api/rest/control.rs b/gears/bss/ledger/ledger/src/api/rest/control.rs new file mode 100644 index 000000000..e8787861b --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/control.rs @@ -0,0 +1,341 @@ +//! Axum handlers + router for the control-feed ingest surface (Slice 7 Phase 3, +//! design §0 decision 3 / §3.4). Three POST operations under +//! `/bss-ledger/v1/ledger/control`, tenant-scoped WITHOUT a tenant in the path +//! (the vhp-core convention): each write carries `tenant_id` in the **body**. +//! +//! These are CALL-DRIVEN control feeds — the owning module (Invoice / +//! Orchestration / the PSP adapter) PUSHES into the ledger; they are never posting +//! sources (design §1.2). In v1 they feed the in-process control store the +//! `ReconciliationFramework` + the period-close gate read back; the store is empty +//! (and so every check is inert) until something is pushed. A real external +//! adapter-gear, when present, overrides the corresponding port — these endpoints +//! remain the manual / seed ingest path. +//! +//! - `POST /ledger/control/issued-invoice-manifest` — the authoritative set of +//! issued invoiceIds a `(tenant, period)` was billed for (the invoice-completeness +//! check, N-recon-1). +//! - `POST /ledger/control/bill-run-finished` — the owning Orchestration's bill-run +//! completion assertion for `(tenant, period)` (the close gate's prerequisite). +//! - `POST /ledger/control/psp-settlement-report` — the PSP's net settled amount for +//! `(tenant, period)` (the Payments↔PSP tie). +//! +//! All three gate on `(ledger, provision)` against the body's `tenant_id` — control +//! feeds are ledger reference data, the same family as the FX rate ingest (lean +//! authz reuse; no new resource). Each push is last-writer-wins (the feed is a +//! snapshot, not an append log) and returns `202 Accepted`. + +use std::sync::Arc; + +use axum::extract::Extension; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::error::authz_error_to_canonical; +use crate::infra::control_feed::InProcessControlFeeds; + +/// `OpenAPI` tag applied to the control-feed operations. +const TAG: &str = "BSS Ledger Control Feeds"; + +/// Shared per-request state for the control-feed routes. Constructed once at +/// `init()` and shared via `Extension>`. Carries the in-process +/// control-feed store the ingest endpoints push into. +#[derive(Clone)] +pub struct ApiState { + /// The in-process control-feed store (the v1 default the framework + close gate + /// read back). `Arc` — shared with the reconciliation framework + close gate. + pub feeds: Arc, +} + +/// `POST /ledger/control/issued-invoice-manifest` request body: the authoritative +/// issued-invoice manifest for `(tenant, period)` to push into the control store. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct IssuedInvoiceManifestRequest { + /// The seller tenant whose ledger this feeds; the PEP gate target. In the body. + pub tenant_id: Uuid, + /// The fiscal `period_id` (`YYYYMM`) the manifest covers. + pub period_id: String, + /// The authoritative set of issued invoiceIds for the period. + pub invoice_ids: Vec, + /// Control total: count of issued invoices. + pub count: u64, + /// Control total: summed gross amount in minor units. + pub gross_total_minor: i64, +} + +/// `POST /ledger/control/bill-run-finished` request body: the owning +/// Orchestration's bill-run completion assertion for `(tenant, period)`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct BillRunFinishedRequest { + /// The seller tenant whose ledger this feeds; the PEP gate target. In the body. + pub tenant_id: Uuid, + /// The fiscal `period_id` (`YYYYMM`) the assertion covers. + pub period_id: String, + /// `true` once the period's bill run has finished (the close-gate prerequisite). + pub finished: bool, +} + +/// `POST /ledger/control/psp-settlement-report` request body: the PSP's net settled +/// amount for `(tenant, period)`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct PspSettlementReportRequest { + /// The seller tenant whose ledger this feeds; the PEP gate target. In the body. + pub tenant_id: Uuid, + /// The fiscal `period_id` (`YYYYMM`) the report covers. + pub period_id: String, + /// External PSP report identity (idempotency grain). + pub report_id: String, + /// Net settled amount in minor units the PSP reports (net of refunds/returns). + pub settled_minor: i64, + /// ISO-4217 currency of the report. + pub currency: String, +} + +/// Ack for a control-feed push: the feed + the `(tenant, period)` grain it landed +/// on. The push is last-writer-wins, so a re-push of the same grain overwrites. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ControlFeedAck { + /// The control feed the push landed on (e.g. `issued-invoice-manifest`). + pub feed: String, + pub tenant_id: Uuid, + pub period_id: String, +} + +/// The three control-feed identifiers echoed in the ack. +const FEED_ISSUED_INVOICE_MANIFEST: &str = "issued-invoice-manifest"; +const FEED_BILL_RUN_FINISHED: &str = "bill-run-finished"; +const FEED_PSP_SETTLEMENT_REPORT: &str = "psp-settlement-report"; + +/// Build the Axum router for the control-feed surface and register all three +/// operations with the supplied `OpenAPI` registry. `state` is attached via an +/// `Extension` layer at the end so the registry sees the route definitions before +/// the per-request state is bound. Mirrors [`crate::api::rest::fx::router`]. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/ledger/control/issued-invoice-manifest") + .operation_id("bss_ledger.ingest_issued_invoice_manifest") + .summary("Push the issued-invoice manifest for a period (control feed)") + .description( + "Pushes the authoritative set of issued invoiceIds a `(tenant, period)` \ + was billed for into the in-process control store the \ + invoice-completeness check reads back (N-recon-1). A control feed only, \ + never a posting source; call-driven (the owning Invoice / Orchestration \ + service pushes). Last writer wins. `(ledger, provision)` PEP gate \ + against the body's `tenant_id`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The issued-invoice manifest (tenant in the body).", + ) + .handler(ingest_issued_invoice_manifest) + .json_response_with_schema::( + openapi, + StatusCode::ACCEPTED, + "The manifest was accepted into the control store.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/ledger/control/bill-run-finished") + .operation_id("bss_ledger.ingest_bill_run_finished") + .summary("Push the bill-run-finished assertion for a period (control feed)") + .description( + "Pushes the owning Orchestration's bill-run completion assertion for \ + `(tenant, period)` into the in-process control store the period-close \ + gate reads back. A control feed only, never a posting source; \ + call-driven. Last writer wins. `(ledger, provision)` PEP gate against \ + the body's `tenant_id`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The bill-run-finished assertion (tenant in the body).", + ) + .handler(ingest_bill_run_finished) + .json_response_with_schema::( + openapi, + StatusCode::ACCEPTED, + "The assertion was accepted into the control store.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/ledger/control/psp-settlement-report") + .operation_id("bss_ledger.ingest_psp_settlement_report") + .summary("Push the PSP settlement report for a period (control feed)") + .description( + "Pushes the PSP's net settled amount for `(tenant, period)` into the \ + in-process control store the Payments↔PSP reconciliation reads back. A \ + control feed only, never a posting source; call-driven (the PSP / its \ + adapter pushes). Last writer wins (the `report_id` carries the external \ + idempotency grain). `(ledger, provision)` PEP gate against the body's \ + `tenant_id`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The PSP settlement report (tenant in the body).", + ) + .handler(ingest_psp_settlement_report) + .json_response_with_schema::( + openapi, + StatusCode::ACCEPTED, + "The report was accepted into the control store.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) BEFORE +// the in-handler `require_authenticated` gate (standard axum extractor ordering; no +// authenticated-only data is disclosed). Mirrors `fx::ingest_fx_rate`. +async fn ingest_issued_invoice_manifest( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // (ledger, provision) gate against the TARGET tenant (the body's `tenant_id`): + // control feeds are ledger reference data (the same family as the FX rate + // ingest); a target outside the caller's authorized scope is denied. + gate_provision(&enforcer, &ctx, body.tenant_id).await?; + + // Intake invariant (design §3.3 / N-recon-1): the manifest's `count` control-total + // MUST equal its id-set size, or the feed is internally inconsistent — the + // completeness reconciliation could then trust neither the count nor the membership. + // Reject the malformed feed at the boundary (fail-loud) rather than store it. + let id_count = u64::try_from(body.invoice_ids.len()).unwrap_or(u64::MAX); + if body.count != id_count { + return Err(CanonicalError::from( + crate::domain::error::DomainError::InvalidRequest(format!( + "issued-invoice manifest count {} does not match its id-set size {id_count}", + body.count + )), + )); + } + + state.feeds.ingest_manifest( + body.tenant_id, + &body.period_id, + bss_ledger_sdk::IssuedInvoiceManifest { + invoice_ids: body.invoice_ids, + count: body.count, + gross_total_minor: body.gross_total_minor, + }, + ); + + Ok(accepted( + FEED_ISSUED_INVOICE_MANIFEST, + body.tenant_id, + body.period_id, + )) +} + +async fn ingest_bill_run_finished( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + gate_provision(&enforcer, &ctx, body.tenant_id).await?; + + state + .feeds + .ingest_bill_run_finished(body.tenant_id, &body.period_id, body.finished); + + Ok(accepted( + FEED_BILL_RUN_FINISHED, + body.tenant_id, + body.period_id, + )) +} + +async fn ingest_psp_settlement_report( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + gate_provision(&enforcer, &ctx, body.tenant_id).await?; + + state.feeds.ingest_psp_report( + body.tenant_id, + &body.period_id, + bss_ledger_sdk::PspSettlementReport { + report_id: body.report_id, + settled_minor: body.settled_minor, + currency: body.currency, + }, + ); + + Ok(accepted( + FEED_PSP_SETTLEMENT_REPORT, + body.tenant_id, + body.period_id, + )) +} + +/// The shared `(ledger, provision)` gate against the body's target tenant, common +/// to all three control-feed pushes (lean authz reuse, the FX-ingest family). +async fn gate_provision( + enforcer: &authz_resolver_sdk::PolicyEnforcer, + ctx: &SecurityContext, + tenant_id: Uuid, +) -> Result<(), CanonicalError> { + crate::authz::access_scope( + enforcer, + ctx, + &crate::authz::resource_types::LEDGER, + crate::authz::actions::PROVISION, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + Ok(()) +} + +/// Build the `202 Accepted` ack for a control-feed push. +fn accepted(feed: &str, tenant_id: Uuid, period_id: String) -> Response { + ( + StatusCode::ACCEPTED, + Json(ControlFeedAck { + feed: feed.to_owned(), + tenant_id, + period_id, + }), + ) + .into_response() +} diff --git a/gears/bss/ledger/ledger/src/api/rest/credit.rs b/gears/bss/ledger/ledger/src/api/rest/credit.rs new file mode 100644 index 000000000..04bc46978 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/credit.rs @@ -0,0 +1,190 @@ +//! Axum handler + router for the ledger's reusable-credit (wallet) REST surface +//! (architecture §5.2). ONE operation under `/bss-ledger/v1`, tenant-scoped +//! WITHOUT a tenant in the path (the vhp-core convention, matching the +//! payment / journal-entry / provisioning surfaces): the write carries +//! `tenant_id` in the **body**. +//! +//! - `POST /credit-applications` — operate a tenant's reusable-credit wallet. +//! ONE endpoint, two `kind`s: `"grant"` parks unallocated pool cash into the +//! wallet sub-grain (`DR UNALLOCATED` / `CR REUSABLE_CREDIT`), `"apply"` spends +//! the wallet against the named open receivables oldest-grant-first +//! (`N×DR REUSABLE_CREDIT` / `M×CR AR`). Idempotent on `credit_application_id`. +//! Always `201` (the recorded posting + — for an apply — the wallet draws and +//! the per-invoice shares). `(credit_application, write)` PEP gate against the +//! body's `tenant_id`. +//! +//! The route registers through `OperationBuilder` so `/openapi.json` lists it +//! with its declared request / response schemas. Mirrors `payments::router`. + +use std::sync::Arc; + +use axum::extract::Extension; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use bss_ledger_sdk::api::LedgerClientV1; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_security::SecurityContext; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{CreditApplicationRequest, CreditApplicationResponse}; +use crate::api::rest::error::authz_error_to_canonical; +use crate::authz::{actions, resource_types}; + +/// `OpenAPI` tag applied to the credit-application operation. +const TAG: &str = "BSS Ledger Credit"; + +/// Shared per-request state for the credit route. Constructed once at `init()` +/// and shared via `Extension>`. Carries the in-process data-access +/// client (the wallet grant / apply goes through it — the client gates the PEP +/// and orchestrates the post). Mirrors [`crate::api::rest::payments::ApiState`]. +#[derive(Clone)] +pub struct ApiState { + /// In-process data-access client (the gear's own local impl). + pub client: Arc, + /// Dual-control lifecycle engine (VHP-1852): the grant gate routes a + /// high-value credit grant to the preparer→approver queue. `None` disables + /// the gate (router unit tests without a governance DB). + pub approval: Option>, +} + +/// Build the Axum router for the credit surface and register its operation with +/// the supplied `OpenAPI` registry. `state` is attached via an `Extension` layer +/// at the end so the registry sees the route definition before the per-request +/// state is bound. Mirrors [`crate::api::rest::payments::router`]. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/credit-applications") + .operation_id("bss_ledger.post_credit_application") + .summary("Grant or apply reusable credit (wallet)") + .description( + "Operates the payer's reusable-credit wallet for the seller named by \ + the body's `tenant_id`. ONE endpoint, two `kind`s. `kind = \"grant\"` \ + parks `amount_minor` of the payer's unallocated pool into the wallet \ + sub-grain `credit_grant_event_type` (DR UNALLOCATED, CR \ + REUSABLE_CREDIT), capped at the live pool. `kind = \"apply\"` spends \ + the wallet against the `targets` open receivables oldest-grant-first \ + (N×DR REUSABLE_CREDIT, M×CR AR), capped on both the receivable side \ + (open AR) and the wallet side (spendable sub-grains). Idempotent on \ + `credit_application_id`. Rejected (400) when a grant exceeds the \ + unallocated pool (GRANT_EXCEEDS_UNALLOCATED), an apply target names \ + an unknown/closed invoice or over-applies the open AR \ + (CREDIT_EXCEEDS_OPEN_AR), or the spendable wallet cannot cover the \ + apply total (CREDIT_EXCEEDS_WALLET).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The wallet operation (kind: grant | apply) + idempotency key.", + ) + .handler(post_credit_application) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "The posting reference plus (apply only) the wallet draws + per-invoice shares", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) +// BEFORE the in-handler `require_authenticated` gate, so a malformed body yields +// 400 even for an unauthenticated caller (standard axum extractor ordering; no +// authenticated-only data is disclosed). Mirrors `payments::allocate_payment`. +async fn post_credit_application( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id` (tenant in body, not path). + let tenant_id = body.tenant_id; + // (credit_application, write) PEP gate against the TARGET tenant: a parent + // operates a wallet in a seller in its authorized subtree; a target outside + // the caller's scope is a cross-tenant write and is denied. The in-process + // client gates again (defence-in-depth, matching the payment surface). The + // gate runs on the body's `tenant_id` BEFORE `into_sdk()` — which may itself + // return a 400 for a malformed `kind` / missing field. + crate::authz::access_scope( + &enforcer, + &ctx, + &resource_types::CREDIT_APPLICATION, + actions::WRITE, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // Dual-control gate (VHP-1852): a high-value credit GRANT routes to the + // preparer→approver queue (409); an apply, or a below-threshold grant, posts + // inline (unchanged). + if body.kind == "grant" + && let Some(amount) = body.amount_minor + && let Some(approval) = &state.approval + { + let grant_intent = crate::domain::approval::intent::ApprovalIntent::CreditGrant( + crate::domain::approval::intent::CreditGrantIntent { + tenant_id: body.tenant_id, + payer_tenant_id: body.payer_tenant_id, + credit_application_id: body.credit_application_id.clone(), + currency: body.currency.clone(), + amount_minor: amount, + credit_grant_event_type: body.credit_grant_event_type.clone(), + }, + ); + let grant_facts = crate::domain::approval::policy::OperationFacts { + kind: crate::domain::approval::ApprovalKind::CreditGrant, + amount_usd_eq_minor: Some(amount), + effective_at: None, + has_outstanding_balance: false, + }; + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &resource_types::CREDIT_APPLICATION, + actions::WRITE, + Some(tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + if let Some(approval_id) = approval + .gate( + &ctx, + &scope, + grant_intent, + grant_facts, + "credit-grant".to_owned(), + ) + .await + .map_err(CanonicalError::from)? + { + return Err(CanonicalError::from( + crate::domain::error::DomainError::DualControlRequired(format!( + "credit grant requires dual-control approval: {approval_id}" + )), + )); + } + } + + let cmd = body.into_sdk()?; + let applied = state.client.post_credit_application(&ctx, cmd).await?; + Ok(( + StatusCode::CREATED, + Json(CreditApplicationResponse::from(applied)), + ) + .into_response()) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/disputes.rs b/gears/bss/ledger/ledger/src/api/rest/disputes.rs new file mode 100644 index 000000000..e3974dd26 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/disputes.rs @@ -0,0 +1,411 @@ +//! Axum handler + router for the ledger's chargeback (dispute) REST surface +//! (architecture §4.5, the dispute state machine). One write operation + two +//! read operations under `/bss-ledger/v1`, tenant-scoped WITHOUT a tenant in the +//! path (the vhp-core convention, matching the payment surface): the write's +//! `tenant_id` is in the **body**, the by-id read takes the dispute id in the +//! path + the owning seller `tenant_id` in the query. +//! +//! - `POST /disputes/{dispute_id}/phases` — record one dispute phase. The +//! `(dispute, write)` PEP gate authorizes it against the body's `tenant_id`. +//! The LEDGER chooses the variant at `opened` from `funds_at_open`. Idempotent +//! on `(dispute_id, cycle, phase)`: a re-post replays (`200`), a fresh phase is +//! `201`. +//! - `GET /disputes/{dispute_id}` — read a dispute's current state (its variant, +//! cycle, last phase, disputed amount, cash hold). The surrogate PK is +//! `(tenant_id, dispute_id)`, so the owning seller `tenant_id` is required in +//! the query. `404` when no such dispute exists (or it is outside the caller's +//! subtree — no existence leak). +//! - `GET /disputes` — cursor-paginated list of the recorded disputes for a +//! tenant, with an `OData` `$filter` over `payment_id` / `last_phase` / `variant`. +//! +//! The WRITE authorizes under `(dispute, write)`; the two READS under +//! `(dispute, read)` — symmetric with the write on the dispute's OWN resource, so a +//! chargeback-analyst role reads disputes without the `entry` data plane (the by-id / +//! list reads draw from the `ledger_dispute` current-state row). The read gate threads +//! the compiled scope into the repo (the SQL-level BOLA filter), exactly as +//! `refunds::get_refund` / +//! `refunds::list_refunds`. +//! +//! The routes register through `OperationBuilder` so `/openapi.json` lists each +//! operation with its declared request / response schemas. Mirrors the +//! `return_payment` handler in [`crate::api::rest::payments`] (write) and +//! [`crate::api::rest::refunds`] (the read surface). + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use bss_ledger_sdk::api::LedgerClientV1; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::odata::OData; +use toolkit::api::operation_builder::OperationBuilderODataExt; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_odata::Page; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::local_client::map_odata_page_err; +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{ + DisputePhaseQueuedResponse, DisputeView, RecordDisputePhaseRequest, RecordDisputePhaseResponse, +}; +use crate::api::rest::error::{authz_error_to_canonical, dispute_not_found}; +use crate::infra::storage::repo::DisputeRepo; +use crate::odata::DisputeFilterField; + +/// `OpenAPI` tag applied to the dispute operations. +const TAG: &str = "BSS Ledger Disputes"; + +/// Shared per-request state for the dispute routes. Constructed once at `init()` +/// and shared via `Extension>`. Carries the in-process data-access +/// client (the chargeback record goes through it — the client gates the PEP and +/// orchestrates the post). Mirrors [`crate::api::rest::payments::ApiState`]. +#[derive(Clone)] +pub struct ApiState { + /// In-process data-access client (the gear's own local impl). + pub client: Arc, + /// Dual-control lifecycle engine (VHP-1852): the loss gate routes a + /// high-value chargeback-loss to the preparer→approver queue. `None` disables + /// the gate (router unit tests without a governance DB). + pub approval: Option>, + /// The dispute current-state repo — the `GET /disputes` list + + /// `GET /disputes/{id}` by-id read source (the `ledger_dispute` row). Mirrors + /// [`crate::api::rest::refunds::ApiState`]'s `refund_repo`. + pub dispute_repo: DisputeRepo, +} + +/// Build the Axum router for the dispute surface and register every operation +/// with the supplied `OpenAPI` registry. `state` is attached via an `Extension` +/// layer at the end so the registry sees the route definitions before the +/// per-request state is bound. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/disputes/{dispute_id}/phases") + .operation_id("bss_ledger.record_dispute_phase") + .summary("Record a chargeback dispute phase") + .description( + "Records one phase of a chargeback dispute on `{dispute_id}` for the \ + seller named by the body's `tenant_id`. The LEDGER chooses the \ + variant at `opened` from `funds_at_open` (`withheld` ⇒ cash-hold \ + DR DISPUTE_HOLD / CR CASH_CLEARING; `not_moved` ⇒ AR-reclass \ + ACTIVE→DISPUTED, AR-class-neutral). `phase` is one of opened / won / \ + lost / partial. Idempotent on `(dispute_id, cycle, phase)`: a re-post \ + returns the prior posting reference (200) instead of a new one (201). \ + A dispute records a card-network / bank event and lands even for a \ + closed payer. Rejected when the phase is not a legal transition from \ + the dispute's current state (INVALID_DISPUTE_PHASE). A won/lost whose \ + opened has not landed yet is durably QUEUED (202 dispute-phase-queued), \ + not rejected, and applied once the opened arrives.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("dispute_id", "The dispute being recorded.") + .json_request::( + openapi, + "The dispute phase to record + idempotency key.", + ) + .handler(record_dispute_phase) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference (201 fresh phase / 200 idempotent replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior phase", + ) + .json_response_with_schema::( + openapi, + StatusCode::ACCEPTED, + "Out-of-order won/lost queued until its opened lands (dispute-phase-queued)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/disputes/{dispute_id}") + .operation_id("bss_ledger.get_dispute") + .summary("Read a chargeback dispute's current state") + .description( + "Returns the recorded chargeback dispute for `(tenant_id, dispute_id)` — \ + its chosen variant (CASH_HOLD ⇒ cash moved to DISPUTE_HOLD at open / \ + AR_RECLASS ⇒ AR reclassed ACTIVE→DISPUTED, no cash leg), its current \ + cycle + last phase (OPENED / WON / LOST), the disputed amount, and the \ + cash held in DISPUTE_HOLD at open (0 for AR_RECLASS). The surrogate PK \ + is `(tenant_id, dispute_id)`, so the owning seller `tenant_id` is \ + required in the query. Tenant-scoped (SQL-level BOLA): an unknown \ + dispute — or one outside the caller's authorized subtree — yields a 404 \ + (no existence leak). Mirrors `get_refund`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("dispute_id", "The dispute whose current state to read.") + .query_param( + "tenant_id", + true, + "The dispute's owning seller tenant (the dispute PK's tenant half).", + ) + .handler(get_dispute) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The dispute's current state", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/disputes") + .operation_id("bss_ledger.list_disputes") + .summary("List recorded disputes (cursor-paginated)") + .description( + "Cursor-paginated list of the recorded chargeback disputes for the \ + `tenant_id` query (the caller's own by default). Supports OData \ + `$filter` over `payment_id`, `last_phase`, and `variant`. The `$filter` \ + ANDs the caller's authorized subtree, so disputes outside it are never \ + returned (SQL-level BOLA). Each item is the same `DisputeView` the \ + by-id read returns. Mirrors `list_refunds`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "The disputes' owning seller tenant (defaults to the caller's own).", + ) + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_disputes) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of recorded disputes", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// A dispute-phase outcome rendered with the right status: `201 Created` for a +/// fresh inline post, `200 OK` for an idempotent replay, or `202 Accepted` + +/// the `dispute-phase-queued` body for an out-of-order `won`/`lost` queued until +/// its `opened` lands (§4.7). Mirrors `payments::allocate_response`'s +/// status-varying rendering. +fn record_response(outcome: bss_ledger_sdk::DisputeOutcome) -> Response { + match outcome { + bss_ledger_sdk::DisputeOutcome::Recorded(recorded) => { + let status = if recorded.posting.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + (status, Json(RecordDisputePhaseResponse::from(recorded))).into_response() + } + bss_ledger_sdk::DisputeOutcome::Queued(queued) => ( + StatusCode::ACCEPTED, + Json(DisputePhaseQueuedResponse::from(queued)), + ) + .into_response(), + } +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) +// BEFORE the in-handler `require_authenticated` gate, so a malformed body yields +// 400 even for an unauthenticated caller (standard axum extractor ordering; no +// authenticated-only data is disclosed). +async fn record_dispute_phase( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(dispute_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id`; `dispute_id` is the path id. + let tenant_id = body.tenant_id; + // (dispute, write) PEP gate against the TARGET tenant: a target outside the + // caller's scope is a cross-tenant write and is denied. The in-process client + // gates again (defence-in-depth, matching the payment surface). + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::DISPUTE, + crate::authz::actions::WRITE, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // Dual-control gate (VHP-1852): a chargeback-LOSS whose magnitude crosses the + // D2 threshold routes to the preparer→approver queue (409); other phases, or a + // below-threshold loss, post inline (unchanged). + if body.phase.eq_ignore_ascii_case("lost") + && let Some(approval) = &state.approval + { + let loss_intent = crate::domain::approval::intent::ApprovalIntent::ChargebackLoss( + crate::domain::approval::intent::ChargebackLossIntent { + tenant_id: body.tenant_id, + payer_tenant_id: body.payer_tenant_id, + payment_id: body.payment_id.clone(), + dispute_id: dispute_id.clone(), + invoice_id: body.invoice_id.clone(), + cycle: body.cycle.unwrap_or(1), + funds_at_open: body.funds_at_open.clone(), + disputed_amount_minor: body.disputed_amount_minor, + currency: body.currency.clone(), + }, + ); + let loss_facts = crate::domain::approval::policy::OperationFacts { + kind: crate::domain::approval::ApprovalKind::ChargebackLoss, + amount_usd_eq_minor: Some(body.disputed_amount_minor), + effective_at: None, + has_outstanding_balance: false, + }; + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::DISPUTE, + crate::authz::actions::WRITE, + Some(tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + if let Some(approval_id) = approval + .gate( + &ctx, + &scope, + loss_intent, + loss_facts, + "chargeback-loss".to_owned(), + ) + .await + .map_err(CanonicalError::from)? + { + return Err(CanonicalError::from( + crate::domain::error::DomainError::DualControlRequired(format!( + "chargeback loss requires dual-control approval: {approval_id}" + )), + )); + } + } + + let cmd = body.into_sdk(dispute_id)?; + let outcome = state.client.record_dispute_phase(&ctx, cmd).await?; + Ok(record_response(outcome)) +} + +/// `GET /disputes/{dispute_id}` query parameters: the dispute's owning seller +/// `tenant_id` (the dispute PK is `(tenant_id, dispute_id)`, so the tenant is +/// REQUIRED in the query — like the by-id refund / exposure reads). +#[derive(Debug, serde::Deserialize)] +struct DisputeQuery { + tenant_id: Uuid, +} + +async fn get_dispute( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(dispute_id): Path, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id; + // (dispute, read) PEP gate against the dispute's owning tenant — the dispute's + // OWN resource (symmetric with `dispute.write`, which records phases), so a + // chargeback-analyst role reads disputes without the `entry` data plane. The + // returned scope is the SQL-level BOLA filter the repo binds, so a foreign-tenant + // dispute resolves to None ⇒ 404 (no existence leak), mirroring `refunds::get_refund`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::DISPUTE, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // `read_dispute` returns `Result, DomainError>` — the + // same reader the refund dispute-hold pre-read uses; reused here for the by-id + // read (no second by-id query). A scoped-out / absent row is a canonical 404. + let dispute = state + .dispute_repo + .read_dispute(&scope, tenant_id, &dispute_id) + .await? + .ok_or_else(|| dispute_not_found(&dispute_id))?; + Ok(Json(DisputeView::from(dispute))) +} + +/// `GET /disputes` non-OData query: the disputes' owning tenant (the caller's own +/// when omitted). The `OData` `$filter` / `$orderby` / `limit` / `cursor` are +/// parsed separately by the `OData` extractor from the same query string; +/// `tenant_id` stays a plain param alongside them (the list convention). +#[derive(Debug, serde::Deserialize)] +struct DisputeListQuery { + tenant_id: Option, +} + +async fn list_disputes( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + // (dispute, read) PEP gate against the disputes' owning tenant — the dispute's + // OWN resource (symmetric with `dispute.write`). The returned scope is the + // SQL-level BOLA filter the repo binds, so the page never contains a foreign-tenant + // dispute (no existence leak), mirroring `refunds::list_refunds`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::DISPUTE, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let page = state + .dispute_repo + .list_disputes(&scope, tenant_id, &odata) + .await + .map_err(map_odata_page_err)?; + Ok(Json(Page { + items: page.items.into_iter().map(DisputeView::from).collect(), + page_info: page.page_info, + })) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/dto.rs b/gears/bss/ledger/ledger/src/api/rest/dto.rs new file mode 100644 index 000000000..a920d939b --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/dto.rs @@ -0,0 +1,3497 @@ +//! Wire DTOs for the seller-provisioning endpoint. These own serde/utoipa +//! (via `#[toolkit_macros::api_dto]`) so the SDK value types +//! (`bss_ledger_sdk::provisioning`) stay infra-free. The `api_dto` macro is +//! the platform-mandated DTO wrapper (dylint DE0203) and fixes the wire shape +//! to `snake_case` — the uniform vhp-core REST convention, overriding the +//! architecture doc's illustrative camelCase. + +use std::str::FromStr; + +use chrono::{DateTime, NaiveDate, Utc}; +use toolkit::api::canonical_prelude::{CanonicalError, resource_error}; +use uuid::Uuid; + +use bss_ledger_sdk::{ + AccountClass, AccountInfo, BalanceView, EntryView, FiscalCalendarSpec, Granularity, LineView, + ProvisionAccount, ProvisionCurrencyScale, ProvisionOutcome, ProvisionRequest, Side, +}; + +use crate::domain::approval::policy::{DualControlPolicy, PolicyVersion}; +use crate::domain::error::DomainError; +use crate::domain::invoice::aging::AgingBucket; +use crate::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use crate::domain::recognition::input::{RecognitionInput, RecognitionTiming}; + +/// One chart-of-accounts row to seed. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct AccountDto { + pub account_class: String, + pub currency: String, + pub revenue_stream: Option, + pub normal_side: String, + pub may_go_negative: Option, +} + +/// One non-ISO currency-scale row to seed. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct CurrencyScaleDto { + pub currency: String, + pub minor_units: i16, + /// Per-currency plausible maximum in MAJOR units; omit for the default + /// `10^12` (max scale 6). A higher-precision currency (e.g. BTC scale 8) + /// passes a smaller cap (e.g. `21_000_000`) so its scale fits `i64` headroom. + pub plausible_max_major: Option, + pub source: Option, +} + +/// The fiscal-calendar config to seed. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct FiscalCalendarDto { + pub timezone: String, + pub granularity: String, + pub fy_start: u8, + /// The legal entity's functional (books) currency, ISO-4217 (S5-F3); omit for + /// a single-currency tenant. + pub functional_currency: Option, +} + +/// The provisioning request body: the target seller tenant, accounts, currency +/// scales, and the fiscal-calendar config to seed in one transaction. The +/// tenant is carried in the **body** (not the path) — the gear's REST surface is +/// tenant-from-context/body per the vhp-core convention (RBAC/RMS), and a +/// provision targets a seller the caller authorizes via the PEP gate. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ProvisioningRequestDto { + pub tenant_id: Uuid, + pub accounts: Vec, + pub currency_scales: Vec, + pub fiscal_calendar: FiscalCalendarDto, +} + +/// A chart-of-accounts entry in a response: coordinate + persistent `account_id`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct AccountInfoDto { + pub account_id: Uuid, + pub account_class: String, + pub currency: String, + pub revenue_stream: Option, + pub lifecycle_state: String, +} + +impl From for AccountInfoDto { + fn from(a: AccountInfo) -> Self { + Self { + account_id: a.account_id, + account_class: a.account_class.as_str().to_owned(), + currency: a.currency, + revenue_stream: a.revenue_stream, + lifecycle_state: a.lifecycle_state, + } + } +} + +// The `GET …/accounts` response is now the canonical `toolkit_odata::Page` +// envelope (`items` + `page_info`), built in the handler — the bespoke +// `AccountListDto { accounts }` wrapper is gone (RBAC list pattern). + +/// The provisioning result: the accounts THIS call created + per-grain +/// created-vs-existing counts. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ProvisioningResultDto { + pub accounts: Vec, + pub accounts_created: u32, + pub accounts_existing: u32, + pub scales_created: u32, + pub scales_existing: u32, + pub calendar_created: bool, + pub period_id: String, + pub period_created: bool, +} + +impl From for ProvisioningResultDto { + fn from(o: ProvisionOutcome) -> Self { + Self { + accounts: o.accounts.into_iter().map(AccountInfoDto::from).collect(), + accounts_created: o.accounts_created, + accounts_existing: o.accounts_existing, + scales_created: o.scales_created, + scales_existing: o.scales_existing, + calendar_created: o.calendar_created, + period_id: o.period_id, + period_created: o.period_created, + } + } +} + +/// Default currency-scale `source` when the caller omits it. +const DEFAULT_SCALE_SOURCE: &str = "TENANT"; + +impl ProvisioningRequestDto { + /// Lower the wire DTO into the infra-free SDK [`ProvisionRequest`], + /// parsing the string-typed enum fields (`account_class`, `normal_side`, + /// `granularity`) into their SDK enums. The target tenant is taken from the + /// body's own `tenant_id`. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when an `account_class`, + /// `normal_side`, or `granularity` literal does not parse to a known SDK + /// enum value. + pub fn into_request(self) -> Result { + let accounts = self + .accounts + .into_iter() + .map(|a| { + let account_class = AccountClass::from_str(&a.account_class).map_err(|e| { + DomainError::InvalidRequest(format!("invalid account_class: {e}")) + })?; + let normal_side = Side::from_str(&a.normal_side).map_err(|e| { + DomainError::InvalidRequest(format!("invalid normal_side: {e}")) + })?; + Ok(ProvisionAccount { + account_class, + currency: a.currency, + revenue_stream: a.revenue_stream, + normal_side, + may_go_negative: a.may_go_negative.unwrap_or(false), + }) + }) + .collect::, DomainError>>()?; + + let currency_scales = self + .currency_scales + .into_iter() + .map(|s| { + // Narrow the request scale to `u8` at the boundary: a negative or + // > 255 value is not a valid minor-unit scale and is rejected as + // InvalidArgument here (not deep in the upsert headroom guard). + let minor_units = u8::try_from(s.minor_units).map_err(|_| { + DomainError::ScaleOutOfRange(format!( + "currency {} scale {} is out of range (0..=255)", + s.currency, s.minor_units + )) + })?; + Ok(ProvisionCurrencyScale { + currency: s.currency, + minor_units, + plausible_max_major: s.plausible_max_major, + source: s.source.unwrap_or_else(|| DEFAULT_SCALE_SOURCE.to_owned()), + }) + }) + .collect::, DomainError>>()?; + + let granularity = + Granularity::parse(&self.fiscal_calendar.granularity).ok_or_else(|| { + DomainError::InvalidRequest(format!( + "invalid granularity: {:?}", + self.fiscal_calendar.granularity + )) + })?; + + Ok(ProvisionRequest { + tenant_id: self.tenant_id, + accounts, + currency_scales, + fiscal_calendar: FiscalCalendarSpec { + timezone: self.fiscal_calendar.timezone, + granularity, + fy_start_month: self.fiscal_calendar.fy_start, + functional_currency: self.fiscal_calendar.functional_currency, + }, + }) + } +} + +// ── Invoice-posting request DTOs (§6) ──────────────────────────────────────── + +/// One ex-tax billable line of an invoice to post. Money is the flat +/// `amount_minor` + `currency` pair the domain/storage carry (the ledger has no +/// composite money type). `catalog_class` / `contract_class` are the optional +/// GL-mapping inputs — a missing pair routes the line to `SUSPENSE`/`PENDING`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +// Field names mirror the domain `InvoiceItem` / `journal_line` columns verbatim; +// renaming to satisfy `struct_field_names` would diverge from the contract. +#[allow(clippy::struct_field_names)] +pub struct InvoiceItemDto { + pub amount_minor_ex_tax: i64, + pub currency: String, + pub revenue_stream: String, + /// Catalog-supplied GL class (the default mapping); a known `AccountClass` + /// literal (e.g. `"REVENUE"`). `None` ⇒ no Catalog mapping. + pub catalog_class: Option, + /// Contract-supplied GL class override; wins over `catalog_class`. + pub contract_class: Option, + pub gl_code: Option, + /// Optional ASC 606 recognition spec (Slice 4). `None` ⇒ the item is fully + /// recognized now (`deferred = 0`, the unchanged Variant-A contract). When + /// present, the post derives the deferral schedule and splits the credit + /// into Revenue (recognized now) + Contract-liability (deferred). + pub recognition: Option, + pub invoice_item_ref: Option, + pub sku_or_plan_ref: Option, + pub price_id: Option, + pub pricing_snapshot_ref: Option, +} + +impl InvoiceItemDto { + /// Lower one item DTO into the domain [`InvoiceItem`], parsing the optional + /// `catalog_class` / `contract_class` literals into [`AccountClass`] and the + /// optional `recognition` block into a [`RecognitionInput`]. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when a class literal does not parse, or + /// when the `recognition` block carries an invalid timing (see + /// [`RecognitionInputDto::into_domain`]). + fn into_domain(self) -> Result { + let catalog_class = parse_opt_account_class("catalog_class", self.catalog_class)?; + let contract_class = parse_opt_account_class("contract_class", self.contract_class)?; + let recognition = self + .recognition + .map(RecognitionInputDto::into_domain) + .transpose()?; + Ok(InvoiceItem { + amount_minor_ex_tax: self.amount_minor_ex_tax, + // The deferred portion is DERIVED server-side by the recognition + // builder in the post orchestrator (never trusted from the wire); + // seed `0` here. + deferred_minor: 0, + currency: self.currency, + revenue_stream: self.revenue_stream, + catalog_class, + contract_class, + gl_code: self.gl_code, + recognition, + invoice_item_ref: self.invoice_item_ref, + sku_or_plan_ref: self.sku_or_plan_ref, + price_id: self.price_id, + pricing_snapshot_ref: self.pricing_snapshot_ref, + }) + } +} + +/// The optional per-item ASC 606 recognition spec on the wire (Slice 4). Maps to +/// the domain [`RecognitionInput`]; absence on an item ⇒ no deferral. `timing` is +/// `"point_in_time"` (no deferral, explicit) or `"straight_line"` (defer the +/// whole ex-tax amount over `periods` equal segments from `first_period_id`, +/// defaulted to the invoice period when omitted). The optional refs/flags carry +/// PO / SSP / VC / subscription context onto the materialized schedule. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +// The `*_ref` / `*_group` fields mirror the `recognition_schedule` column names +// verbatim (the storage contract); renaming to satisfy `struct_field_names` +// would diverge from `RecognitionInput` / `NewSchedule`. +#[allow(clippy::struct_field_names)] +pub struct RecognitionInputDto { + /// The deferral+timing policy version stamped immutably on the schedule. + pub policy_ref: String, + /// `"point_in_time"` | `"straight_line"` — the recognition timing pattern. + pub timing: String, + /// Straight-line only: number of equal recognition segments (`>= 1`). + /// Required for `timing = "straight_line"`, ignored for `"point_in_time"`. + pub periods: Option, + /// Straight-line only: first fiscal period (`YYYYMM`) the schedule recognizes + /// into; `None` ⇒ defaulted to the invoice period. + pub first_period_id: Option, + /// The PO / allocation group this line books under (audit, §4.7). + pub po_allocation_group: Option, + /// `true` for a genuine multi-performance-obligation line — the only case + /// where a missing/unresolvable SSP snapshot blocks the post (§4.4). `None` ⇒ + /// `false` (single-PO). + pub multi_po: Option, + /// The SSP snapshot ref pinned at contract inception; required + resolvable + /// for a `multi_po` line (else `SSP_SNAPSHOT_REQUIRED`). + pub ssp_snapshot_ref: Option, + /// The subscription/entitlement this obligation belongs to (audit). + pub subscription_ref: Option, + /// Variable-consideration estimate ref (carried only — VC posting is OUT of + /// the MVP, N-revrec-4). + pub vc_estimate_ref: Option, + /// Variable-consideration method ref (carried only — VC posting is OUT of + /// the MVP, N-revrec-4). + pub vc_method_ref: Option, + /// `true` iff the Catalog SKU is flagged immaterial-one-shot-eligible (an R4 + /// exemption precondition). `None` ⇒ `false` (not eligible). + pub immaterial_one_shot_sku: Option, +} + +/// The two recognition-timing wire literals. +const TIMING_POINT_IN_TIME: &str = "point_in_time"; +const TIMING_STRAIGHT_LINE: &str = "straight_line"; + +impl RecognitionInputDto { + /// Lower the wire recognition block into the domain [`RecognitionInput`], + /// parsing the `timing` literal into [`RecognitionTiming`]. A + /// `"straight_line"` requires `periods` (`>= 1`); a `"point_in_time"` ignores + /// `periods` / `first_period_id`. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `timing` is neither + /// `"point_in_time"` nor `"straight_line"`, or when `"straight_line"` omits a + /// `periods >= 1`. + fn into_domain(self) -> Result { + let timing = match self.timing.as_str() { + TIMING_POINT_IN_TIME => RecognitionTiming::PointInTime, + TIMING_STRAIGHT_LINE => { + let periods = self.periods.filter(|p| *p >= 1).ok_or_else(|| { + DomainError::InvalidRequest( + "recognition straight_line requires `periods` >= 1".to_owned(), + ) + })?; + RecognitionTiming::StraightLine { + periods, + first_period_id: self.first_period_id, + } + } + other => { + return Err(DomainError::InvalidRequest(format!( + "unknown recognition timing {other:?} (expected \ + \"point_in_time\" or \"straight_line\")" + ))); + } + }; + Ok(RecognitionInput { + policy_ref: self.policy_ref, + timing, + po_allocation_group: self.po_allocation_group, + multi_po: self.multi_po.unwrap_or(false), + ssp_snapshot_ref: self.ssp_snapshot_ref, + subscription_ref: self.subscription_ref, + vc_estimate_ref: self.vc_estimate_ref, + vc_method_ref: self.vc_method_ref, + immaterial_one_shot_sku: self.immaterial_one_shot_sku.unwrap_or(false), + }) + } +} + +/// One tax component of an invoice to post, already computed by the tax engine. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct TaxBreakdownDto { + pub amount_minor: i64, + pub currency: String, + pub tax_jurisdiction: String, + pub tax_filing_period: String, + pub tax_rate_ref: Option, +} + +impl From for TaxBreakdown { + fn from(t: TaxBreakdownDto) -> Self { + Self { + amount_minor: t.amount_minor, + currency: t.currency, + tax_jurisdiction: t.tax_jurisdiction, + tax_filing_period: t.tax_filing_period, + tax_rate_ref: t.tax_rate_ref, + } + } +} + +/// The `POST /journal-entries` request body: a fully-recognized invoice +/// (Variant A) to post. The target seller ledger is the body's own `tenant_id` +/// (tenant in body, not path — the vhp-core REST convention); the `(entry, +/// post)` PEP gate authorizes it. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct PostInvoiceRequestDto { + /// The seller tenant whose ledger this posts into (= `entry.tenant_id`); + /// the PEP gate target. Carried in the body, not the path. + pub tenant_id: Uuid, + pub invoice_id: String, + pub payer_tenant_id: Uuid, + pub resource_tenant_id: Option, + pub effective_at: NaiveDate, + /// AR due date stamped on the AR line (drives AR-aging); `None` ⇒ due on + /// posting. + pub due_date: Option, + /// The fiscal `period_id` (`YYYYMM`) the entry posts into. + pub period_id: String, + pub items: Vec, + pub tax: Vec, + /// Caller-supplied trace token propagated onto the entry (NOT an authority + /// claim — the poster identity is the authenticated subject, stamped + /// server-side, never read from the body). + pub correlation_id: Uuid, +} + +impl PostInvoiceRequestDto { + /// Lower the wire DTO into the domain [`PostedInvoice`], parsing each item's + /// optional GL-class literals at the boundary (a bad literal is rejected as + /// `InvalidArgument` ⇒ HTTP 400, not deep in the post path). The seller + /// ledger is the body's own `tenant_id`; `posted_by_actor_id` is the + /// authenticated subject (passed in), never trusted from the body. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when an item's `catalog_class` / + /// `contract_class` literal does not parse to a known [`AccountClass`], or + /// when the items/tax carry more than one currency (mixed-currency invoice). + pub fn into_domain(self, posted_by_actor_id: Uuid) -> Result { + let items = self + .items + .into_iter() + .map(InvoiceItemDto::into_domain) + .collect::, DomainError>>()?; + let tax: Vec = self.tax.into_iter().map(TaxBreakdown::from).collect(); + // Single-currency invariant: the builder stamps the reference currency on + // every line, so a differing item/tax currency would be silently + // misattributed. Reject it at the boundary (the reference = first item's + // currency, else first tax's). + if let Some(reference) = items + .first() + .map(|i| i.currency.as_str()) + .or_else(|| tax.first().map(|t| t.currency.as_str())) + && (items.iter().any(|i| i.currency != reference) + || tax.iter().any(|t| t.currency != reference)) + { + return Err(DomainError::InvalidRequest( + "mixed-currency invoice: all items and tax must share one currency".to_owned(), + )); + } + Ok(PostedInvoice { + invoice_id: self.invoice_id, + payer_tenant_id: self.payer_tenant_id, + resource_tenant_id: self.resource_tenant_id, + seller_tenant_id: self.tenant_id, + effective_at: self.effective_at, + due_date: self.due_date, + period_id: self.period_id, + items, + tax, + posted_by_actor_id, + correlation_id: self.correlation_id, + }) + } +} + +/// The `POST /journal-entries/{entryId}/reversals` request body. The reversed +/// entry is the `{entryId}` path id; the tenant is the caller's auth context. +/// The reversal lands in the supplied `period_id` (a still-OPEN period — it may +/// differ from the original's) effective `effective_at`; both default +/// server-side to the original's period / today when omitted. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ReversalRequestDto { + /// Audit reason for the reversal (recorded by the caller's actor). + pub reason: String, + /// Target OPEN period for the reversal; `None` ⇒ the original's period. + pub period_id: Option, + /// GL effective date of the reversal; `None` ⇒ today (UTC). + pub effective_at: Option, +} + +/// The `POST /journal-entries/{entryId}/mapping-corrections` request body: a +/// strict reversal of the mis-mapped original immediately followed by a +/// corrected re-post (`corrected_items` re-mapped to the right accounts). The +/// original is the `{entryId}` path id; the tenant is the caller's auth context. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct MappingCorrectionRequestDto { + /// Audit reason for the correction. + pub reason: String, + /// Target OPEN period for both halves; `None` ⇒ the original's period. + pub period_id: Option, + /// GL effective date; `None` ⇒ today (UTC). + pub effective_at: Option, + /// The corrected ex-tax lines, re-mapped to the right accounts. + pub corrected_items: Vec, +} + +impl MappingCorrectionRequestDto { + /// Lower the corrected items into domain [`InvoiceItem`]s (parsing the GL + /// class literals at the boundary). + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when an item's class literal does not + /// parse. + pub fn corrected_items_into_domain(&self) -> Result, DomainError> { + self.corrected_items + .iter() + .cloned() + .map(InvoiceItemDto::into_domain) + .collect() + } +} + +/// The `PATCH /journal-entries/{entryId}/annotation` request body (Group 2B, +/// Variant C): set the typed controlled non-financial `description` note on the +/// entry (or one of its lines). `description` is screened for raw customer PII +/// before any write (`PII_IN_METADATA_VALUE`); `null` clears the note. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct EntryAnnotationRequestDto { + /// The controlled note. `null` clears it. Screened for raw customer PII. + pub description: Option, + /// `ENTRY` (the default) or `LINE`. `LINE` requires `target_line_id`. + pub target_kind: Option, + /// The target line id when `target_kind = "LINE"`; ignored for `ENTRY`. + pub target_line_id: Option, + /// Audit reason for the change (recorded on the secured-audit record). + pub reason: String, +} + +// ── Audit-retrieval response DTOs (Group 2C, AC #8) ────────────────────────── + +/// The who/when/source/correlation dims of one posted entry, the +/// `GET …/audit/journal-entries/{entryId}` response (and one row of a document +/// history). A pure audit projection of `journal_entry`; carries no lines. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct AuditEntryDto { + pub entry_id: Uuid, + pub tenant_id: Uuid, + pub period_id: String, + pub posted_by_actor_id: Uuid, + pub origin: String, + pub posted_at_utc: DateTime, + pub source_doc_type: String, + pub source_business_id: String, + pub correlation_id: Uuid, + pub reverses_entry_id: Option, +} + +impl From for AuditEntryDto { + fn from(r: crate::infra::audit::retrieval::AuditEntryRecord) -> Self { + Self { + entry_id: r.entry_id, + tenant_id: r.tenant_id, + period_id: r.period_id, + posted_by_actor_id: r.posted_by_actor_id, + origin: r.origin, + posted_at_utc: r.posted_at_utc, + source_doc_type: r.source_doc_type, + source_business_id: r.source_business_id, + correlation_id: r.correlation_id, + reverses_entry_id: r.reverses_entry_id, + } + } +} + +/// The full posting history of one source document, the +/// `GET …/audit/documents/{sourceDocType}/{sourceBusinessId}/history` response: +/// every entry for that document plus any reversal / mapping-correction that +/// links to one of them, ordered by `created_seq`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct DocumentHistoryDto { + pub entries: Vec, +} + +/// One scope-freeze row in a tamper-status read. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct FreezeDto { + pub scope: String, + pub period_id: String, + pub reason: String, + pub frozen_at: DateTime, + pub set_by: String, + pub cleared_by: Option, + pub cleared_at: Option>, +} + +impl From for FreezeDto { + fn from(r: crate::infra::audit::retrieval::FreezeRecord) -> Self { + Self { + scope: r.scope, + period_id: r.period_id, + reason: r.reason, + frozen_at: r.frozen_at, + set_by: r.set_by, + cleared_by: r.cleared_by, + cleared_at: r.cleared_at, + } + } +} + +/// The tamper-status of a resolved scope, the `GET …/audit/tamper-status` +/// response: `scope_frozen` (any ACTIVE freeze), the freeze rows, and a derived +/// `verified` (= `!scope_frozen`, the MVP derivation — see +/// [`crate::infra::audit::retrieval::AuditRetrievalReader::tamper_status_in_txn`]). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct TamperStatusDto { + pub scope_frozen: bool, + pub freezes: Vec, + pub verified: bool, +} + +impl From for TamperStatusDto { + fn from(r: crate::infra::audit::retrieval::TamperStatusRecord) -> Self { + Self { + scope_frozen: r.scope_frozen, + freezes: r.freezes.into_iter().map(FreezeDto::from).collect(), + verified: r.verified, + } + } +} + +// ── Audit-pack inquiry / export DTOs (Slice 6 Phase 4 Group 4A) ────────────── + +/// The inquiry filter axes of an audit-pack request (all optional; an absent +/// field is "any"). `payer_tenant_id` / `account_class` filter the lines; +/// `period_id` / `legal_entity_id` filter the entry header. `period_id` is the +/// fiscal `YYYYMM` string the schema carries (NOT a `Uuid`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct InquiryFilterDto { + pub payer_tenant_id: Option, + pub period_id: Option, + pub account_class: Option, + pub legal_entity_id: Option, +} + +impl From for crate::infra::inquiry::InquiryFilter { + fn from(f: InquiryFilterDto) -> Self { + Self { + payer_tenant_id: f.payer_tenant_id, + period_id: f.period_id, + account_class: f.account_class, + legal_entity_id: f.legal_entity_id, + } + } +} + +/// `POST …/audit/packs` request body: the inquiry filter, an optional +/// cross-tenant `target_scope` (the tenant to open — a different tenant triggers +/// the forensic elevation gate), and the machine `reason_code` (the free-text +/// reason is the `X-Investigation-Reason` header). A cross-tenant pack needs +/// both a reason header and a `reason_code`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct AuditPackRequestDto { + pub filter: InquiryFilterDto, + /// The tenant to open (defaults to the caller's own; a different tenant + /// triggers the forensic cross-tenant elevation gate). + pub target_scope: Option, + /// Machine-readable investigation reason code (required for a cross-tenant + /// pack). + pub reason_code: Option, +} + +/// Audit-pack export resource (Slice 6 §5/§10). `POST …/audit/packs` returns +/// this at `202 Accepted` (without `csv` — a job summary) with a `Location` to +/// `GET …/audit/packs/{exportId}`, which returns it again with `csv` populated +/// once `status = succeeded`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct AuditPackExportDto { + pub export_id: Uuid, + /// `accepted` | `processing` | `succeeded` | `failed`. + pub status: String, + /// The tenant whose ledger was exported (= the caller's own on a routine + /// export). + pub target_tenant_id: Uuid, + /// Data-row count of the CSV (excludes the header row). + pub row_count: u64, + /// The materialized CSV document; present once `status = succeeded` (omitted + /// from the `202` create response — poll the `Location`). + pub csv: Option, + /// Failure diagnostic when `status = failed`. + pub error_detail: Option, + pub created_at_utc: DateTime, + pub completed_at_utc: Option>, +} + +impl AuditPackExportDto { + /// The `202`-create summary: the job identity + state, WITHOUT the CSV body + /// (the client fetches the full resource — with `csv` — from the `Location`). + #[must_use] + pub fn summary(model: &crate::infra::storage::entity::audit_pack_export::Model) -> Self { + Self { + export_id: model.export_id, + status: model.status.clone(), + target_tenant_id: model.target_tenant_id, + row_count: u64::try_from(model.row_count).unwrap_or(0), + csv: None, + error_detail: model.error_detail.clone(), + created_at_utc: model.created_at_utc, + completed_at_utc: model.completed_at_utc, + } + } +} + +impl From for AuditPackExportDto { + /// The full polled resource: includes the materialized `csv` (decoded as + /// UTF-8 — the exporter writes UTF-8) when present. + fn from(model: crate::infra::storage::entity::audit_pack_export::Model) -> Self { + Self { + export_id: model.export_id, + status: model.status, + target_tenant_id: model.target_tenant_id, + row_count: u64::try_from(model.row_count).unwrap_or(0), + csv: model + .csv + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()), + error_detail: model.error_detail, + created_at_utc: model.created_at_utc, + completed_at_utc: model.completed_at_utc, + } + } +} + +/// Parse an optional `AccountClass` literal carried on a request, mapping a bad +/// literal to [`DomainError::InvalidRequest`] (⇒ HTTP 400). `field` names the +/// offending field for the diagnostic. +fn parse_opt_account_class( + field: &str, + value: Option, +) -> Result, DomainError> { + match value { + None => Ok(None), + Some(literal) => AccountClass::from_str(&literal) + .map(Some) + .map_err(|e| DomainError::InvalidRequest(format!("invalid {field}: {e}"))), + } +} + +// ── Invoice-posting response DTOs (§6) ─────────────────────────────────────── + +/// Reference to a posted (or idempotently replayed) entry. `replayed` is `true` +/// when the call matched a prior post (the handler then renders `200`, not +/// `201`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PostingRefDto { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, +} + +impl From for PostingRefDto { + fn from(r: bss_ledger_sdk::PostingRef) -> Self { + Self { + entry_id: r.entry_id, + created_seq: r.created_seq, + replayed: r.replayed, + } + } +} + +/// One recognition schedule materialized by an invoice-post, echoed in the +/// response so a REST client learns the server-minted `schedule_id` (a `UUIDv7` +/// string) without subscribing to the event bus. One per deferred item-stream; +/// a point-in-time item produces none. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct MaterializedScheduleDto { + /// The server-minted schedule id (`(tenant, schedule_id)` PK tail). + pub schedule_id: String, + /// The revenue stream the schedule books to. + pub revenue_stream: String, + /// The Contract-liability invoice line the schedule draws down. + pub source_invoice_item_ref: String, +} + +impl From for MaterializedScheduleDto { + fn from(v: bss_ledger_sdk::RecognitionScheduleSummaryView) -> Self { + Self { + schedule_id: v.schedule_id, + revenue_stream: v.revenue_stream, + source_invoice_item_ref: v.source_invoice_item_ref, + } + } +} + +/// The `POST /journal-entries` (invoice-post) response: the posted-entry +/// reference (`entry_id`, `created_seq`, `replayed`) plus the recognition +/// schedules materialized in the SAME transaction (`schedules`, empty when the +/// invoice carried no deferred items). Extends the bare posting reference with +/// the discovery half of the recognition-schedule surface — the minted +/// `schedule_id`s are otherwise only observable on the event bus. On an +/// idempotent replay (`replayed = true`) the schedules are those the original +/// post materialized. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PostInvoiceResponseDto { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, + pub schedules: Vec, +} + +/// A read-back journal line in a response (a row of [`EntryDto`] or of the +/// `GET /journal-lines` `Page`). Enum fields render as their canonical +/// string literals (`account_class`, `side`, `mapping_status`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct LineDto { + pub line_id: Uuid, + pub entry_id: Uuid, + pub payer_tenant_id: Uuid, + pub account_id: Uuid, + pub account_class: String, + pub gl_code: Option, + pub side: String, + pub amount_minor: i64, + pub currency: String, + pub currency_scale: u8, + pub invoice_id: Option, + pub due_date: Option, + pub revenue_stream: Option, + pub mapping_status: String, + pub tax_jurisdiction: Option, + pub tax_filing_period: Option, + /// AR dispute sub-class (`ACTIVE`/`DISPUTED`); absent on non-dispute lines. + pub ar_status: Option, +} + +impl From for LineDto { + fn from(l: LineView) -> Self { + Self { + line_id: l.line_id, + entry_id: l.entry_id, + payer_tenant_id: l.payer_tenant_id, + account_id: l.account_id, + account_class: l.account_class.as_str().to_owned(), + gl_code: l.gl_code, + side: l.side.as_str().to_owned(), + amount_minor: l.amount_minor, + currency: l.currency, + currency_scale: l.currency_scale, + invoice_id: l.invoice_id, + due_date: l.due_date, + revenue_stream: l.revenue_stream, + mapping_status: l.mapping_status.as_str().to_owned(), + tax_jurisdiction: l.tax_jurisdiction, + tax_filing_period: l.tax_filing_period, + ar_status: l.ar_status, + } + } +} + +/// A read-back journal entry (header + its lines), the `GET +/// /journal-entries/{entryId}` response. Carries the audit dims +/// (`posted_at_utc`, `posted_by_actor_id`, `origin`, `correlation_id`) + +/// `reverses_entry_id` a caller needs to audit / build a reversal. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct EntryDto { + pub entry_id: Uuid, + pub tenant_id: Uuid, + pub period_id: String, + pub entry_currency: String, + pub source_doc_type: String, + pub source_business_id: String, + pub reverses_entry_id: Option, + pub reverses_period_id: Option, + pub posted_at_utc: DateTime, + pub effective_at: NaiveDate, + pub posted_by_actor_id: Uuid, + pub origin: String, + pub correlation_id: Uuid, + pub created_seq: i64, + pub lines: Vec, +} + +impl From for EntryDto { + fn from(e: EntryView) -> Self { + Self { + entry_id: e.entry_id, + tenant_id: e.tenant_id, + period_id: e.period_id, + entry_currency: e.entry_currency, + source_doc_type: e.source_doc_type.as_str().to_owned(), + source_business_id: e.source_business_id, + reverses_entry_id: e.reverses_entry_id, + reverses_period_id: e.reverses_period_id, + posted_at_utc: e.posted_at_utc, + effective_at: e.effective_at, + posted_by_actor_id: e.posted_by_actor_id, + origin: e.origin, + correlation_id: e.correlation_id, + created_seq: e.created_seq, + lines: e.lines.into_iter().map(LineDto::from).collect(), + } + } +} + +// The `GET /journal-lines` response is now the canonical +// `toolkit_odata::Page` envelope (`items` + `page_info` cursor +// metadata), built in the handler — the bespoke +// `JournalLinePageDto { items, next_cursor }` wrapper is gone (RBAC list +// pattern: the page envelope is the shared toolkit type, not a per-gear DTO). + +/// A read-back account-balance row in the `GET /balances` response. Carries both +/// the transaction-currency `balance_minor` and the Slice-5 functional valuation: +/// `functional_balance_minor` is `null` on a single-currency grain (the +/// `?valuation=functional` read falls back to `balance_minor` by identity, P1 +/// decision 8). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct BalanceDto { + pub account_id: Uuid, + pub account_class: String, + pub currency: String, + pub balance_minor: i64, + /// Functional-currency carried balance; `null` on a single-currency grain + /// (functional ≡ transaction). + pub functional_balance_minor: Option, + /// Functional currency of `functional_balance_minor`; `null` on a + /// single-currency grain. + pub functional_currency: Option, +} + +impl From for BalanceDto { + fn from(b: BalanceView) -> Self { + Self { + account_id: b.account_id, + account_class: b.account_class.as_str().to_owned(), + currency: b.currency, + balance_minor: b.balance_minor, + functional_balance_minor: b.functional_balance_minor, + functional_currency: b.functional_currency, + } + } +} + +// The `GET /balances` response is now the canonical +// `toolkit_odata::Page` envelope (`items` + `page_info`), built in +// the handler — the bespoke `BalanceListDto { balances }` wrapper is gone. + +/// One aged AR grain in the `GET /balances/ar-aging` response: the outstanding +/// receivable for a `(payer, currency, bucket)`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct AgingBucketDto { + pub payer_tenant_id: Uuid, + pub currency: String, + pub bucket: String, + pub amount_minor: i64, +} + +impl From for AgingBucketDto { + fn from(b: AgingBucket) -> Self { + Self { + payer_tenant_id: b.payer_tenant_id, + currency: b.currency, + bucket: b.bucket, + amount_minor: b.amount_minor, + } + } +} + +/// The `GET /balances/ar-aging` response: the open AR aged into buckets. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ArAgingDto { + pub buckets: Vec, +} + +impl From> for ArAgingDto { + fn from(buckets: Vec) -> Self { + Self { + buckets: buckets.into_iter().map(AgingBucketDto::from).collect(), + } + } +} + +// ── Payment request/response DTOs (§6, money-in / money-out) ───────────────── + +/// The `POST /payments` request body: a settled payment to record (the +/// **money-in** side). The target seller ledger is the body's own `tenant_id` +/// (tenant in body, not path — the vhp-core REST convention); the `(payment, +/// write)` PEP gate authorizes it. `scale` is the payment's currency scale as +/// known to the caller — advisory; the ledger resolves the authoritative +/// per-line scale from the provisioned currency config. `effective_at` `None` +/// ⇒ the receipt is stamped at post time. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct SettlePaymentRequest { + /// The seller tenant whose ledger this settles into; the PEP gate target. + /// Carried in the body, not the path. + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + /// External payment identity — the idempotency key (a re-settle replays). + pub payment_id: String, + /// Gross received in minor units (what the payer was charged). + pub gross_minor: i64, + /// Processor's withheld cut in minor units (`<= gross`). + pub fee_minor: i64, + pub currency: String, + /// Advisory currency scale; the ledger resolves the authoritative one. + pub scale: u8, + /// Receipt instant; `None` ⇒ stamped at post time (current-month period). + pub effective_at: Option>, +} + +/// Max length of a client-supplied business id — matches the `varchar(128)` +/// columns the ledger persists these ids in. An over-long (or empty) id is +/// rejected at the boundary as a clean 400 rather than surfacing as a 500 from +/// the column write. +pub const MAX_BUSINESS_ID_LEN: usize = 128; + +/// Validate a client-supplied business id: non-empty and within the +/// `varchar(128)` column bound. `field` names the offending field in the 400. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `value` is empty or longer than +/// [`MAX_BUSINESS_ID_LEN`] bytes. +fn validate_business_id(field: &str, value: &str) -> Result<(), DomainError> { + if value.is_empty() { + return Err(DomainError::InvalidRequest(format!( + "{field} must not be empty" + ))); + } + if value.len() > MAX_BUSINESS_ID_LEN { + return Err(DomainError::InvalidRequest(format!( + "{field} must be at most {MAX_BUSINESS_ID_LEN} bytes, got {}", + value.len() + ))); + } + Ok(()) +} + +impl SettlePaymentRequest { + /// Lower the wire DTO into the SDK [`bss_ledger_sdk::SettlePayment`], + /// validating the client-supplied `payment_id` length at the boundary. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `payment_id` is empty or exceeds + /// [`MAX_BUSINESS_ID_LEN`]. + pub fn into_sdk(self) -> Result { + validate_business_id("payment_id", &self.payment_id)?; + Ok(bss_ledger_sdk::SettlePayment { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + payment_id: self.payment_id, + gross_minor: self.gross_minor, + fee_minor: self.fee_minor, + currency: self.currency, + scale: self.scale, + effective_at: self.effective_at, + }) + } +} + +/// The `POST /payments` response: a reference to the settlement posting. A +/// fresh settle renders `201`, an idempotent replay `200` (the handler reads +/// `replayed`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct SettlePaymentResponse { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, +} + +impl From for SettlePaymentResponse { + fn from(r: bss_ledger_sdk::PostingRef) -> Self { + Self { + entry_id: r.entry_id, + created_seq: r.created_seq, + replayed: r.replayed, + } + } +} + +/// The `POST /payments/{payment_id}/returns` request body: claw a settled +/// receipt back out (the reversal of a money-in). `{payment_id}` (the original +/// settled payment) comes from the PATH; `psp_return_id` is the idempotency key +/// (a re-post replays). `scale` is advisory; `effective_at` `None` ⇒ stamped at +/// post time. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ReturnPaymentRequest { + /// The seller tenant whose ledger this returns within; the PEP gate target. + /// Carried in the body, not the path. + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + /// External return identity — the idempotency key. + pub psp_return_id: String, + /// Amount returned in minor units (`> 0`). + pub amount_minor: i64, + pub currency: String, + /// Advisory currency scale; the ledger resolves the authoritative one. + pub scale: u8, + /// Return instant; `None` ⇒ stamped at post time (current-month period). + pub effective_at: Option>, +} + +impl ReturnPaymentRequest { + /// Lower the wire DTO into the SDK [`bss_ledger_sdk::ReturnPayment`], binding + /// `payment_id` from the request PATH (the body carries no payment id — + /// mirrors [`AllocatePaymentRequest::into_sdk`]) and validating the + /// client-supplied id lengths at the boundary. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `payment_id` (path) or `psp_return_id` + /// (body) is empty or exceeds [`MAX_BUSINESS_ID_LEN`]. + pub fn into_sdk( + self, + payment_id: String, + ) -> Result { + validate_business_id("payment_id", &payment_id)?; + validate_business_id("psp_return_id", &self.psp_return_id)?; + Ok(bss_ledger_sdk::ReturnPayment { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + payment_id, + psp_return_id: self.psp_return_id, + amount_minor: self.amount_minor, + currency: self.currency, + scale: self.scale, + effective_at: self.effective_at, + }) + } +} + +/// The `POST /payments/{payment_id}/returns` response: a reference to the return +/// posting. A fresh return renders `201`, an idempotent replay `200` (the +/// handler reads `replayed`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ReturnPaymentResponse { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, +} + +impl From for ReturnPaymentResponse { + fn from(r: bss_ledger_sdk::PostingRef) -> Self { + Self { + entry_id: r.entry_id, + created_seq: r.created_seq, + replayed: r.replayed, + } + } +} + +// ── Chargeback (dispute) request/response DTOs (§4.5, dispute state machine) ── + +/// The `POST /disputes/{dispute_id}/phases` request body: record one phase of a +/// chargeback dispute. `{dispute_id}` (the dispute's external id) comes from the +/// PATH; the target seller ledger is the body's own `tenant_id` (tenant in body, +/// not path — the vhp-core REST convention); the `(dispute, write)` PEP gate +/// authorizes it. The LEDGER chooses the variant at `opened` from `funds_at_open` +/// (`"withheld"` ⇒ cash-hold, `"not_moved"` ⇒ AR-reclass); `phase` is one of +/// `"opened" | "won" | "lost" | "partial"` (Group B implements `opened`). +/// `cycle` defaults to 1 and increments on a re-open. `invoice_id` is required +/// for an AR-reclass `opened` (the disputed `(payer, invoice)` grain), ignored +/// for cash-hold. `scale` is advisory; `effective_at` `None` ⇒ stamped at post +/// time. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct RecordDisputePhaseRequest { + /// The seller tenant whose ledger this disputes within; the PEP gate target. + /// Carried in the body, not the path. + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + /// The disputed payment. + pub payment_id: String, + /// The disputed `(payer, invoice)` AR grain — required for an AR-reclass + /// `opened`, ignored for cash-hold. + pub invoice_id: Option, + /// Re-entrancy counter; `None` ⇒ 1 (the first cycle). + pub cycle: Option, + /// The phase: `"opened" | "won" | "lost" | "partial"`. + pub phase: String, + /// The funds-movement fact: `"withheld"` (card rails) | `"not_moved"` + /// (invoice/ACH) — read at `opened` to choose the variant. + pub funds_at_open: String, + /// The disputed amount in minor units (`> 0`): the **gross** claim (what the + /// buyer paid / the bank reverses), NOT net of the PSP fee — the ledger sizes a + /// `CASH_HOLD` cash leg at `net = settled − fee` itself. + pub disputed_amount_minor: i64, + pub currency: String, + /// Advisory currency scale; the ledger resolves the authoritative one. + pub scale: u8, + /// Phase instant; `None` ⇒ stamped at post time (current-month period). + pub effective_at: Option>, +} + +impl RecordDisputePhaseRequest { + /// Lower the wire DTO into the SDK [`bss_ledger_sdk::RecordDisputePhase`], + /// binding `dispute_id` from the request PATH (the body carries no dispute + /// id — mirrors [`AllocatePaymentRequest::into_sdk`]) and validating the + /// client-supplied id lengths at the boundary. `cycle` defaults to 1. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `dispute_id` (path), `payment_id`, or + /// (when present) `invoice_id` is empty or exceeds [`MAX_BUSINESS_ID_LEN`], or + /// when `cycle` is supplied and `< 1`. + pub fn into_sdk( + self, + dispute_id: String, + ) -> Result { + validate_business_id("dispute_id", &dispute_id)?; + validate_business_id("payment_id", &self.payment_id)?; + if let Some(invoice_id) = &self.invoice_id { + validate_business_id("invoice_id", invoice_id)?; + } + // The DB CHECK (cycle >= 1) is authoritative; reject a non-positive cycle + // at the boundary with a clear `InvalidRequest` rather than letting it hit + // the constraint and surface as a generic 500. + if let Some(cycle) = self.cycle + && cycle < 1 + { + return Err(DomainError::InvalidRequest(format!( + "dispute cycle must be >= 1, got {cycle}" + ))); + } + Ok(bss_ledger_sdk::RecordDisputePhase { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + payment_id: self.payment_id, + dispute_id, + invoice_id: self.invoice_id, + cycle: self.cycle.unwrap_or(1), + phase: self.phase, + funds_at_open: self.funds_at_open, + disputed_amount_minor: self.disputed_amount_minor, + currency: self.currency, + scale: self.scale, + effective_at: self.effective_at, + }) + } +} + +/// The `POST /disputes/{dispute_id}/phases` response when the phase POSTED +/// inline: a reference to the dispute phase posting. A fresh phase renders `201`, +/// an idempotent replay `200` (the handler reads `replayed`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RecordDisputePhaseResponse { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, +} + +impl From for RecordDisputePhaseResponse { + fn from(r: bss_ledger_sdk::DisputeRecorded) -> Self { + Self { + entry_id: r.posting.entry_id, + created_seq: r.posting.created_seq, + replayed: r.posting.replayed, + } + } +} + +/// The status token a queued (out-of-order) dispute phase renders in +/// [`DisputePhaseQueuedResponse::status`] — a kebab-case literal in a normal JSON +/// body, NOT a `problem+json` error: a `won`/`lost` whose `opened` has not landed +/// is accepted-for-later (HTTP 202), not rejected (§4.7 out-of-order). +const DISPUTE_PHASE_QUEUED_STATUS: &str = "dispute-phase-queued"; + +/// The `POST /disputes/{dispute_id}/phases` response when the phase was an +/// out-of-order `won`/`lost` whose `opened` has not landed: the request was +/// durably QUEUED for a later drain (HTTP 202 Accepted), not posted. `status` is +/// the fixed `dispute-phase-queued` token; `flow` + `business_id` are the queue +/// key and `queued_at` the intake instant. No posting handle — nothing has posted +/// yet. Mirrors `AllocationQueuedResponse`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct DisputePhaseQueuedResponse { + /// Always `dispute-phase-queued` (a normal-body kebab-case token, not an error). + pub status: String, + /// The deferred-apply queue flow (the `CHARGEBACK` literal). + pub flow: String, + /// The queue/dedup business id — `dispute_id:cycle:phase`. + pub business_id: String, + /// When the intake durably enqueued the request. + pub queued_at: DateTime, +} + +impl From for DisputePhaseQueuedResponse { + fn from(q: bss_ledger_sdk::DisputeQueued) -> Self { + Self { + status: DISPUTE_PHASE_QUEUED_STATUS.to_owned(), + flow: q.flow, + business_id: q.business_id, + queued_at: q.queued_at, + } + } +} + +/// One caller-computed allocation share (Mode B, §4.4 F-5): apply `amount_minor` +/// of the lump to `invoice_id`. A row of [`AllocatePaymentRequest::splits`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct AllocationSplitDto { + pub invoice_id: String, + pub amount_minor: i64, +} + +impl From for bss_ledger_sdk::AllocationSplit { + fn from(s: AllocationSplitDto) -> Self { + Self { + invoice_id: s.invoice_id, + amount_minor: s.amount_minor, + } + } +} + +/// The `POST /payments/{payment_id}/allocations` request body: allocate a +/// settled payment's unallocated pool to the payer's open receivables (the +/// **money-out** side). The `payment_id` comes from the PATH (not the body — +/// see [`SettlePaymentRequest::into`] vs [`AllocatePaymentRequest::into_sdk`]), +/// so the lowering to the SDK type takes it as a parameter. `allocation_id` is +/// the idempotency key. `scale` is advisory. +/// +/// When `splits` is omitted the `lump_minor` is applied by the tenant's +/// precedence policy and `hint_invoice_id` jumps one invoice to the front of +/// that fill order. When `splits` is supplied (Mode B), the precedence decision +/// is skipped and those explicit shares are validated against the open +/// receivables instead (each must name an open invoice and not over-allocate it; +/// the shares must sum to at most `lump_minor`) — a bad split is rejected +/// `ALLOCATION_SPLIT_INVALID`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct AllocatePaymentRequest { + /// The seller tenant whose ledger this allocates within; the PEP gate + /// target. Carried in the body, not the path. + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + /// Idempotency key for the allocation (a re-issue replays). + pub allocation_id: Uuid, + /// The lump to apply in minor units, drained oldest-first across open AR. + pub lump_minor: i64, + pub currency: String, + /// Advisory currency scale; the ledger resolves the authoritative one. + pub scale: u8, + /// Optional invoice to jump to the front of the oldest-first fill order. + /// Ignored when `splits` is supplied (Mode B bypasses the fill order). + pub hint_invoice_id: Option, + /// Optional caller-computed per-invoice split (Mode B escape hatch). `None` + /// ⇒ the precedence policy decides the split. + pub splits: Option>, +} + +impl AllocatePaymentRequest { + /// Lower the wire DTO into the SDK [`bss_ledger_sdk::AllocatePayment`], + /// binding `payment_id` from the request PATH (the body carries no payment + /// id) and validating the client-supplied id lengths at the boundary. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `payment_id` (path), `hint_invoice_id` + /// (when present), or any `splits[].invoice_id` is empty or exceeds + /// [`MAX_BUSINESS_ID_LEN`]. + pub fn into_sdk( + self, + payment_id: String, + ) -> Result { + validate_business_id("payment_id", &payment_id)?; + if let Some(hint) = &self.hint_invoice_id { + validate_business_id("hint_invoice_id", hint)?; + } + if let Some(splits) = &self.splits { + for split in splits { + validate_business_id("splits.invoice_id", &split.invoice_id)?; + } + } + Ok(bss_ledger_sdk::AllocatePayment { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + payment_id, + allocation_id: self.allocation_id, + lump_minor: self.lump_minor, + currency: self.currency, + scale: self.scale, + hint_invoice_id: self.hint_invoice_id, + splits: self.splits.map(|v| v.into_iter().map(Into::into).collect()), + }) + } +} + +/// One recorded allocation split in a response: how much of a payment was +/// applied to `invoice_id`, when, under which precedence policy. A row of +/// [`AllocatePaymentResponse::allocations`] and of the `GET +/// /payments/{payment_id}/allocations` list. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct AllocationDto { + pub invoice_id: String, + pub amount_minor: i64, + pub currency: String, + pub allocated_at_utc: DateTime, + pub precedence_policy_ref: String, +} + +impl From for AllocationDto { + fn from(a: bss_ledger_sdk::AllocationView) -> Self { + Self { + invoice_id: a.invoice_id, + amount_minor: a.amount_minor, + currency: a.currency, + allocated_at_utc: a.allocated_at_utc, + precedence_policy_ref: a.precedence_policy_ref, + } + } +} + +/// The `POST /payments/{payment_id}/allocations` response: the posting handle +/// plus the per-invoice splits the allocation applied (always `201`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct AllocatePaymentResponse { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, + pub allocations: Vec, +} + +impl From for AllocatePaymentResponse { + fn from(a: bss_ledger_sdk::AllocationApplied) -> Self { + Self { + entry_id: a.posting.entry_id, + created_seq: a.posting.created_seq, + replayed: a.posting.replayed, + allocations: a.allocations.into_iter().map(AllocationDto::from).collect(), + } + } +} + +/// The status token a queued (deferred) allocation renders in +/// [`AllocationQueuedResponse::status`] — a kebab-case literal in a normal JSON +/// body, NOT a `problem+json` error: an allocate of a not-yet-settled payment is +/// accepted-for-later (HTTP 202), not rejected (§4.7 allocation-before-settlement). +const ALLOCATION_QUEUED_STATUS: &str = "allocation-queued"; + +/// The `POST /payments/{payment_id}/allocations` response when the payment is not +/// yet settled: the allocation was durably QUEUED for a later drain (HTTP 202 +/// Accepted), not posted. `status` is the fixed `allocation-queued` token; `flow` +/// + `business_id` are the queue key and `queued_at` the intake instant. No +/// posting handle / splits — nothing has posted yet (those arrive on the drain). +/// Mirrors the credit/settle status-varying rendering, but as a distinct body +/// shape since there is no `PostingRef` to surface. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct AllocationQueuedResponse { + /// Always `allocation-queued` (a normal-body kebab-case token, not an error). + pub status: String, + /// The deferred-apply queue flow (the `PAYMENT_ALLOCATE` literal). + pub flow: String, + /// The queue/dedup business id — the allocation's `allocation_id`. + pub business_id: String, + /// When the intake durably enqueued the request. + pub queued_at: DateTime, +} + +impl From for AllocationQueuedResponse { + fn from(q: bss_ledger_sdk::AllocationQueued) -> Self { + Self { + status: ALLOCATION_QUEUED_STATUS.to_owned(), + flow: q.flow, + business_id: q.business_id, + queued_at: q.queued_at, + } + } +} + +/// The `GET /payments/{payment_id}/allocations` response: the recorded splits +/// for a payment. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PaymentAllocationsDto { + pub allocations: Vec, +} + +impl From> for PaymentAllocationsDto { + fn from(rows: Vec) -> Self { + Self { + allocations: rows.into_iter().map(AllocationDto::from).collect(), + } + } +} + +/// The `GET /balances/unallocated` response: the payer's still-undrained pool +/// for a currency. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct UnallocatedDto { + pub payer_tenant_id: Uuid, + pub currency: String, + pub balance_minor: i64, +} + +impl From for UnallocatedDto { + fn from(u: bss_ledger_sdk::UnallocatedView) -> Self { + Self { + payer_tenant_id: u.payer_tenant_id, + currency: u.currency, + balance_minor: u.balance_minor, + } + } +} + +// ── Credit-application request/response DTOs (§5.2, reusable-credit wallet) ─── + +/// Stamps `context.resource_type` on the 400s [`CreditApplicationRequest::into_sdk`] +/// raises for a malformed `kind` / missing field (mirrors the `resource_error` +/// structs in `error.rs` / `error_mapping.rs`). The body-shape validation lives +/// here on the DTO; the `DomainError` ladder handles the post-time caps. +#[resource_error("gts.cf.bss.ledger.credit_application.v1~")] +struct CreditApplicationResource; + +/// The `POST /credit-applications` request body: ONE wire shape for both wallet +/// kinds, discriminated by `kind` (`"grant"` | `"apply"`). The target seller +/// ledger is the body's own `tenant_id` (tenant in body, not path — the vhp-core +/// REST convention); the `(credit_application, write)` PEP gate authorizes it. +/// `scale` is advisory; the ledger resolves the authoritative per-line scale. +/// +/// The kind-specific fields are all optional on the wire and validated in +/// [`Self::into_sdk`]: a `grant` requires `amount_minor` + `credit_grant_event_type` +/// (and ignores `targets`); an `apply` requires a non-empty `targets` (and ignores +/// the grant fields). A missing required field or an unknown `kind` is rejected +/// `400 InvalidArgument` before the SDK type is built. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct CreditApplicationRequest { + /// The wallet operation: `"grant"` (park pool cash) | `"apply"` (spend wallet). + pub kind: String, + /// The seller tenant whose ledger this posts into; the PEP gate target. + /// Carried in the body, not the path. + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + /// The `CREDIT_APPLY` idempotency business id (a replay returns the prior post). + pub credit_application_id: String, + pub currency: String, + /// Advisory currency scale; the ledger resolves the authoritative one. + pub scale: u8, + /// Grant only: amount to park into the wallet, in minor units. Required for + /// `kind = "grant"`, ignored for `"apply"`. + pub amount_minor: Option, + /// Grant only: the wallet sub-grain bucket the credit accrues to. Required for + /// `kind = "grant"`, ignored for `"apply"`. + pub credit_grant_event_type: Option, + /// Apply only: the per-invoice receivable shares to spend the wallet against + /// (validated against the payer's open AR). Required (non-empty) for + /// `kind = "apply"`, ignored for `"grant"`. + pub targets: Option>, +} + +impl CreditApplicationRequest { + /// Lower the flat wire DTO into the SDK [`bss_ledger_sdk::CreditApplication`], + /// dispatching on `kind` and validating the kind-specific fields at the + /// boundary (a missing field / unknown kind is a `400 InvalidArgument`, never + /// a panic or a silent default). + /// + /// # Errors + /// A `400 InvalidArgument` [`CanonicalError`] when `credit_application_id` is + /// empty / over [`MAX_BUSINESS_ID_LEN`], when `kind = "grant"` omits + /// `amount_minor` / `credit_grant_event_type`, when `kind = "apply"` omits a + /// non-empty `targets` (or a target names an empty / over-long invoice id), or + /// when `kind` is neither `"grant"` nor `"apply"`. + pub fn into_sdk(self) -> Result { + if self.credit_application_id.is_empty() + || self.credit_application_id.len() > MAX_BUSINESS_ID_LEN + { + return Err(invalid_field( + "credit_application_id", + format!("must be 1..={MAX_BUSINESS_ID_LEN} bytes"), + )); + } + match self.kind.as_str() { + "grant" => { + let amount_minor = self.amount_minor.ok_or_else(|| { + invalid_field("amount_minor", "grant requires `amount_minor`") + })?; + let credit_grant_event_type = self.credit_grant_event_type.ok_or_else(|| { + invalid_field( + "credit_grant_event_type", + "grant requires `credit_grant_event_type`", + ) + })?; + Ok(bss_ledger_sdk::CreditApplication::Grant( + bss_ledger_sdk::CreditGrant { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + credit_application_id: self.credit_application_id, + currency: self.currency, + scale: self.scale, + amount_minor, + credit_grant_event_type, + }, + )) + } + "apply" => { + let targets = self.targets.filter(|t| !t.is_empty()).ok_or_else(|| { + invalid_field("targets", "apply requires a non-empty `targets`") + })?; + for t in &targets { + if t.invoice_id.is_empty() || t.invoice_id.len() > MAX_BUSINESS_ID_LEN { + return Err(invalid_field( + "targets.invoice_id", + format!("must be 1..={MAX_BUSINESS_ID_LEN} bytes"), + )); + } + } + Ok(bss_ledger_sdk::CreditApplication::Apply( + bss_ledger_sdk::CreditApply { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + credit_application_id: self.credit_application_id, + currency: self.currency, + scale: self.scale, + targets: targets.into_iter().map(Into::into).collect(), + }, + )) + } + other => Err(invalid_field( + "kind", + format!( + "unknown credit application kind {other:?} (expected \"grant\" or \"apply\")" + ), + )), + } + } +} + +/// Build a `400 InvalidArgument` [`CanonicalError`] carrying one `field` +/// violation for a malformed credit-application body (a missing kind-specific +/// field or an unknown `kind`). +fn invalid_field(field: &'static str, message: impl Into) -> CanonicalError { + CreditApplicationResource::invalid_argument() + .with_field_violation(field, message.into(), "INVALID_CREDIT_APPLICATION") + .create() +} + +/// One per-sub-grain wallet draw-down in a credit-application response: how much +/// the apply drew from the `credit_grant_event_type` bucket. A row of +/// [`CreditApplicationResponse::debits`] (empty for a grant). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct CreditDebitDto { + pub credit_grant_event_type: String, + pub amount_minor: i64, +} + +impl From for CreditDebitDto { + fn from(d: bss_ledger_sdk::CreditDebitView) -> Self { + Self { + credit_grant_event_type: d.credit_grant_event_type, + amount_minor: d.amount_minor, + } + } +} + +/// One per-invoice receivable share in a credit-application apply response: how +/// much of the wallet was applied to `invoice_id`. A row of +/// [`CreditApplicationResponse::applications`] (empty for a grant). Mirrors the +/// SDK's reuse of `AllocationSplit` on the apply side. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct CreditApplicationShareDto { + pub invoice_id: String, + pub amount_minor: i64, +} + +impl From for CreditApplicationShareDto { + fn from(s: bss_ledger_sdk::AllocationSplit) -> Self { + Self { + invoice_id: s.invoice_id, + amount_minor: s.amount_minor, + } + } +} + +/// The `POST /credit-applications` response: the posting handle plus — for an +/// apply — the per-sub-grain wallet draw-downs (`debits`) and the per-invoice +/// receivable shares (`applications`). A grant leaves both vecs empty (it moves +/// no wallet/AR splits). Always `201` (the wallet post is never an idempotent +/// `200`-replay at the handler — a replay returns the prior `posting` with +/// `replayed = true`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct CreditApplicationResponse { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, + pub debits: Vec, + pub applications: Vec, +} + +impl From for CreditApplicationResponse { + fn from(a: bss_ledger_sdk::CreditApplicationApplied) -> Self { + Self { + entry_id: a.posting.entry_id, + created_seq: a.posting.created_seq, + replayed: a.posting.replayed, + debits: a.debits.into_iter().map(CreditDebitDto::from).collect(), + applications: a + .applications + .into_iter() + .map(CreditApplicationShareDto::from) + .collect(), + } + } +} + +// ── Recognition-run request/response DTOs (§5, the ASC 606 S6 release) ─────── + +/// The `POST /recognition-runs` request body: trigger an ASC 606 recognition +/// run for one fiscal period (release the period's due `PENDING` segments). The +/// target seller ledger is the body's own `tenant_id` (tenant in body, not path +/// — the vhp-core REST convention); the `(entry, post)` PEP gate authorizes it +/// (a run posts `DR CL / CR Revenue` journal entries). `run_id` is the +/// run-trigger idempotency key: `None` ⇒ the ledger mints a fresh one (a first, +/// un-keyed trigger); a stable one makes retries idempotent (a replay returns +/// the prior run reference without starting a second run). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +#[allow( + clippy::struct_field_names, + reason = "the *_id fields mirror the run-trigger identity tuple (tenant / period / run)" +)] +pub struct TriggerRecognitionRunRequest { + /// The seller tenant whose ledger this releases revenue in; the PEP gate + /// target. Carried in the body, not the path. + pub tenant_id: Uuid, + /// The fiscal period to release due segments for (`YYYYMM`). + pub period_id: String, + /// The run-trigger idempotency key; `None` ⇒ the ledger mints a fresh one. + pub run_id: Option, +} + +impl TriggerRecognitionRunRequest { + /// Lower the wire DTO into the SDK [`bss_ledger_sdk::TriggerRecognitionRun`], + /// validating the `period_id` at the boundary (a `YYYYMM` business id, bound + /// by the same `varchar(128)` column convention as the other ids). + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `period_id` is empty or exceeds + /// [`MAX_BUSINESS_ID_LEN`]. + pub fn into_sdk(self) -> Result { + validate_business_id("period_id", &self.period_id)?; + Ok(bss_ledger_sdk::TriggerRecognitionRun { + tenant_id: self.tenant_id, + period_id: self.period_id, + run_id: self.run_id, + }) + } +} + +/// The `POST /recognition-runs` response when the run EXECUTED (fresh or an +/// idempotent replay of a prior trigger): the run identity + the release tally. +/// A fresh run renders `200`, an idempotent replay also `200` (the run already +/// committed under the original trigger; `replayed = true`). The `Ran` arm of +/// the outcome. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RecognitionRunResponse { + /// The run that executed (minted by the trigger, or replayed). + pub run_id: Uuid, + /// The period the run released for (`YYYYMM`). + pub period_id: String, + /// `true` when this trigger replayed a prior run with the same + /// `(tenant, run_id)` (no new run row was inserted; no re-release). + pub replayed: bool, + /// Segments released on THIS pass (a fresh `DR CL / CR Revenue` post). + pub released: usize, + /// Segments that were already released (an idempotent `RECOGNITION` replay). + pub already_recognized: usize, +} + +impl From for RecognitionRunResponse { + fn from(r: bss_ledger_sdk::RecognitionRunRef) -> Self { + Self { + run_id: r.run_id, + period_id: r.period_id, + replayed: r.replayed, + released: r.released, + already_recognized: r.already_recognized, + } + } +} + +/// The status token a recognition run that parked out-of-order segments renders +/// in [`RecognitionRunQueuedResponse::status`] — a kebab-case literal in a normal +/// JSON body, NOT a `problem+json` error: a due segment whose lower-period +/// predecessor is not yet `DONE` is accepted-for-later (HTTP 202), not rejected +/// (§4.6 ordering, uniform with `allocation-queued` / `dispute-phase-queued`). +const RECOGNITION_PERIOD_QUEUED_STATUS: &str = "recognition-period-queued"; + +/// The `POST /recognition-runs` response when the run had to park one or more +/// out-of-order segments `QUEUED` for a later drain (HTTP 202 Accepted): the run +/// ran (it may have released in-order segments) but a due segment's lower-period +/// predecessor was not yet `DONE` (§4.6). `status` is the fixed +/// `recognition-period-queued` token; `released` + `queued` are this pass's +/// tallies. A later run drains the `QUEUED` segments once their predecessors +/// commit. Mirrors `AllocationQueuedResponse`. The `Queued` arm of the outcome. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RecognitionRunQueuedResponse { + /// Always `recognition-period-queued` (a normal-body kebab-case token, not an error). + pub status: String, + /// The run that executed (the queued ones are the out-of-order tail). + pub run_id: Uuid, + /// The period the run was triggered for (`YYYYMM`). + pub period_id: String, + /// Segments released in order on this pass before/around the queued ones. + pub released: usize, + /// Segments parked `QUEUED` this pass (a predecessor period was not `DONE`). + pub queued: usize, +} + +impl From for RecognitionRunQueuedResponse { + fn from(q: bss_ledger_sdk::RecognitionRunQueued) -> Self { + Self { + status: RECOGNITION_PERIOD_QUEUED_STATUS.to_owned(), + run_id: q.run_id, + period_id: q.period_id, + released: q.released, + queued: q.queued, + } + } +} + +// ── Revenue-disaggregation response DTOs (§3.5 / §4.5, ASC 606 by stream) ───── + +/// One disaggregated recognized-revenue grain in the `GET +/// /revenue/disaggregation` response: the revenue RECOGNIZED into +/// `revenue_stream` during `period_id` (`Σ amount_minor` of the DONE recognition +/// segments at that grain), in minor units of `currency`. A row of +/// [`RevenueDisaggregationResponse::entries`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RevenueDisaggregationEntryDto { + /// The fiscal period the revenue recognized in (`YYYYMM`). + pub period_id: String, + /// The revenue stream the recognized revenue books to. + pub revenue_stream: String, + /// Revenue recognized into this `(period, stream)` grain, in minor units. + pub recognized_minor: i64, + /// ISO currency of the recognized amount. + pub currency: String, +} + +impl From for RevenueDisaggregationEntryDto { + fn from(e: bss_ledger_sdk::RevenueDisaggregationEntry) -> Self { + Self { + period_id: e.period_id, + revenue_stream: e.revenue_stream, + recognized_minor: e.recognized_minor, + currency: e.currency, + } + } +} + +/// The `GET /revenue/disaggregation` response: recognized ASC 606 revenue +/// disaggregated by `(period_id, revenue_stream)`, ordered by +/// `(period_id, revenue_stream)`. NOTE: like `ar-aging`, this is a **computed +/// aggregate report** (a grouped SUM over the recognized segments), not a +/// paginated row collection — it keeps plain `?tenant_id=&period_id=` query +/// params (no `OData` `$filter` / `Page` envelope). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RevenueDisaggregationResponse { + pub entries: Vec, +} + +impl From for RevenueDisaggregationResponse { + fn from(d: bss_ledger_sdk::RevenueDisaggregation) -> Self { + Self { + entries: d + .entries + .into_iter() + .map(RevenueDisaggregationEntryDto::from) + .collect(), + } + } +} + +// ── Schedule change/cancel request/response DTOs (§3.6 / §4.6, Group H) ─────── + +/// One replacement recognition segment on a `replace` change (Group H). Maps to +/// the SDK [`bss_ledger_sdk::ChangeSegment`]; ignored on a `cancel`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ChangeSegmentDto { + /// Fiscal `period_id` (`YYYYMM`) this replacement segment recognizes into. + pub period_id: String, + /// Minor-unit amount of this segment (`>= 0`). + pub amount_minor: i64, +} + +impl From for bss_ledger_sdk::ChangeSegment { + fn from(s: ChangeSegmentDto) -> Self { + Self { + period_id: s.period_id, + amount_minor: s.amount_minor, + } + } +} + +/// The `POST /recognition-schedules/{schedule_id}/changes` request body: change or +/// cancel an ASC 606 recognition schedule (design §3.6 / §4.6). `{schedule_id}` +/// (the ACTIVE schedule to change) comes from the PATH; the target seller ledger +/// is the body's own `tenant_id` (tenant in body, not path — the vhp-core REST +/// convention); the `(entry, post)` PEP gate authorizes it (a change marks/mints +/// schedule state). `change_id` is the idempotency key (a replay returns the prior +/// result, minting no second schedule). `action` is `"cancel"` | `"replace"`. +/// `treatment` is the upstream modification-accounting decision: `"prospective"` / +/// `"separate_contract"` apply; `"catch_up"` / unknown is rejected +/// `MODIFICATION_TREATMENT_REVIEW` (the ledger never silently treats a +/// modification as prospective). `new_segments` is required for a `replace` (the +/// NEW version's plan of the remaining deferred), ignored for a `cancel`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ChangeRecognitionScheduleRequest { + /// The seller tenant whose schedule this changes; the PEP gate target. + /// Carried in the body, not the path. + pub tenant_id: Uuid, + /// The change idempotency key — a replay returns the prior result. + pub change_id: String, + /// The change action: `"cancel"` | `"replace"`. + pub action: String, + /// The upstream modification treatment: `"prospective"` | `"separate_contract"` + /// (apply) | `"catch_up"` / unknown (review). + pub treatment: String, + /// The replacement segments for a `replace` (the new version's plan of the + /// remaining deferred); omit for a `cancel`. + pub new_segments: Option>, +} + +impl ChangeRecognitionScheduleRequest { + /// Lower the wire DTO into the SDK [`bss_ledger_sdk::ChangeRecognitionSchedule`], + /// binding `schedule_id` from the request PATH (the body carries no schedule + /// id — mirrors [`AllocatePaymentRequest::into_sdk`]) and validating the + /// client-supplied id lengths at the boundary. The `action` / `treatment` + /// literals are parsed deeper (in the change service), so a bad value surfaces + /// as `MODIFICATION_TREATMENT_REVIEW` / an unknown-action `InvalidArgument` + /// from there — not here. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `schedule_id` (path) or `change_id` + /// (body) is empty or exceeds [`MAX_BUSINESS_ID_LEN`]. + pub fn into_sdk( + self, + schedule_id: String, + ) -> Result { + validate_business_id("schedule_id", &schedule_id)?; + validate_business_id("change_id", &self.change_id)?; + Ok(bss_ledger_sdk::ChangeRecognitionSchedule { + tenant_id: self.tenant_id, + schedule_id, + change_id: self.change_id, + action: self.action, + treatment: self.treatment, + new_segments: self + .new_segments + .map(|v| v.into_iter().map(Into::into).collect()), + }) + } +} + +/// The `POST /recognition-schedules/{schedule_id}/changes` response: a small +/// reference to the change's outcome. `new_schedule_id` is the successor version's +/// id on a `replace` (`null` on a `cancel`); `status` is the original schedule's +/// resulting status (`"REPLACED"` | `"CANCELLED"`). Always `200` (the change is +/// applied or an idempotent replay). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ScheduleChangeResponse { + /// The original schedule that was cancelled / replaced. + pub schedule_id: String, + /// The successor schedule version's id on a `replace`; `null` on a `cancel`. + pub new_schedule_id: Option, + /// The original schedule's resulting status (`"REPLACED"` | `"CANCELLED"`). + pub status: String, +} + +impl From for ScheduleChangeResponse { + fn from(r: bss_ledger_sdk::ScheduleChangeRef) -> Self { + Self { + schedule_id: r.schedule_id, + new_schedule_id: r.new_schedule_id, + status: r.status, + } + } +} + +// ── Recognition-schedule read DTOs (§3.7 / §4, GET /recognition-schedules/{id}) ─ + +/// One recognition segment in the `GET /recognition-schedules/{schedule_id}` +/// response: the `segment_no` (immutable, 1:1 with `period_id`), the period it +/// recognizes into, its minor-unit amount, and its release status +/// (`PENDING` | `QUEUED` | `DONE`). A row of [`RecognitionScheduleResponse::segments`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RecognitionScheduleSegmentDto { + /// The immutable segment number (1:1 with `period_id`). + pub segment_no: i32, + /// The fiscal period this segment recognizes into (`YYYYMM`). + pub period_id: String, + /// The segment's minor-unit amount. + pub amount_minor: i64, + /// The release status (`PENDING` | `QUEUED` | `DONE`). + pub status: String, +} + +impl From for RecognitionScheduleSegmentDto { + fn from(s: bss_ledger_sdk::RecognitionScheduleSegmentView) -> Self { + Self { + segment_no: s.segment_no, + period_id: s.period_id, + amount_minor: s.amount_minor, + status: s.status, + } + } +} + +/// The `GET /recognition-schedules/{schedule_id}` response: one schedule's +/// lifecycle view (design §3.7 / §4) — the schedule header (status, version, +/// revenue stream, currency, total-deferred / recognized-to-date, the originating +/// invoice + the §4.7 item-link anchor, the PO / subscription / policy refs) plus +/// its segments, ordered by `segment_no` (period order). A schedule outside the +/// caller's authorized subtree (or simply absent) yields a 404 — never this body +/// (no existence leak). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +// The `*_ref` / `*_id` fields mirror the `recognition_schedule` columns verbatim +// (the storage/SDK contract); renaming to satisfy `struct_field_names` would +// diverge from `RecognitionScheduleView`. +#[allow(clippy::struct_field_names)] +pub struct RecognitionScheduleResponse { + /// The schedule's business id. + pub schedule_id: String, + /// The durable lifecycle status (`ACTIVE` | `REPLACED` | `CANCELLED` | …). + pub status: String, + /// The lineage version (`0` fresh; `old + 1` for a `replace` successor). + pub version: i64, + /// The revenue stream the obligation books to (one schedule per stream). + pub revenue_stream: String, + /// ISO-4217 currency (one schedule/account per currency). + pub currency: String, + /// The total deferred Contract-liability the schedule plans to release. + pub total_deferred_minor: i64, + /// The cumulative recognized-to-date (`<= total_deferred_minor`). + pub recognized_minor: i64, + /// The originating posted invoice. + pub source_invoice_id: String, + /// The Contract-liability invoice line the schedule draws down (the §4.7 + /// invoice-link anchor). + pub source_invoice_item_ref: String, + /// The PO / allocation group this obligation books under; `null` when none. + pub po_allocation_group: Option, + /// The subscription / entitlement this obligation belongs to; `null` when none. + pub subscription_ref: Option, + /// The immutable deferral/timing policy version stamped at build. + pub policy_ref: String, + /// The schedule's segments, ordered by `segment_no` (period order). + pub segments: Vec, +} + +impl From for RecognitionScheduleResponse { + fn from(v: bss_ledger_sdk::RecognitionScheduleView) -> Self { + Self { + schedule_id: v.schedule_id, + status: v.status, + version: v.version, + revenue_stream: v.revenue_stream, + currency: v.currency, + total_deferred_minor: v.total_deferred_minor, + recognized_minor: v.recognized_minor, + source_invoice_id: v.source_invoice_id, + source_invoice_item_ref: v.source_invoice_item_ref, + po_allocation_group: v.po_allocation_group, + subscription_ref: v.subscription_ref, + policy_ref: v.policy_ref, + segments: v + .segments + .into_iter() + .map(RecognitionScheduleSegmentDto::from) + .collect(), + } + } +} + +/// One recognition schedule header in the `GET /recognition-schedules` list +/// response — like [`RecognitionScheduleResponse`] but WITHOUT segments (the +/// by-id `GET /recognition-schedules/{schedule_id}` carries those). Maps from +/// [`bss_ledger_sdk::RecognitionScheduleSummaryView`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[allow(clippy::struct_field_names)] +pub struct RecognitionScheduleSummaryDto { + /// The schedule's business id. + pub schedule_id: String, + /// The durable lifecycle status (`ACTIVE` | `REPLACED` | `CANCELLED` | …). + pub status: String, + /// The lineage version (`0` fresh; `old + 1` for a `replace` successor). + pub version: i64, + /// The revenue stream the obligation books to (one schedule per stream). + pub revenue_stream: String, + /// ISO-4217 currency (one schedule/account per currency). + pub currency: String, + /// The total deferred Contract-liability the schedule plans to release. + pub total_deferred_minor: i64, + /// The cumulative recognized-to-date (`<= total_deferred_minor`). + pub recognized_minor: i64, + /// The originating posted invoice. + pub source_invoice_id: String, + /// The Contract-liability invoice line the schedule draws down. + pub source_invoice_item_ref: String, + /// The PO / allocation group this obligation books under; `null` when none. + pub po_allocation_group: Option, + /// The subscription / entitlement this obligation belongs to; `null` when none. + pub subscription_ref: Option, + /// The immutable deferral/timing policy version stamped at build. + pub policy_ref: String, +} + +impl From for RecognitionScheduleSummaryDto { + fn from(v: bss_ledger_sdk::RecognitionScheduleSummaryView) -> Self { + Self { + schedule_id: v.schedule_id, + status: v.status, + version: v.version, + revenue_stream: v.revenue_stream, + currency: v.currency, + total_deferred_minor: v.total_deferred_minor, + recognized_minor: v.recognized_minor, + source_invoice_id: v.source_invoice_id, + source_invoice_item_ref: v.source_invoice_item_ref, + po_allocation_group: v.po_allocation_group, + subscription_ref: v.subscription_ref, + policy_ref: v.policy_ref, + } + } +} + +/// The `GET /recognition-schedules` response: the recognition schedule headers +/// matching the `(tenant_id[, invoice_id][, revenue_stream])` filter (segments +/// omitted — the by-id surface carries those). A discovery surface for the +/// server-minted `schedule_id`; an empty list is a normal `200`, never a `404`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RecognitionScheduleListResponse { + /// The matching schedule headers (`0..=cap`). + pub schedules: Vec, + /// `true` when the result was capped server-side — more schedules exist than + /// returned (the list surface is not paginated). A client seeing `truncated` + /// must narrow the filter (`invoice_id` / `revenue_stream`) to see the rest. + pub truncated: bool, +} + +// ── PII erasure / re-identification request + response DTOs (§4.5, Group 3A) ── + +/// `POST …/audit/erasure` request body: the payer to erase. The free-text +/// investigation reason is the **`X-Investigation-Reason` header** (§5 — the +/// single source for the reason recorded on the `erasure` record), not a body +/// field. `target_scope` opens a DIFFERENT tenant's PII map (the cross-tenant +/// DPO path, §5) — absent or the caller's own ⇒ routine same-tenant erasure; a +/// different tenant is forensic-gated (authorized for `(entry, erase)` on the +/// target + a required reason, else `CROSS_TENANT_ACCESS_DENIED` / +/// `MISSING_INVESTIGATION_REASON`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ErasureRequestDto { + pub payer_tenant_id: Uuid, + /// The tenant whose PII map to erase (defaults to the caller's own). + pub target_scope: Option, +} + +/// `POST …/audit/reidentify` request body: the payer to re-identify + the +/// machine `reason_code`. The free-text `reason` is the **`X-Investigation-Reason` +/// header** (§5), not a body field; both reason and `reason_code` are required +/// (a missing one is rejected `MISSING_INVESTIGATION_REASON`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ReidentifyRequestDto { + pub payer_tenant_id: Uuid, + pub reason_code: String, + /// The tenant whose PII map to re-identify against (defaults to the caller's + /// own). A different tenant is the forensic cross-tenant path (§5): + /// authorized for `(entry, reidentify)` on the target, else + /// `CROSS_TENANT_ACCESS_DENIED`. + pub target_scope: Option, +} + +/// `POST …/audit/reidentify` response: the recovered opaque `pii_ref` (the +/// pointer into the external PII store; never the PII itself). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ReidentifyResponseDto { + pub pii_ref: String, +} + +// ── Credit-note / debit-note / exposure DTOs (Slice 3 §4.2 / §4.3 / §4.7, Group E) ─ +// +// Wire shape: `snake_case` + the gear's flat money triple (`amount_minor` + +// `currency` + advisory `scale`), the SAME convention every other ledger DTO +// uses (the `api_dto` macro fixes `snake_case`, see the module header) — NOT the +// design doc's illustrative `camelCase` / nested `{amountMinor,currency,scale}` +// money object (that overrides nothing here; the dto.rs convention is +// authoritative and dylint-enforced, exactly as the payment / dispute / credit +// surfaces already ship). `scale` is advisory on the request: the ledger resolves +// the authoritative per-line scale from the provisioned currency config (mirrors +// `SettlePaymentRequest`); the credit/debit-note domain requests carry no scale. + +/// The `POST /credit-notes` request body: a compensating credit note against a +/// posted invoice (design §4.2). The target seller ledger is the body's own +/// `tenant_id` (tenant in body, not path — the vhp-core REST convention); the +/// `(entry, post)` PEP gate authorizes it (a credit note posts a balanced +/// compensating journal entry — the same data-plane post action as the +/// recognition run / invoice post). Idempotent on `credit_note_id` (the +/// `(tenant, CREDIT_NOTE, credit_note_id)` engine claim): a replay returns the +/// prior posting with `replayed = true`. +/// +/// `amount_minor` is **incl-tax**; `tax_minor` is the reversed-tax slice of it; +/// `requested_deferred_minor` is the **split intent** — how much of the ex-tax +/// revenue portion (`amount_minor − tax_minor`) targets the unreleased deferred +/// balance (the rest reduces recognized revenue). `goodwill = true` ⇒ an AR-only +/// goodwill credit (debits `GOODWILL`, touches no schedule, MUST carry +/// `requested_deferred_minor = 0`). A malformed shape (negative amounts, tax over +/// amount, deferred over ex-tax, empty reason, goodwill-with-deferred) is rejected +/// `400` (`AMOUNT_OUT_OF_RANGE` / `InvalidArgument`) by the domain `validate_shape`; +/// an indeterminable split is `CREDIT_NOTE_SPLIT_AMBIGUOUS`; an over-headroom note +/// is `CREDIT_NOTE_EXCEEDS_HEADROOM`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +// The `*_id` / `*_ref` fields mirror the domain `CreditNoteRequest` / storage +// column names verbatim; renaming to satisfy `struct_field_names` would diverge +// from the contract. +#[allow(clippy::struct_field_names)] +pub struct CreditNoteRequest { + /// The seller tenant whose ledger this posts into; the PEP gate target. + /// Carried in the body, not the path. + pub tenant_id: Uuid, + /// The tenant the original invoice billed (the AR / wallet owner). + pub payer_tenant_id: Uuid, + /// The `(tenant, CREDIT_NOTE, credit_note_id)` idempotency business id + the + /// `credit_note` row PK. + pub credit_note_id: String, + /// The originating posted invoice the note credits (its rows are never + /// mutated). + pub origin_invoice_id: String, + /// The targeted posted invoice-item ref (the line being credited); `None` for + /// an invoice-level credit. + pub origin_invoice_item_ref: Option, + /// The PO / allocation group the targeted line books under (the split-basis + /// dimension); `None` for a line with no group. + pub po_allocation_group: Option, + /// The revenue stream the credit books against (per-stream legs carry it). + pub revenue_stream: String, + /// ISO-4217 currency of the note (all legs share it). + pub currency: String, + /// Advisory currency scale; the ledger resolves the authoritative one. + pub scale: u8, + /// The note amount **incl-tax**, in minor units (`>= 0`, `> 0` enforced). + pub amount_minor: i64, + /// The tax slice of `amount_minor` to reverse onto `TAX_PAYABLE` (`>= 0`, + /// `<= amount_minor`). + pub tax_minor: i64, + /// The authoritative tax breakdown — one component per `(jurisdiction, + /// filing-period, rate)`, each reversing onto its OWN `TAX_PAYABLE` leg so + /// `tax_subbalance` disaggregates (§4.5). Empty ⇒ a single dimensionless tax leg + /// from `tax_minor` (legacy). A non-empty breakdown MUST sum to `tax_minor` (a + /// `400` otherwise); `tax_minor` stays the split scalar. + pub tax: Vec, + /// The split **intent**: how much of the ex-tax revenue amount targets the + /// unreleased deferred balance (`0 <= … <= amount_minor − tax_minor`). MUST be + /// `0` when `goodwill` is set. + pub requested_deferred_minor: i64, + /// The mandatory business reason code (AC #14) recorded on the `credit_note` + /// row. + pub reason_code: String, + /// `true` ⇒ an AR-only goodwill credit (`GOODWILL`, no schedule reduction); + /// `None` ⇒ `false`. + pub goodwill: Option, +} + +impl CreditNoteRequest { + /// Lower the wire DTO into the domain + /// [`crate::domain::adjustment::credit_note::CreditNoteRequest`], validating + /// the client-supplied id lengths at the boundary (the amounts / goodwill shape + /// are validated by the domain `validate_shape` in the handler — a `400` from + /// there). The advisory `scale` is dropped (the handler resolves the + /// authoritative scale). `goodwill` defaults to `false`. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `credit_note_id` / `origin_invoice_id` + /// (or, when present, `origin_invoice_item_ref`) is empty or exceeds + /// [`MAX_BUSINESS_ID_LEN`]. + pub fn into_domain( + self, + ) -> Result { + validate_business_id("credit_note_id", &self.credit_note_id)?; + validate_business_id("origin_invoice_id", &self.origin_invoice_id)?; + if let Some(item) = &self.origin_invoice_item_ref { + validate_business_id("origin_invoice_item_ref", item)?; + } + Ok(crate::domain::adjustment::credit_note::CreditNoteRequest { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + credit_note_id: self.credit_note_id, + origin_invoice_id: self.origin_invoice_id, + origin_invoice_item_ref: self.origin_invoice_item_ref, + po_allocation_group: self.po_allocation_group, + revenue_stream: self.revenue_stream, + currency: self.currency, + amount_minor: self.amount_minor, + tax_minor: self.tax_minor, + tax: self.tax.into_iter().map(TaxBreakdown::from).collect(), + requested_deferred_minor: self.requested_deferred_minor, + reason_code: self.reason_code, + goodwill: self.goodwill.unwrap_or(false), + }) + } +} + +/// The `POST /credit-notes` response: a reference to the compensating-entry +/// posting. A fresh post renders `201`, an idempotent replay `200` (the handler +/// reads `replayed`). Mirrors [`PostingRefDto`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct CreditNoteResponse { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, +} + +impl From for CreditNoteResponse { + fn from(r: bss_ledger_sdk::PostingRef) -> Self { + Self { + entry_id: r.entry_id, + created_seq: r.created_seq, + replayed: r.replayed, + } + } +} + +/// One planned leg of a governed manual adjustment (design §4.6). `account_class` +/// + `side` carry the stable wire literals (parsed at [`ManualAdjustmentRequest::into_domain`]): +/// the class is an [`AccountClass`] token (e.g. `"SUSPENSE"`, `"CASH_CLEARING"`, +/// `"AR"`, `"GOODWILL"`); the side is `"DR"` / `"CR"` ([`Side`]). A leg outside the +/// action's code-owned allow-list — or any `REVENUE` / `CONTRACT_LIABILITY` leg, or +/// an unpaired `CONTRA_REVENUE` write-off — is rejected by the domain `govern` gate +/// (`400 MANUAL_ADJUSTMENT_NOT_ALLOWED`). `revenue_stream` is carried only for a +/// per-stream class and is otherwise `null`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ManualLegDto { + /// The [`AccountClass`] wire token this leg posts to (parsed in `into_domain`). + pub account_class: String, + /// The [`Side`] wire token: `"DR"` (debit) / `"CR"` (credit). + pub side: String, + /// The leg amount in minor units (`> 0`; the domain `govern` rejects + /// zero/negative legs). + pub amount_minor: i64, + /// The revenue stream — `Some` only for a per-stream class; `null` otherwise. + pub revenue_stream: Option, +} + +/// The `POST /manual-adjustments` request body: a *governed* manual adjustment +/// (design §4.6) — the ledger's escape hatch for corrections the typed flows +/// (invoice / settle / allocate / S3 notes / S4 recognition) do not cover (rounding +/// residue, suspense / cash-clearing clean-up). The target seller ledger is the +/// body's own `tenant_id` (tenant in body, not path); the `(entry, post)` PEP gate +/// authorizes it. Idempotent on `adjustment_id` (the `(tenant, MANUAL_ADJUSTMENT, +/// adjustment_id)` engine claim): a replay returns the prior posting with `replayed +/// = true`. +/// +/// `action` selects a code-owned allow-list of account classes the legs may touch; +/// `REVENUE` / `CONTRACT_LIABILITY` are globally off-limits and an unpaired +/// `CONTRA_REVENUE` leg is rejected as an attempted write-off (all `400 +/// MANUAL_ADJUSTMENT_NOT_ALLOWED`). A `reason_code` is mandatory (AC #14). The +/// preparer actor is the AUTHENTICATED subject (stamped server-side, never read from +/// the body); the approver is assigned by the dual-control approval flow. A +/// governed adjustment whose gross (`Σ DR`) crosses the tenant's D2 threshold routes +/// to dual-control (`409 DUAL_CONTROL_REQUIRED`) instead of posting inline. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ManualAdjustmentRequest { + /// The seller tenant whose ledger this posts into; the PEP gate target. + /// Carried in the body, not the path. + pub tenant_id: Uuid, + /// The payer tenant the legs attribute to when the adjustment touches a + /// payer-scoped balance (`AR` / `UNALLOCATED`); `null` for a payer-less internal + /// clean-up. + pub payer_tenant_id: Option, + /// The `(tenant, MANUAL_ADJUSTMENT, adjustment_id)` idempotency business id. + pub adjustment_id: String, + /// The governed action wire token (`"ROUNDING_CORRECTION"` / + /// `"SUSPENSE_CLEAR"`) — selects the allow-list, parsed in `into_domain`. + pub action: String, + /// ISO-4217 currency of the adjustment (every leg shares it). + pub currency: String, + /// The legs to post — must net to zero (`Σ DR == Σ CR`, enforced by `govern`). + pub legs: Vec, + /// The mandatory business reason code (AC #14); an empty/blank one is rejected. + pub reason_code: String, + /// The authoritative tax breakdown for a tax-bearing action (never recomputed); + /// usually empty for the MVP actions. + pub tax: Vec, +} + +impl ManualAdjustmentRequest { + /// Lower the wire DTO into the domain + /// [`crate::domain::adjustment::manual::ManualAdjustmentRequest`], parsing the + /// action + each leg's `account_class` / `side` literals at the boundary (a bad + /// literal is a clean `400`, not a deep failure). `preparer_actor_id` is the + /// AUTHENTICATED subject (passed in by the handler from `ctx.subject_id()`), + /// never trusted from the body; `approver_actor_id` is `None` on the POST (it is + /// assigned by the dual-control approval flow). The amounts / balance / allow-list + /// are validated by the domain `govern` gate in the handler. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `adjustment_id` is empty / over + /// [`MAX_BUSINESS_ID_LEN`], when `action` is not a known + /// [`crate::domain::adjustment::manual::ManualAdjustmentAction`], or when a leg's + /// `account_class` / `side` literal does not parse. + pub fn into_domain( + self, + preparer_actor_id: Uuid, + ) -> Result { + validate_business_id("adjustment_id", &self.adjustment_id)?; + let action = crate::domain::adjustment::manual::ManualAdjustmentAction::parse(&self.action) + .ok_or_else(|| { + DomainError::InvalidRequest(format!( + "unknown manual-adjustment action {:?}", + self.action + )) + })?; + let legs = self + .legs + .into_iter() + .map(|leg| { + let account_class = leg.account_class.parse::().map_err(|_| { + DomainError::InvalidRequest(format!( + "unknown account_class {:?}", + leg.account_class + )) + })?; + let side = leg.side.parse::().map_err(|_| { + DomainError::InvalidRequest(format!("unknown side {:?}", leg.side)) + })?; + Ok(crate::domain::adjustment::manual::ManualLeg { + account_class, + side, + amount_minor: leg.amount_minor, + revenue_stream: leg.revenue_stream, + }) + }) + .collect::, DomainError>>()?; + Ok(crate::domain::adjustment::manual::ManualAdjustmentRequest { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + adjustment_id: self.adjustment_id, + action, + currency: self.currency, + legs, + reason_code: self.reason_code, + preparer_actor_id, + // The approver is assigned by the dual-control approval flow, never the + // POST body (the preparer is the authenticated subject; SoD is enforced + // by the ApprovalService when the gross crosses the D2 threshold). + approver_actor_id: None, + tax: self.tax.into_iter().map(TaxBreakdown::from).collect(), + }) + } +} + +/// The `POST /manual-adjustments` response: a reference to the governed +/// adjustment's posting. A fresh post renders `201`, an idempotent replay `200` +/// (the handler reads `replayed`). Mirrors [`CreditNoteResponse`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ManualAdjustmentResponse { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, +} + +impl From for ManualAdjustmentResponse { + fn from(r: bss_ledger_sdk::PostingRef) -> Self { + Self { + entry_id: r.entry_id, + created_seq: r.created_seq, + replayed: r.replayed, + } + } +} + +/// The `POST /debit-notes` request body: an *additional charge* against a posted +/// invoice — a DIRECT split mirroring the Slice-1 invoice-post (design §4.3). The +/// target seller ledger is the body's own `tenant_id` (tenant in body, not path); +/// the `(entry, post)` PEP gate authorizes it. Idempotent on `debit_note_id` (the +/// `(tenant, DEBIT_NOTE, debit_note_id)` engine claim): a replay returns the prior +/// posting with `replayed = true`. A debit note **raises** the invoice's headroom +/// (`debit_note_total_minor += amount`); it cannot trip the headroom cap. +/// +/// `amount_minor` is **incl-tax** (the single DR `AR`); `tax_minor` is the posted +/// tax evidence (CR `TAX_PAYABLE`, never recomputed); `deferred_minor` is how much +/// of the ex-tax revenue portion (`amount_minor − tax_minor`) defers to +/// `CONTRACT_LIABILITY` (the rest recognizes now to `REVENUE`). When +/// `deferred_minor > 0` the `recognition` spec drives the schedule build (D4 — the +/// SAME `ScheduleBuilder` path the invoice-post uses) and is REQUIRED (a deferred +/// note with no spec is a `400`). A malformed shape is rejected `400` by the +/// domain `validate_shape`; a closed payer is `PAYER_CLOSED`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +#[allow(clippy::struct_field_names)] +pub struct DebitNoteRequest { + /// The seller tenant whose ledger this posts into; the PEP gate target. + /// Carried in the body, not the path. + pub tenant_id: Uuid, + /// The tenant the original invoice billed (the AR owner the charge lands on). + pub payer_tenant_id: Uuid, + /// The `(tenant, DEBIT_NOTE, debit_note_id)` idempotency business id + the + /// `debit_note` row PK. + pub debit_note_id: String, + /// The originating posted invoice (whose headroom this raises; its rows are + /// never mutated). + pub origin_invoice_id: String, + /// The targeted posted invoice-item ref — anchors the freshly-built schedule's + /// NOT-NULL `source_invoice_item_ref` when the note defers (§4.7). Required + /// (non-empty) for a deferred note; optional (lineage only) otherwise. + pub origin_invoice_item_ref: Option, + /// The revenue stream the charge books against (per-stream legs carry it). + pub revenue_stream: String, + /// ISO-4217 currency of the note (all legs share it). + pub currency: String, + /// Advisory currency scale; the ledger resolves the authoritative one. + pub scale: u8, + /// The note amount **incl-tax**, in minor units (`>= 0`, `> 0` enforced) — the + /// single DR `AR`. + pub amount_minor: i64, + /// The tax slice of `amount_minor` posted onto `TAX_PAYABLE` (`>= 0`, + /// `<= amount_minor`). Posted tax evidence — never recomputed. + pub tax_minor: i64, + /// The authoritative tax breakdown — one component per `(jurisdiction, + /// filing-period, rate)`, each posting onto its OWN `TAX_PAYABLE` leg so + /// `tax_subbalance` disaggregates (§4.5). Empty ⇒ a single dimensionless tax leg + /// from `tax_minor` (legacy). A non-empty breakdown MUST sum to `tax_minor` (a + /// `400` otherwise); `tax_minor` stays the split scalar. + pub tax: Vec, + /// How much of the ex-tax revenue amount is deferred to `CONTRACT_LIABILITY` + /// (`0 <= … <= amount_minor − tax_minor`); the rest recognizes now. `0` ⇒ fully + /// recognized (no `CONTRACT_LIABILITY` line, no schedule build). + pub deferred_minor: i64, + /// The mandatory business reason / context code (AC #14). Non-empty. + pub reason_code: String, + /// The ASC 606 recognition spec (Slice 4 — the SAME shape the invoice-post + /// item carries). REQUIRED when `deferred_minor > 0` (drives the schedule + /// build, D4); `None` for a fully-recognized note. + pub recognition: Option, +} + +impl DebitNoteRequest { + /// Lower the wire DTO into the domain + /// [`crate::domain::adjustment::debit_note::DebitNoteRequest`], validating the + /// client-supplied id lengths + lowering the optional `recognition` block at + /// the boundary. The advisory `scale` is dropped (the handler resolves the + /// authoritative scale). + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `debit_note_id` / `origin_invoice_id` + /// (or, when present, `origin_invoice_item_ref`) is empty or exceeds + /// [`MAX_BUSINESS_ID_LEN`], or when the `recognition` block carries an invalid + /// timing. + pub fn into_domain( + self, + ) -> Result { + validate_business_id("debit_note_id", &self.debit_note_id)?; + validate_business_id("origin_invoice_id", &self.origin_invoice_id)?; + if let Some(item) = &self.origin_invoice_item_ref { + validate_business_id("origin_invoice_item_ref", item)?; + } + let recognition = self + .recognition + .map(RecognitionInputDto::into_domain) + .transpose()?; + Ok(crate::domain::adjustment::debit_note::DebitNoteRequest { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + debit_note_id: self.debit_note_id, + origin_invoice_id: self.origin_invoice_id, + origin_invoice_item_ref: self.origin_invoice_item_ref, + revenue_stream: self.revenue_stream, + currency: self.currency, + amount_minor: self.amount_minor, + tax_minor: self.tax_minor, + tax: self.tax.into_iter().map(TaxBreakdown::from).collect(), + deferred_minor: self.deferred_minor, + reason_code: self.reason_code, + recognition, + }) + } +} + +/// The `POST /debit-notes` response: a reference to the direct-split posting. A +/// fresh post renders `201`, an idempotent replay `200`. Mirrors [`PostingRefDto`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct DebitNoteResponse { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, +} + +impl From for DebitNoteResponse { + fn from(r: bss_ledger_sdk::PostingRef) -> Self { + Self { + entry_id: r.entry_id, + created_seq: r.created_seq, + replayed: r.replayed, + } + } +} + +/// The `GET /invoices/{invoice_id}/exposure` response: an invoice's credit-note +/// **headroom** (the `invoice_exposure` counter) plus its **true remaining AR** +/// (the payment-reduced open receivable, design §4.7). The headroom is the room +/// left for further credit notes: `remaining_headroom_minor = original_total_minor +/// + `debit_note_total_minor` − `credit_note_total_minor`` (the slack in the +/// ``credit_note_total_minor` <= `original_total_minor` + `debit_note_total_minor`` +/// CHECK, AC #24). ``open_ar_minor`` is the SEPARATE current open AR (what a credit +/// note's `CR AR` leg is capped at before the wallet remainder, K-2) — distinct +/// from the headroom (which never decreases with payments). Tenant-scoped +/// (SQL-level BOLA): an invoice with no exposure row yet (no note ever posted) — +/// or one outside the caller's subtree — yields a `404` (no existence leak). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[allow(clippy::struct_field_names)] +pub struct InvoiceExposureResponse { + /// The invoice the exposure is for. + pub invoice_id: String, + /// ISO-4217 currency of the exposure counters. + pub currency: String, + /// The seeded original posted AR incl. tax (the headroom basis). + pub original_total_minor: i64, + /// The running Σ debit-note incl-tax totals (raises the headroom). + pub debit_note_total_minor: i64, + /// The running Σ credit-note incl-tax totals (consumes the headroom). + pub credit_note_total_minor: i64, + /// The remaining credit-note headroom = `original + debit − credit` (`>= 0`). + pub remaining_headroom_minor: i64, + /// The invoice's current open AR incl. tax (payment-reduced) — the `CR AR` cap + /// a credit note fills before spilling to the wallet remainder. SEPARATE from + /// the headroom. + pub open_ar_minor: i64, +} + +// ── Refund request/response DTOs (§4.4 / §5 / §7, Group G — money-OUT) ──────── + +/// The `POST /refunds` request body: one PSP refund phase to record (the +/// **money-out** side that unwinds a settled receipt). The target seller ledger +/// is the body's own `tenant_id` (tenant in body, not path — the vhp-core REST +/// convention); the `(entry, post)` PEP gate authorizes it (a refund posts a +/// balanced journal entry into the seller's ledger, like the credit/debit notes, +/// design §8 / Phase-1 precedent). `pattern` selects the economic shape +/// (`"A_UNALLOCATED"` / `"B_RESTORE_AR"`); `phase` the lifecycle stage +/// (`"initiated"` / `"confirmed"` / `"rejected"` / `"voided"` / +/// `"unknown_final"`). `invoice_id` is required for Pattern B (the AR it +/// restores), absent for Pattern A. `two_stage` defaults to `true` (the +/// conservative `REFUND_CLEARING` shape). `relates_to_refund_id` + `direction` +/// drive a refund-of-refund (Group E); a first-order refund omits both (direction +/// defaults to outbound). `scale` is advisory; the ledger resolves the +/// authoritative currency scale. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +// `*_id` fields mirror the domain `RefundRequest` / `refund` columns verbatim; +// renaming to satisfy `struct_field_names` would diverge from the contract. +#[allow(clippy::struct_field_names)] +pub struct RefundRequest { + /// The seller tenant whose ledger this posts into; the PEP gate target. + /// Carried in the body, not the path. + pub tenant_id: Uuid, + /// The tenant the refund returns cash to (the original payer). + pub payer_tenant_id: Uuid, + /// The business id of this refund — the `refund` row's surrogate PK + + /// the `GET /refunds/{refundId}` handle (NOT the idempotency key). + pub refund_id: String, + /// The PSP's refund id — the idempotency grain together with `phase`. + pub psp_refund_id: String, + /// The lifecycle phase: `initiated` | `confirmed` | `rejected` | `voided` | + /// `unknown_final`. + pub phase: String, + /// The economic pattern: `A_UNALLOCATED` | `B_RESTORE_AR`. + pub pattern: String, + /// The origin settled payment the refund unwinds (NOT NULL both patterns, D7). + pub payment_id: String, + /// The invoice whose AR the refund restores — REQUIRED for Pattern B, MUST be + /// absent for Pattern A (validated by the domain `validate_shape`). + pub invoice_id: Option, + /// ISO-4217 currency (all legs share it; MUST match the origin settlement's). + pub currency: String, + /// The cash to return, in minor units (`> 0`). + pub amount_minor: i64, + /// Advisory currency scale; the ledger resolves the authoritative one. + pub scale: u8, + /// `true` (default) ⇒ the two-stage `REFUND_CLEARING` shape; `false` ⇒ the + /// single-step shape (D1). `None` ⇒ `true`. + pub two_stage: Option, + /// The prior refund this one references (refund-of-refund link); `None` for a + /// first-order refund. + pub relates_to_refund_id: Option, + /// The economic direction (refund-of-refund only): `OUTBOUND` | `CLAWBACK`. + /// `None` ⇒ `OUTBOUND` for a first-order refund (the domain default is + /// claw-back ONLY when a `relates_to_refund_id` is set; a first-order refund is + /// always outbound, so the DTO defaults to outbound here and lets + /// `validate_shape` require the link for an explicit claw-back). + pub direction: Option, +} + +impl RefundRequest { + /// Lower the wire DTO into the domain + /// [`RefundRequest`](crate::domain::adjustment::refund::RefundRequest), + /// parsing the string-typed `phase` / `pattern` / `direction` enums and + /// validating the client-supplied id lengths at the boundary. The deeper + /// shape rules (Pattern-B `invoice_id`, single-step phase, claw-back link) are + /// the domain's `validate_shape`, run by the handler. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `phase` / `pattern` / `direction` is + /// not a known literal, or when `refund_id` / `psp_refund_id` / `payment_id` / + /// (when present) `invoice_id` / `relates_to_refund_id` is empty or exceeds + /// [`MAX_BUSINESS_ID_LEN`]. + pub fn into_domain( + self, + ) -> Result { + use crate::domain::adjustment::refund::{RefundDirection, RefundPattern, RefundPhase}; + validate_business_id("refund_id", &self.refund_id)?; + validate_business_id("psp_refund_id", &self.psp_refund_id)?; + validate_business_id("payment_id", &self.payment_id)?; + if let Some(invoice_id) = &self.invoice_id { + validate_business_id("invoice_id", invoice_id)?; + } + if let Some(rel) = &self.relates_to_refund_id { + validate_business_id("relates_to_refund_id", rel)?; + } + let phase = RefundPhase::parse(&self.phase).ok_or_else(|| { + DomainError::InvalidRequest(format!( + "unknown refund phase {:?} (expected initiated|confirmed|rejected|voided|\ + unknown_final)", + self.phase + )) + })?; + let pattern = RefundPattern::parse(&self.pattern).ok_or_else(|| { + DomainError::InvalidRequest(format!( + "unknown refund pattern {:?} (expected A_UNALLOCATED|B_RESTORE_AR)", + self.pattern + )) + })?; + // A first-order refund (no link) is always outbound; an explicit + // `direction` is parsed, defaulting to OUTBOUND when omitted. The domain + // `validate_shape` is the authority on requiring the link for a claw-back. + let direction = match self.direction.as_deref() { + None => RefundDirection::Outbound, + Some(d) => RefundDirection::parse(d).ok_or_else(|| { + DomainError::InvalidRequest(format!( + "unknown refund direction {d:?} (expected OUTBOUND|CLAWBACK)" + )) + })?, + }; + Ok(crate::domain::adjustment::refund::RefundRequest { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + refund_id: self.refund_id, + psp_refund_id: self.psp_refund_id, + phase, + pattern, + payment_id: self.payment_id, + invoice_id: self.invoice_id, + currency: self.currency, + amount_minor: self.amount_minor, + two_stage: self.two_stage.unwrap_or(true), + relates_to_refund_id: self.relates_to_refund_id, + direction, + }) + } +} + +/// The `POST /refunds` (and the refund leg of `POST /refund-with-credit-note`) +/// response when the refund POSTED inline: a reference to the refund-stage +/// posting. A fresh stage renders `201`, an idempotent replay `200` (the handler +/// reads `replayed`). Mirrors [`SettlePaymentResponse`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RefundResponse { + pub entry_id: Uuid, + pub created_seq: i64, + pub replayed: bool, +} + +impl From for RefundResponse { + fn from(r: bss_ledger_sdk::PostingRef) -> Self { + Self { + entry_id: r.entry_id, + created_seq: r.created_seq, + replayed: r.replayed, + } + } +} + +/// The status token a quarantined (refund-before-payment) refund renders in +/// [`RefundQuarantinedResponse::status`] — a kebab-case literal in a normal JSON +/// body, NOT a `problem+json` error: a refund whose origin payment has not landed +/// is accepted-but-quarantined (HTTP 202), never rejected and NEVER posted +/// (design §4.4 / PRD L668 / Rev2 E-11). DISTINCT from queue-and-apply: a +/// quarantined refund only ever posts after an explicit, re-validating +/// de-quarantine. +pub const REFUND_QUARANTINED_STATUS: &str = "refund-quarantined"; + +/// The `POST /refunds` response when the refund references a payment with no +/// resolvable origin settlement: the refund was durably QUARANTINED (HTTP 202 +/// Accepted), NOT posted. `status` is the fixed `refund-quarantined` token; `flow` +/// + `business_id` are the quarantine queue key and `quarantined_at` the intake +/// instant. No posting handle — nothing has posted (and de-quarantine re-validates +/// all §4.7 caps + the then-current D2 threshold + dispute state before any post). +/// Mirrors [`AllocationQueuedResponse`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RefundQuarantinedResponse { + /// Always `refund-quarantined` (a normal-body kebab-case token, not an error). + pub status: String, + /// The quarantine queue flow (the `REFUND_QUARANTINE` literal). + pub flow: String, + /// The quarantine/dedup business id — `psp_refund_id:phase`. + pub business_id: String, + /// When the intake durably quarantined the request. + pub quarantined_at: DateTime, +} + +/// The status token a dispute-held refund renders in +/// [`RefundDisputeHeldResponse::status`] — a kebab-case literal in a normal JSON +/// body, NOT a `problem+json` error: a refund whose origin payment has an OPEN +/// dispute is accepted-but-held (HTTP 202), never rejected and NEVER posted (Z5-2 / +/// design §5). The cash leg is held until the dispute resolves WON (the hold drain +/// re-drives it) or LOST (the hold is cancelled — the chargeback already returned +/// the money). +pub const REFUND_DISPUTE_HELD_STATUS: &str = "refund-dispute-held"; + +/// The `POST /refunds` response when the refund's origin payment has an OPEN +/// dispute: the refund's cash leg was durably HELD (HTTP 202 Accepted), NOT posted +/// (Z5-2 / design §5). `status` is the fixed `refund-dispute-held` token; `flow` + +/// `business_id` are the dispute-hold queue key and `held_at` the intake instant. No +/// posting handle — nothing has posted (and the hold drain re-validates the dispute +/// state + all §4.7 caps + the then-current D2 threshold before any post). Mirrors +/// [`RefundQuarantinedResponse`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RefundDisputeHeldResponse { + /// Always `refund-dispute-held` (a normal-body kebab-case token, not an error). + pub status: String, + /// The dispute-hold queue flow (the `REFUND_DISPUTE_HOLD` literal). + pub flow: String, + /// The dispute-hold/dedup business id — `psp_refund_id:phase`. + pub business_id: String, + /// When the intake durably held the request. + pub held_at: DateTime, +} + +/// The `POST /refund-with-credit-note` request body: post a refund AND its paired +/// S3 credit note ATOMICALLY in one transaction as two linked entries (K-3, +/// design §4.4). The refund carries the credit note's id (`credit_note_id`) so the +/// two are linked; AR is never overstated between them (both commit or neither). +/// The target seller ledger is the body's own `tenant_id`; the `(entry, post)` PEP +/// gate authorizes it. Carries BOTH the refund request and the credit-note request +/// inline (each its own idempotency grain) — a composite, not a discriminated +/// union. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct RefundWithCreditNoteRequest { + /// The S5 refund to post (money-out). + pub refund: RefundRequest, + /// The paired S3 credit note to post atomically with the refund. + pub credit_note: CreditNoteRequest, +} + +/// The `POST /refund-with-credit-note` response: references to BOTH posted entries +/// (the refund + the paired credit note), committed atomically. Always `201` on a +/// fresh composite (an idempotent replay of an already-posted composite renders +/// `200`; both halves replay together since they share the post txn). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RefundWithCreditNoteResponse { + /// The posted refund entry id. + pub refund_entry_id: Uuid, + /// The posted credit-note entry id (the paired second entry). + pub credit_note_entry_id: Uuid, + /// `true` ⇒ an idempotent replay of an already-posted composite (both halves). + pub replayed: bool, +} + +/// The `GET /refunds/{refundId}` response: the recorded refund + its clearing +/// state (Group G). Drawn from the `refund` row (the surrogate `(tenant, +/// refund_id)` grain). `clearing_state` is the two-stage `REFUND_CLEARING` drain +/// state (`PENDING` ⇒ stage-1 open / `SETTLED` ⇒ drained or single-step / +/// `REVERSED` ⇒ a PSP reject/void line-negated the stage-1). Tenant-scoped +/// (SQL-level BOLA): an unknown refund — or one outside the caller's subtree — +/// yields a `404` (no existence leak). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[allow(clippy::struct_field_names)] +pub struct RefundView { + pub refund_id: String, + pub psp_refund_id: String, + /// The latest lifecycle phase recorded on this refund. + pub phase: String, + /// The economic pattern (`A_UNALLOCATED` / `B_RESTORE_AR`). + pub pattern: String, + pub payment_id: String, + pub invoice_id: Option, + pub currency: String, + pub amount_minor: i64, + /// The `REFUND_CLEARING` drain state (`PENDING` / `SETTLED` / `REVERSED`). + pub clearing_state: String, + /// The refund-of-refund forward link (`None` for a first-order refund). + pub relates_to_refund_id: Option, + /// The negated stage-1 entry id on a PSP reject/void (`None` otherwise). + pub reverses_entry_id: Option, +} + +impl From for RefundView { + fn from(r: crate::infra::storage::entity::refund::Model) -> Self { + Self { + refund_id: r.refund_id, + psp_refund_id: r.psp_refund_id, + phase: r.phase, + pattern: r.pattern, + payment_id: r.payment_id, + invoice_id: r.invoice_id, + currency: r.currency, + amount_minor: r.amount_minor, + clearing_state: r.clearing_state, + relates_to_refund_id: r.relates_to_refund_id, + reverses_entry_id: r.reverses_entry_id, + } + } +} + +/// The `GET /credit-notes` / `GET /credit-notes/{creditNoteId}` response: the +/// recorded credit note (Phase 1b / read-surface §5). Drawn from the +/// `credit_note` row (the `(tenant, credit_note_id)` grain). `amount_minor` is +/// incl-tax; `recognized_part_minor` + `deferred_part_minor` are the ex-tax split +/// parts and do NOT sum to `amount_minor` (no CHECK — they mirror the entity). +/// Tenant-scoped (SQL-level BOLA): an unknown credit note — or one outside the +/// caller's subtree — yields a `404` (no existence leak). Mirrors [`RefundView`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[allow(clippy::struct_field_names)] +pub struct CreditNoteView { + pub credit_note_id: String, + pub origin_invoice_id: String, + /// The originating invoice item the note targets (`None` when whole-invoice). + pub origin_invoice_item_ref: Option, + pub revenue_stream: String, + pub currency: String, + pub amount_minor: i64, + /// The ex-tax recognized part of the split (does NOT sum with the deferred + /// part to `amount_minor`). + pub recognized_part_minor: i64, + /// The ex-tax deferred part of the split. + pub deferred_part_minor: i64, + /// The schedule/split basis the `RecognizedDeferredSplitter` keyed on + /// (`None` when the split needed no schedule basis). + pub split_basis_ref: Option, + pub reason_code: String, + pub created_at_utc: DateTime, +} + +impl From for CreditNoteView { + fn from(c: crate::infra::storage::entity::credit_note::Model) -> Self { + Self { + credit_note_id: c.credit_note_id, + origin_invoice_id: c.origin_invoice_id, + origin_invoice_item_ref: c.origin_invoice_item_ref, + revenue_stream: c.revenue_stream, + currency: c.currency, + amount_minor: c.amount_minor, + recognized_part_minor: c.recognized_part_minor, + deferred_part_minor: c.deferred_part_minor, + split_basis_ref: c.split_basis_ref, + reason_code: c.reason_code, + created_at_utc: c.created_at_utc, + } + } +} + +/// The `GET /debit-notes` / `GET /debit-notes/{debitNoteId}` response: the +/// recorded debit note — an additional charge (Phase 1b / read-surface §5). Drawn +/// from the `debit_note` row (the `(tenant, debit_note_id)` grain). `amount_minor` +/// is incl-tax; `recognized_part_minor` + `deferred_part_minor` are the ex-tax +/// split parts and do NOT sum to `amount_minor` (no CHECK). The `debit_note` +/// table is leaner than `credit_note` (NO `revenue_stream` / `reason_code` / +/// item ref). Tenant-scoped (SQL-level BOLA): an unknown debit note — or one +/// outside the caller's subtree — yields a `404` (no existence leak). Mirrors +/// [`CreditNoteView`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[allow(clippy::struct_field_names)] +pub struct DebitNoteView { + pub debit_note_id: String, + pub origin_invoice_id: String, + pub currency: String, + pub amount_minor: i64, + /// The ex-tax recognized part of the split. + pub recognized_part_minor: i64, + /// The ex-tax deferred part of the split. + pub deferred_part_minor: i64, + pub created_at_utc: DateTime, +} + +impl From for DebitNoteView { + fn from(d: crate::infra::storage::entity::debit_note::Model) -> Self { + Self { + debit_note_id: d.debit_note_id, + origin_invoice_id: d.origin_invoice_id, + currency: d.currency, + amount_minor: d.amount_minor, + recognized_part_minor: d.recognized_part_minor, + deferred_part_minor: d.deferred_part_minor, + created_at_utc: d.created_at_utc, + } + } +} + +/// The `GET /disputes` / `GET /disputes/{disputeId}` response: the chargeback +/// dispute's current state (read-surface R3). Drawn from the `ledger_dispute` row +/// (the `(tenant, dispute_id)` grain) — its chosen `variant` (`CASH_HOLD` / +/// `AR_RECLASS`), the current `cycle` + `last_phase` (`OPENED` / `WON` / `LOST`), +/// the `disputed_amount_minor`, and the `cash_hold_minor` actually moved into +/// `DISPUTE_HOLD` at open (`0` for `AR_RECLASS`). The persisted `version` (the +/// optimistic-concurrency counter) is INTERNAL and not surfaced. Tenant-scoped +/// (SQL-level BOLA): an unknown dispute — or one outside the caller's subtree — +/// yields a `404` (no existence leak). Mirrors [`RefundView`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[allow(clippy::struct_field_names)] +pub struct DisputeView { + pub dispute_id: String, + pub payment_id: String, + pub currency: String, + /// The chosen variant (`CASH_HOLD` ⇒ cash moved to `DISPUTE_HOLD` at open / + /// `AR_RECLASS` ⇒ AR reclassed `ACTIVE`→`DISPUTED`, no cash leg). + pub variant: String, + /// The latest phase recorded on this dispute (`OPENED` / `WON` / `LOST`). + pub last_phase: String, + /// The dispute cycle (re-opens advance it; the `last_phase` is at this cycle). + pub cycle: i32, + pub disputed_amount_minor: i64, + /// The cash held in `DISPUTE_HOLD` at open (`0` for `AR_RECLASS`) — the size + /// the `won`/`lost` outcome releases / forfeits. + pub cash_hold_minor: i64, +} + +impl From for DisputeView { + fn from(d: crate::infra::storage::entity::dispute::Model) -> Self { + Self { + dispute_id: d.dispute_id, + payment_id: d.payment_id, + currency: d.currency, + variant: d.variant, + last_phase: d.last_phase, + cycle: d.cycle, + disputed_amount_minor: d.disputed_amount_minor, + cash_hold_minor: d.cash_hold_minor, + } + } +} + +/// The `GET /recognition-runs` / `GET /recognition-runs/{run_id}` response: one +/// recorded ASC 606 recognition run (read-surface R4). Drawn from the +/// `recognition_run` row (the `(tenant, period_id, run_id)` grain — the run is the +/// orchestration wrapper that released a period's due segments). `status` is the +/// run lifecycle (`RUNNING` ⇒ in-flight / `DONE` ⇒ completed / `FAILED` ⇒ aborted); +/// `started_at_utc` is when the run began. Tenant-scoped (SQL-level BOLA): an +/// unknown run — or one outside the caller's subtree — yields a `404` (no existence +/// leak). Mirrors [`DisputeView`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RecognitionRunView { + pub run_id: Uuid, + /// The fiscal period (`YYYYMM`) the run released due segments for. + pub period_id: String, + /// The run lifecycle (`RUNNING` / `DONE` / `FAILED`). + pub status: String, + pub started_at_utc: DateTime, +} + +impl From for RecognitionRunView { + fn from(r: crate::infra::storage::entity::recognition_run::Model) -> Self { + Self { + run_id: r.run_id, + period_id: r.period_id, + status: r.status, + started_at_utc: r.started_at_utc, + } + } +} + +/// The `GET /payments/{payment_id}/settlement` response: the per-payment money-out +/// serialization counters (read-surface R4). Drawn from the `payment_settlement` +/// row (the `(tenant, payment_id)` grain) — the settled / fee / allocated / +/// refunded / clawed-back running totals the money-out caps serialize against. +/// The persisted `version` (the optimistic-concurrency counter) is INTERNAL and +/// not surfaced. Tenant-scoped (SQL-level BOLA): a payment that was never settled +/// — or one outside the caller's subtree — yields a `404` (no existence leak). +/// Mirrors [`RefundView`]. +#[derive(Debug, Clone)] +#[allow(clippy::struct_field_names)] +#[toolkit_macros::api_dto(response)] +pub struct SettlementView { + pub payment_id: String, + pub currency: String, + /// The gross settled amount recorded for the payment (money-in). + pub settled_minor: i64, + /// The PSP fee withheld from the settled receipt. + pub fee_minor: i64, + /// The portion of the pool already drained to open AR (money-out). + pub allocated_minor: i64, + /// The portion already returned to the payer via refunds. + pub refunded_minor: i64, + /// The portion refunded from the still-unallocated pool (Pattern A). + pub refunded_unallocated_minor: i64, + /// The portion clawed back (a refund-of-refund / PSP claw-back). + pub clawed_back_minor: i64, +} + +impl From for SettlementView { + fn from(s: crate::infra::storage::entity::payment_settlement::Model) -> Self { + Self { + payment_id: s.payment_id, + currency: s.currency, + settled_minor: s.settled_minor, + fee_minor: s.fee_minor, + allocated_minor: s.allocated_minor, + refunded_minor: s.refunded_minor, + refunded_unallocated_minor: s.refunded_unallocated_minor, + clawed_back_minor: s.clawed_back_minor, + } + } +} + +/// One row of the `GET /journal-entries` header list (read-surface R5). A +/// LIGHTWEIGHT projection of a `journal_entry` HEADER — the entry coordinate + +/// audit dims a caller filters / cross-cuts on (`source_doc_type` ⇒ all +/// `MANUAL_ADJUSTMENT` / `REFUND` / `CREDIT_NOTE` entries, `source_business_id` +/// ⇒ all entries of one business document, `period_id` ⇒ all entries of a +/// period). Carries NO lines and NONE of the tamper-evidence hash-chain fields +/// (`row_hash` / `prev_hash` / `prev_entry_id` / `prev_period_id` are chain +/// internals); a caller that needs the lines reads the full entry via +/// `GET /journal-entries/{entryId}` (which returns the richer [`EntryDto`]). +/// Drawn from the `journal_entry` row (the `(entry_id, tenant_id, period_id)` +/// grain; `entry_id` is the default keyset-order column). Tenant-scoped +/// (SQL-level BOLA): the page never contains a foreign-tenant header (no +/// existence leak). Mirrors [`RefundView`]. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct EntryHeaderView { + pub entry_id: Uuid, + /// The fiscal period (`YYYYMM`) the entry posted into. + pub period_id: String, + /// The entry's booking currency (the header-level currency). + pub entry_currency: String, + /// The business document class (`INVOICE_POST` / `MANUAL_ADJUSTMENT` / + /// `REFUND` / `CREDIT_NOTE` / …) — the primary cross-cut filter dim. + pub source_doc_type: String, + /// The originating business document id (the invoice / note / refund ref). + pub source_business_id: String, + /// The reversed entry id when this header is itself a reversal (`None` + /// otherwise). + pub reverses_entry_id: Option, + pub posted_at_utc: DateTime, + pub effective_at: NaiveDate, + /// The posting origin (the channel / driver that emitted the entry). + pub origin: String, + /// The DB-assigned per-tenant monotonic posting sequence. + pub created_seq: i64, +} + +impl From for EntryHeaderView { + fn from(e: crate::infra::storage::entity::journal_entry::Model) -> Self { + Self { + entry_id: e.entry_id, + period_id: e.period_id, + entry_currency: e.entry_currency, + source_doc_type: e.source_doc_type, + source_business_id: e.source_business_id, + reverses_entry_id: e.reverses_entry_id, + posted_at_utc: e.posted_at_utc, + effective_at: e.effective_at, + origin: e.origin, + created_seq: e.created_seq, + } + } +} + +/// The `GET /payers/{payer_tenant_id}/state` response: a payer's ledger lifecycle +/// state for the caller's seller tenant (read-surface). `lifecycle_state` is the +/// payer's current state (`OPEN` / `CLOSED`); `closed_with_open_balance` records +/// whether a close was approved over an outstanding balance (the dual-control +/// disposition). Tenant-scoped (SQL-level BOLA): a payer with no recorded state — +/// or outside the caller's subtree — yields a `404` (no existence leak). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[allow(clippy::struct_field_names)] +pub struct PayerStateView { + /// The payer tenant whose lifecycle state this is. + pub payer_tenant_id: Uuid, + /// The payer's current ledger lifecycle state (`OPEN` / `CLOSED`). + pub lifecycle_state: String, + /// `true` when the payer was CLOSED while still holding an outstanding balance + /// (an approved dual-control disposition); `false` for a clean close. + pub closed_with_open_balance: bool, + /// The approver who signed off a close-with-balance (`None` for a clean / open + /// payer). + pub approved_by: Option, + /// When the lifecycle state last changed (`None` if never transitioned). + pub changed_at: Option>, +} + +impl From for PayerStateView { + fn from(m: crate::infra::storage::entity::payer_state::Model) -> Self { + Self { + payer_tenant_id: m.payer_tenant_id, + lifecycle_state: m.lifecycle_state, + closed_with_open_balance: m.closed_with_open_balance, + approved_by: m.approved_by, + changed_at: m.changed_at, + } + } +} + +/// The `GET /dual-control-policy` response (read-surface): the tenant's EFFECTIVE +/// dual-control threshold policy — the version in force now (greatest +/// `effective_from <= now`, highest `version` on a tie), or the ratified platform +/// defaults when the tenant has set no policy row. `is_default` is `true` in the +/// latter case (`version` / `effective_from` are then `None`). Tenant-scoped +/// (SQL-level BOLA): a tenant outside the caller's subtree reads as the platform +/// defaults — the thresholds are public constants, so no existence/value leak. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct DualControlPolicyView { + /// The D2 amount threshold in USD-equivalent minor units: a governed money-out + /// / grant at or above this needs preparer→approver sign-off. + pub d2_threshold_minor: i64, + /// The A6 material-backdating window in business days. + pub a6_backdating_biz_days: i32, + /// The TTL (seconds) a fresh `PENDING` / `NEEDS_REWORK` approval lives before + /// it expires. + pub pending_ttl_seconds: i64, + /// The `effective_from` instant of the version in force (`None` when the + /// platform defaults apply — the tenant has no row). + pub effective_from: Option>, + /// The `version` number in force (`None` when the platform defaults apply). + pub version: Option, + /// `true` when no tenant row applies and these are the ratified platform + /// defaults; `false` when a configured version is in force. + pub is_default: bool, +} + +impl DualControlPolicyView { + /// Build from the effective policy version (`None` ⇒ the ratified platform + /// defaults, `is_default = true`). + #[must_use] + pub fn from_effective(effective: Option) -> Self { + // No in-force row ⇒ render the ratified platform defaults (guard clause). + let Some(v) = effective else { + let d = DualControlPolicy::DEFAULT; + return Self { + d2_threshold_minor: d.d2_threshold_minor, + a6_backdating_biz_days: d.a6_backdating_biz_days, + pending_ttl_seconds: d.pending_ttl_seconds, + effective_from: None, + version: None, + is_default: true, + }; + }; + Self { + d2_threshold_minor: v.policy.d2_threshold_minor, + a6_backdating_biz_days: v.policy.a6_backdating_biz_days, + pending_ttl_seconds: v.policy.pending_ttl_seconds, + effective_from: Some(v.effective_from), + version: Some(v.version), + is_default: false, + } + } +} + +// ── FX & multi-currency (Slice 5) ──────────────────────────────────────────── + +/// Light boundary check for a currency code on an FX ingest: non-empty, ASCII, +/// ≤ 10 chars (the gear admits non-ISO/crypto codes — same envelope as the +/// `read_unallocated` query guard). An unvalidated code would silently match zero +/// rows at lock time instead of a clean 400 here. +fn check_currency_code(field: &str, code: &str) -> Result<(), DomainError> { + if code.is_empty() || code.len() > 10 || !code.is_ascii() { + return Err(DomainError::InvalidRequest(format!( + "{field} must be a non-empty ASCII code of at most 10 chars, got {code:?}" + ))); + } + Ok(()) +} + +/// Secondary manual / seed ingest of one FX rate into the local `ledger_fx_rate` +/// store (the primary path is the `RateProviderV1` plugin pull, design §4.6 / +/// decision 2). Upsert-keyed on `(tenant_id, base_currency, quote_currency, +/// provider)`: re-posting the same tuple overwrites the quote (`rate_micro` / +/// `as_of` / `fallback_order`) — idempotent on `(tenant, base, quote, provider, +/// as_of)`. `tenant_id` rides the body (the vhp-core write convention, no tenant +/// in the path). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct FxRateIngestRequest { + /// The seller tenant whose local rate store receives the row. + pub tenant_id: Uuid, + /// Transaction-side ISO-4217 code (the `base` of the `base → quote` rate). + pub base_currency: String, + /// Functional-side ISO-4217 code (the `quote`). + pub quote_currency: String, + /// Provider id recorded verbatim (the fallback-order key, e.g. `"ecb"`). + pub provider: String, + /// The rate as a fixed-precision multiplier (functional per unit transaction + /// × 1e6). Must be `> 0`. + pub rate_micro: i64, + /// The publication timestamp that drives the staleness rule. + pub as_of: DateTime, + /// The provider's precedence rank (0 = primary); defaults to `0` when omitted. + pub fallback_order: Option, +} + +impl FxRateIngestRequest { + /// Validate the ingest and return the resolved `fallback_order` (defaulted to + /// `0`). Rejects empty/oversized currency or provider codes, a non-positive + /// `rate_micro`, a negative `fallback_order`, and an identity (`base == + /// quote`) pair (a no-op rate the lock-time short-circuit never reads). + /// + /// # Errors + /// [`DomainError::InvalidRequest`] on any boundary violation (rendered 400). + pub fn validate(&self) -> Result { + check_currency_code("base_currency", &self.base_currency)?; + check_currency_code("quote_currency", &self.quote_currency)?; + if self.base_currency == self.quote_currency { + return Err(DomainError::InvalidRequest(format!( + "base_currency and quote_currency must differ (identity rate {} is never \ + locked — single-currency entries leave functional NULL)", + self.base_currency + ))); + } + if self.provider.is_empty() || self.provider.len() > 64 { + return Err(DomainError::InvalidRequest(format!( + "provider must be a non-empty code of at most 64 chars, got {:?}", + self.provider + ))); + } + if self.rate_micro <= 0 { + return Err(DomainError::InvalidRequest(format!( + "rate_micro must be > 0, got {}", + self.rate_micro + ))); + } + let fallback_order = self.fallback_order.unwrap_or(0); + if fallback_order < 0 { + return Err(DomainError::InvalidRequest(format!( + "fallback_order must be >= 0, got {fallback_order}" + ))); + } + Ok(fallback_order) + } +} + +/// Confirmation of a stored FX rate (echoes the now-current `ledger_fx_rate` +/// row's key + quote so a seeder can read back what it ingested). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct FxRateIngestResponse { + pub tenant_id: Uuid, + pub base_currency: String, + pub quote_currency: String, + pub provider: String, + pub rate_micro: i64, + pub as_of: DateTime, + pub fallback_order: i32, +} + +/// An immutable `ledger_fx_rate_snapshot` row — the frozen rate a journal line's +/// `rate_snapshot_ref` points at, reproducing its exact lock-time translation. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct FxRateSnapshotResponse { + pub rate_id: Uuid, + pub tenant_id: Uuid, + pub base_currency: String, + pub quote_currency: String, + pub rate_micro: i64, + pub as_of: DateTime, + pub provider: String, + pub stale: bool, + pub fallback_order: i32, + pub triangulated_via: Option, +} + +impl From for FxRateSnapshotResponse { + fn from(m: crate::infra::storage::entity::fx_rate_snapshot::Model) -> Self { + Self { + rate_id: m.rate_id, + tenant_id: m.tenant_id, + base_currency: m.base_currency, + quote_currency: m.quote_currency, + rate_micro: m.rate_micro, + as_of: m.as_of, + provider: m.provider, + stale: m.stale, + fallback_order: m.fallback_order, + triangulated_via: m.triangulated_via, + } + } +} + +/// Trigger an unrealized (Mode-B) revaluation for one period across the monetary +/// scopes `{AR, UNALLOCATED, REUSABLE_CREDIT}` (design §4.5). `tenant_id` rides +/// the body (the vhp-core write convention). Each scope is idempotent on +/// `(tenant, period_id, scope)` — re-posting the same period replays the +/// already-posted scopes. A no-op when the tenant is Mode-A +/// (`revaluation_enabled = false`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct RevaluationRunRequest { + /// The seller tenant whose foreign-currency monetary positions are remeasured. + pub tenant_id: Uuid, + /// The period to revalue (`YYYYMM`). Must be an OPEN period at run time (the + /// run posts INTO it); a CLOSED/absent period is rejected by the posting gate. + pub period_id: String, +} + +impl RevaluationRunRequest { + /// Validate the request: a well-formed `YYYYMM` period id. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] on a malformed period id (rendered 400). + pub fn validate(&self) -> Result<(), DomainError> { + if crate::domain::period::period_end_utc(&self.period_id).is_none() { + return Err(DomainError::InvalidRequest(format!( + "period_id must be a valid YYYYMM, got {:?}", + self.period_id + ))); + } + Ok(()) + } +} + +/// The outcome of a revaluation run — one entry per monetary scope. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RevaluationRunResponse { + /// The period that was revalued (`YYYYMM`). + pub period_id: String, + /// Per-scope outcomes (`AR` / `UNALLOCATED` / `REUSABLE_CREDIT`). + pub scopes: Vec, +} + +/// One monetary scope's revaluation outcome (aggregated across its per-payer +/// entries — an entry spans only one payer, so a scope fans out over payers). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RevaluationScopeOutcomeDto { + /// The monetary scope (`AR` / `UNALLOCATED` / `REUSABLE_CREDIT`). + pub scope: String, + /// The outcome: `posted` (entries written), `nothing_to_post` (no + /// cross-currency movement / a full idempotent replay), or `disabled` (Mode A). + pub status: String, + /// Entries freshly written this call (a full idempotent replay reports `0`). + pub entries: i64, + /// Grains moved across those entries (a forward run only; `0` for a no-op). + pub grains: i64, +} + +#[cfg(test)] +#[path = "dto_tests.rs"] +mod dto_tests; diff --git a/gears/bss/ledger/ledger/src/api/rest/dto_tests.rs b/gears/bss/ledger/ledger/src/api/rest/dto_tests.rs new file mode 100644 index 000000000..c8c2648f7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/dto_tests.rs @@ -0,0 +1,1099 @@ +//! Unit tests for the invoice-posting request DTOs: `snake_case` wire +//! deserialization + `into_domain` lowering (enum literals parsed at the +//! boundary; a bad literal is `DomainError::InvalidRequest` ⇒ HTTP 400). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +/// A valid `snake_case` `POST /journal-entries` body deserializes into the +/// request DTO and `into_domain` yields the expected `PostedInvoice`. +#[test] +fn post_invoice_body_deserializes_and_lowers_to_domain() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let payer = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + let actor = uuid::uuid!("99999999-8888-7777-6666-555555555555"); + let body = serde_json::json!({ + "tenant_id": tenant, + "invoice_id": "INV-42", + "payer_tenant_id": payer, + "effective_at": "2026-06-01", + "due_date": "2026-07-01", + "period_id": "202606", + "items": [ + { + "amount_minor_ex_tax": 1000, + "currency": "USD", + "revenue_stream": "subscription", + "catalog_class": "REVENUE", + "gl_code": "4000" + } + ], + "tax": [ + { + "amount_minor": 200, + "currency": "USD", + "tax_jurisdiction": "US-CA", + "tax_filing_period": "2026Q2" + } + ], + "correlation_id": actor + }); + + let dto: PostInvoiceRequestDto = + serde_json::from_value(body).expect("snake_case body must deserialize"); + // The poster identity is the authenticated subject, passed in by the handler + // (never read from the body). + let inv = dto + .into_domain(actor) + .expect("into_domain must lower cleanly"); + + assert_eq!(inv.invoice_id, "INV-42"); + assert_eq!(inv.seller_tenant_id, tenant, "seller = body tenant_id"); + assert_eq!( + inv.posted_by_actor_id, actor, + "posted_by_actor_id is the authenticated subject, stamped server-side" + ); + assert_eq!(inv.payer_tenant_id, payer); + assert_eq!(inv.period_id, "202606"); + assert_eq!(inv.items.len(), 1); + assert_eq!(inv.items[0].amount_minor_ex_tax, 1000); + assert_eq!(inv.items[0].revenue_stream, "subscription"); + assert_eq!( + inv.items[0].catalog_class, + Some(AccountClass::Revenue), + "catalog_class literal parsed at the boundary" + ); + assert!(inv.items[0].contract_class.is_none()); + assert_eq!(inv.tax.len(), 1); + assert_eq!(inv.tax[0].amount_minor, 200); + assert_eq!(inv.tax[0].tax_jurisdiction, "US-CA"); + // Gross is derived downstream; assert the helper agrees (1000 + 200). + assert_eq!(inv.gross_minor(), 1200); +} + +/// A contract-class override is parsed and wins precedence at mapping time. +#[test] +fn invoice_item_contract_class_override_parses() { + let dto = InvoiceItemDto { + amount_minor_ex_tax: 500, + currency: "USD".to_owned(), + revenue_stream: "usage".to_owned(), + catalog_class: Some("REVENUE".to_owned()), + contract_class: Some("CONTRA_REVENUE".to_owned()), + gl_code: None, + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }; + let item = dto.into_domain().expect("valid literals"); + assert_eq!(item.catalog_class, Some(AccountClass::Revenue)); + assert_eq!(item.contract_class, Some(AccountClass::ContraRevenue)); +} + +/// A missing mapping pair (both `None`) is valid at the DTO level — the line +/// routes to SUSPENSE/PENDING later, not a 400. +#[test] +fn invoice_item_without_mapping_is_valid() { + let dto = InvoiceItemDto { + amount_minor_ex_tax: 500, + currency: "USD".to_owned(), + revenue_stream: "usage".to_owned(), + catalog_class: None, + contract_class: None, + gl_code: None, + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }; + let item = dto + .into_domain() + .expect("a missing mapping is not an error"); + assert!(item.catalog_class.is_none()); + assert!(item.contract_class.is_none()); +} + +/// A bad `catalog_class` literal lowers to `DomainError::InvalidRequest` (the +/// REST layer maps this to a 400, never a 500 or a silent default). +#[test] +fn invoice_item_bad_account_class_is_invalid_request() { + let dto = InvoiceItemDto { + amount_minor_ex_tax: 100, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some("NOT_A_CLASS".to_owned()), + contract_class: None, + gl_code: None, + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }; + let err = dto + .into_domain() + .expect_err("a bad class literal must reject"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest, got {err:?}" + ); +} + +/// The mapping-correction body lowers its `corrected_items` the same way (bad +/// literal ⇒ `InvalidRequest`). +#[test] +fn mapping_correction_corrected_items_lower_to_domain() { + let dto = MappingCorrectionRequestDto { + reason: "wrong stream".to_owned(), + period_id: None, + effective_at: None, + corrected_items: vec![InvoiceItemDto { + amount_minor_ex_tax: 1000, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some("REVENUE".to_owned()), + contract_class: None, + gl_code: None, + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }], + }; + let items = dto.corrected_items_into_domain().expect("valid items"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].catalog_class, Some(AccountClass::Revenue)); +} + +// ── Slice 4: the optional recognition block ────────────────────────────────── + +/// An item WITHOUT a `recognition` block lowers to `recognition: None` + +/// `deferred_minor: 0` — the unchanged Variant-A default. +#[test] +fn invoice_item_without_recognition_defaults_to_no_deferral() { + let body = serde_json::json!({ + "amount_minor_ex_tax": 1000, + "currency": "USD", + "revenue_stream": "subscription", + "catalog_class": "REVENUE" + }); + let dto: InvoiceItemDto = + serde_json::from_value(body).expect("snake_case item must deserialize"); + let item = dto.into_domain().expect("valid item"); + assert!(item.recognition.is_none(), "absent recognition ⇒ None"); + assert_eq!( + item.deferred_minor, 0, + "deferred is always seeded 0 at the DTO" + ); +} + +/// A `straight_line` recognition block lowers to the domain timing (periods + +/// optional first period); the deferred amount is NOT taken from the wire. +#[test] +fn invoice_item_straight_line_recognition_lowers_to_domain() { + let body = serde_json::json!({ + "amount_minor_ex_tax": 1200, + "currency": "USD", + "revenue_stream": "subscription", + "catalog_class": "REVENUE", + "recognition": { + "policy_ref": "policy.sl.v1", + "timing": "straight_line", + "periods": 12, + "po_allocation_group": "grp-1", + "subscription_ref": "sub-1" + } + }); + let dto: InvoiceItemDto = + serde_json::from_value(body).expect("snake_case item must deserialize"); + let item = dto.into_domain().expect("valid recognition block"); + let rec = item.recognition.expect("recognition present"); + assert_eq!(rec.policy_ref, "policy.sl.v1"); + assert!( + matches!( + rec.timing, + RecognitionTiming::StraightLine { + periods: 12, + first_period_id: None + } + ), + "straight_line lowers with periods=12, first_period defaulted later" + ); + assert_eq!(rec.po_allocation_group.as_deref(), Some("grp-1")); + assert!(!rec.multi_po, "multi_po defaults to false when omitted"); + assert_eq!( + item.deferred_minor, 0, + "the DTO never carries the deferred amount" + ); +} + +/// A `point_in_time` recognition block lowers to `PointInTime` (an explicit +/// no-defer spec — distinct from absence, but same posting outcome). +#[test] +fn invoice_item_point_in_time_recognition_lowers_to_domain() { + let body = serde_json::json!({ + "amount_minor_ex_tax": 500, + "currency": "USD", + "revenue_stream": "usage", + "catalog_class": "REVENUE", + "recognition": { "policy_ref": "policy.pit.v1", "timing": "point_in_time" } + }); + let dto: InvoiceItemDto = + serde_json::from_value(body).expect("snake_case item must deserialize"); + let item = dto.into_domain().expect("valid recognition block"); + let rec = item.recognition.expect("recognition present"); + assert!(matches!(rec.timing, RecognitionTiming::PointInTime)); +} + +/// A `straight_line` block WITHOUT `periods` is rejected at the boundary +/// (`InvalidRequest` ⇒ HTTP 400), never a panic or a silent default. +#[test] +fn invoice_item_straight_line_without_periods_is_invalid_request() { + let body = serde_json::json!({ + "amount_minor_ex_tax": 1200, + "currency": "USD", + "revenue_stream": "subscription", + "catalog_class": "REVENUE", + "recognition": { "policy_ref": "p", "timing": "straight_line" } + }); + let dto: InvoiceItemDto = + serde_json::from_value(body).expect("snake_case item must deserialize"); + let err = dto + .into_domain() + .expect_err("straight_line without periods must reject"); + assert!(matches!(err, DomainError::InvalidRequest(_)), "got {err:?}"); +} + +/// An unknown `timing` literal is rejected at the boundary (`InvalidRequest`). +#[test] +fn invoice_item_unknown_timing_is_invalid_request() { + let body = serde_json::json!({ + "amount_minor_ex_tax": 100, + "currency": "USD", + "revenue_stream": "subscription", + "catalog_class": "REVENUE", + "recognition": { "policy_ref": "p", "timing": "milestone" } + }); + let dto: InvoiceItemDto = + serde_json::from_value(body).expect("snake_case item must deserialize"); + let err = dto + .into_domain() + .expect_err("an unknown timing must reject"); + assert!(matches!(err, DomainError::InvalidRequest(_)), "got {err:?}"); +} + +/// An invoice whose items/tax carry DIFFERENT currencies is rejected at the +/// boundary (`InvalidRequest`) — the builder would otherwise stamp the first +/// currency on every line and silently misattribute the others. +#[test] +fn mixed_currency_invoice_is_invalid_request() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let actor = uuid::uuid!("99999999-8888-7777-6666-555555555555"); + let body = serde_json::json!({ + "tenant_id": tenant, + "invoice_id": "INV-MIX", + "payer_tenant_id": tenant, + "effective_at": "2026-06-01", + "period_id": "202606", + "items": [ + {"amount_minor_ex_tax": 1000, "currency": "USD", "revenue_stream": "subscription"}, + {"amount_minor_ex_tax": 500, "currency": "EUR", "revenue_stream": "usage"} + ], + "tax": [], + "correlation_id": actor + }); + let dto: PostInvoiceRequestDto = + serde_json::from_value(body).expect("snake_case body must deserialize"); + let err = dto + .into_domain(actor) + .expect_err("a mixed-currency invoice must reject"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest, got {err:?}" + ); +} + +/// An allocate body WITHOUT `splits` lowers to the SDK type with `splits: None` +/// (Mode A/B precedence path — unchanged), and `payment_id` is bound from the +/// PATH (not the body). +#[test] +fn allocate_body_without_splits_lowers_with_none() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let payer = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + let body = serde_json::json!({ + "tenant_id": tenant, + "payer_tenant_id": payer, + "allocation_id": uuid::Uuid::now_v7(), + "lump_minor": 500, + "currency": "USD", + "scale": 2 + }); + let dto: AllocatePaymentRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + let sdk = dto + .into_sdk("PAY-1".to_owned()) + .expect("valid ids lower cleanly"); + assert_eq!(sdk.payment_id, "PAY-1", "payment_id bound from the path"); + assert!(sdk.splits.is_none(), "no caller split ⇒ precedence path"); +} + +/// An allocate body WITH `splits` (Mode B) deserializes `snake_case` and lowers +/// each share into the SDK `AllocationSplit`, preserving order. +#[test] +fn allocate_body_with_splits_lowers_to_sdk_split() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let payer = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + let body = serde_json::json!({ + "tenant_id": tenant, + "payer_tenant_id": payer, + "allocation_id": uuid::Uuid::now_v7(), + "lump_minor": 500, + "currency": "USD", + "scale": 2, + "splits": [ + { "invoice_id": "INV-B", "amount_minor": 300 }, + { "invoice_id": "INV-A", "amount_minor": 200 } + ] + }); + let dto: AllocatePaymentRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + let sdk = dto + .into_sdk("PAY-1".to_owned()) + .expect("valid ids lower cleanly"); + let splits = sdk.splits.expect("splits present"); + assert_eq!(splits.len(), 2); + assert_eq!(splits[0].invoice_id, "INV-B"); + assert_eq!(splits[0].amount_minor, 300); + assert_eq!(splits[1].invoice_id, "INV-A"); + assert_eq!(splits[1].amount_minor, 200); +} + +/// An over-long business id is rejected at the DTO boundary as a clean +/// `InvalidRequest` (⇒ HTTP 400) rather than reaching a `varchar(128)` column as +/// a 500. Covers a PATH-bound id (`payment_id`) and a BODY id (`psp_return_id`) — +/// the shared `validate_business_id` guards every id the same way. +#[test] +fn over_long_business_id_is_rejected_at_the_boundary() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let payer = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + let too_long = "X".repeat(MAX_BUSINESS_ID_LEN + 1); // one over the varchar(128) bound + + // PATH-bound payment_id on a return: rejected. + let ret: ReturnPaymentRequest = serde_json::from_value(serde_json::json!({ + "tenant_id": tenant, + "payer_tenant_id": payer, + "psp_return_id": "RET-1", + "amount_minor": 100, + "currency": "USD", + "scale": 2 + })) + .expect("body deserializes"); + assert!( + matches!( + ret.into_sdk(too_long.clone()), + Err(DomainError::InvalidRequest(_)) + ), + "an over-long payment_id must reject at the boundary, not reach the column" + ); + + // BODY-supplied psp_return_id: also rejected (valid path id, bad body id). + let ret2: ReturnPaymentRequest = serde_json::from_value(serde_json::json!({ + "tenant_id": tenant, + "payer_tenant_id": payer, + "psp_return_id": too_long, + "amount_minor": 100, + "currency": "USD", + "scale": 2 + })) + .expect("body deserializes"); + assert!( + matches!( + ret2.into_sdk("PAY-1".to_owned()), + Err(DomainError::InvalidRequest(_)) + ), + "an over-long psp_return_id must reject at the boundary" + ); + + // An empty id is likewise rejected (not silently accepted as a blank key). + let ret3: ReturnPaymentRequest = serde_json::from_value(serde_json::json!({ + "tenant_id": tenant, + "payer_tenant_id": payer, + "psp_return_id": "", + "amount_minor": 100, + "currency": "USD", + "scale": 2 + })) + .expect("body deserializes"); + assert!( + matches!( + ret3.into_sdk("PAY-1".to_owned()), + Err(DomainError::InvalidRequest(_)) + ), + "an empty psp_return_id must reject at the boundary" + ); +} + +/// A `kind = "grant"` credit-application body deserializes `snake_case` and +/// `into_sdk` lowers it to the SDK `CreditApplication::Grant` (the grant fields +/// are required; `targets` is moot). +#[test] +fn credit_application_grant_body_lowers_to_sdk_grant() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let payer = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + let body = serde_json::json!({ + "kind": "grant", + "tenant_id": tenant, + "payer_tenant_id": payer, + "credit_application_id": "CA-1", + "currency": "USD", + "scale": 2, + "amount_minor": 1500, + "credit_grant_event_type": "PROMO" + }); + let dto: CreditApplicationRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + let sdk = dto.into_sdk().expect("grant lowers cleanly"); + let bss_ledger_sdk::CreditApplication::Grant(g) = sdk else { + panic!("expected a Grant"); + }; + assert_eq!(g.tenant_id, tenant, "tenant = body tenant_id"); + assert_eq!(g.payer_tenant_id, payer); + assert_eq!(g.credit_application_id, "CA-1"); + assert_eq!(g.amount_minor, 1500); + assert_eq!(g.credit_grant_event_type, "PROMO"); +} + +/// A `kind = "apply"` credit-application body deserializes `snake_case` and +/// `into_sdk` lowers each target into the SDK `AllocationSplit`, preserving order +/// (the grant fields are moot). +#[test] +fn credit_application_apply_body_lowers_to_sdk_apply() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let payer = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + let body = serde_json::json!({ + "kind": "apply", + "tenant_id": tenant, + "payer_tenant_id": payer, + "credit_application_id": "CA-2", + "currency": "USD", + "scale": 2, + "targets": [ + { "invoice_id": "INV-B", "amount_minor": 300 }, + { "invoice_id": "INV-A", "amount_minor": 200 } + ] + }); + let dto: CreditApplicationRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + let sdk = dto.into_sdk().expect("apply lowers cleanly"); + let bss_ledger_sdk::CreditApplication::Apply(a) = sdk else { + panic!("expected an Apply"); + }; + assert_eq!(a.credit_application_id, "CA-2"); + assert_eq!(a.targets.len(), 2); + assert_eq!(a.targets[0].invoice_id, "INV-B"); + assert_eq!(a.targets[0].amount_minor, 300); + assert_eq!(a.targets[1].invoice_id, "INV-A"); + assert_eq!(a.targets[1].amount_minor, 200); +} + +/// A `grant` missing `amount_minor` is rejected `400 InvalidArgument` in +/// `into_sdk` (the kind-specific shape is validated at the boundary, not deep in +/// the post path). +#[test] +fn credit_application_grant_missing_amount_is_invalid() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let payer = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + let body = serde_json::json!({ + "kind": "grant", + "tenant_id": tenant, + "payer_tenant_id": payer, + "credit_application_id": "CA-3", + "currency": "USD", + "scale": 2, + "credit_grant_event_type": "PROMO" + }); + let dto: CreditApplicationRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + let err = dto + .into_sdk() + .expect_err("a grant without amount_minor must reject"); + assert_eq!( + err.status_code(), + 400, + "expected a 400 InvalidArgument, got {err:?}" + ); +} + +/// An apply with an empty `targets` is rejected `400 InvalidArgument` (an empty +/// split set has nothing to spend the wallet on). +#[test] +fn credit_application_apply_empty_targets_is_invalid() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let payer = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + let body = serde_json::json!({ + "kind": "apply", + "tenant_id": tenant, + "payer_tenant_id": payer, + "credit_application_id": "CA-4", + "currency": "USD", + "scale": 2, + "targets": [] + }); + let dto: CreditApplicationRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + assert!( + dto.into_sdk().is_err(), + "an apply with empty targets must reject" + ); +} + +/// An unknown `kind` is rejected `400 InvalidArgument` (neither grant nor apply). +#[test] +fn credit_application_unknown_kind_is_invalid() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let payer = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + let body = serde_json::json!({ + "kind": "refund", + "tenant_id": tenant, + "payer_tenant_id": payer, + "credit_application_id": "CA-5", + "currency": "USD", + "scale": 2 + }); + let dto: CreditApplicationRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + assert!(dto.into_sdk().is_err(), "an unknown kind must reject"); +} + +/// A `replace` change body deserializes (`snake_case`) and lowers to the SDK +/// command, binding `schedule_id` from the PATH and mapping `new_segments`. +#[test] +fn change_schedule_replace_body_lowers_to_sdk() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let body = serde_json::json!({ + "tenant_id": tenant, + "change_id": "CHG-1", + "action": "replace", + "treatment": "prospective", + "new_segments": [ + { "period_id": "202607", "amount_minor": 300 }, + { "period_id": "202608", "amount_minor": 500 } + ] + }); + let dto: ChangeRecognitionScheduleRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + let cmd = dto.into_sdk("SCH-1".to_owned()).expect("lowers to sdk"); + assert_eq!(cmd.tenant_id, tenant); + assert_eq!( + cmd.schedule_id, "SCH-1", + "schedule_id is bound from the PATH" + ); + assert_eq!(cmd.change_id, "CHG-1"); + assert_eq!(cmd.action, "replace"); + assert_eq!(cmd.treatment, "prospective"); + let segs = cmd.new_segments.expect("replace carries segments"); + assert_eq!(segs.len(), 2); + assert_eq!(segs[0].period_id, "202607"); + assert_eq!(segs[0].amount_minor, 300); + assert_eq!(segs[1].amount_minor, 500); +} + +/// A `cancel` change body lowers with `new_segments = None`. +#[test] +fn change_schedule_cancel_body_lowers_with_no_segments() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let body = serde_json::json!({ + "tenant_id": tenant, + "change_id": "CHG-2", + "action": "cancel", + "treatment": "prospective" + }); + let dto: ChangeRecognitionScheduleRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + let cmd = dto.into_sdk("SCH-2".to_owned()).expect("lowers to sdk"); + assert_eq!(cmd.action, "cancel"); + assert!(cmd.new_segments.is_none(), "cancel carries no segments"); +} + +/// An empty `change_id` is rejected at the boundary (the `varchar(128)` business +/// id convention), not deep in the change service. +#[test] +fn change_schedule_empty_change_id_is_rejected() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let body = serde_json::json!({ + "tenant_id": tenant, + "change_id": "", + "action": "cancel", + "treatment": "prospective" + }); + let dto: ChangeRecognitionScheduleRequest = + serde_json::from_value(body).expect("snake_case body must deserialize"); + assert!( + dto.into_sdk("SCH-3".to_owned()).is_err(), + "an empty change_id must reject" + ); +} + +/// The invoice-post response serializes the materialized schedules as a +/// `snake_case` `schedules` array (the discovery contract that lets a REST client +/// learn the server-minted `schedule_id`). Pins the wire field names so a rename +/// can't silently break a client. +#[test] +fn post_invoice_response_serializes_materialized_schedules() { + let dto = PostInvoiceResponseDto { + entry_id: uuid::uuid!("11111111-1111-1111-1111-111111111111"), + created_seq: 7, + replayed: false, + schedules: vec![MaterializedScheduleDto { + schedule_id: "SCH-1".to_owned(), + revenue_stream: "subscription".to_owned(), + source_invoice_item_ref: "ITEM-1".to_owned(), + }], + }; + let v = serde_json::to_value(&dto).expect("serialize"); + assert_eq!(v["created_seq"], serde_json::json!(7)); + assert_eq!(v["replayed"], serde_json::json!(false)); + assert_eq!(v["schedules"][0]["schedule_id"], serde_json::json!("SCH-1")); + assert_eq!( + v["schedules"][0]["revenue_stream"], + serde_json::json!("subscription") + ); + assert_eq!( + v["schedules"][0]["source_invoice_item_ref"], + serde_json::json!("ITEM-1") + ); +} + +/// The `GET /recognition-schedules` list response is a `snake_case` `schedules` +/// array of HEADER views — no `segments` key (those live on the by-id read), an +/// absent optional ref serializes as an explicit `null` (not a dropped key), and +/// the envelope carries the `truncated` cap signal. Pins EVERY header field's +/// wire name so a rename can't silently break a client. +#[test] +fn recognition_schedule_list_response_is_header_only() { + let dto = RecognitionScheduleListResponse { + schedules: vec![RecognitionScheduleSummaryDto { + schedule_id: "SCH-9".to_owned(), + status: "ACTIVE".to_owned(), + version: 0, + revenue_stream: "support".to_owned(), + currency: "USD".to_owned(), + total_deferred_minor: 1200, + recognized_minor: 100, + source_invoice_id: "INV-9".to_owned(), + source_invoice_item_ref: "ITEM-9".to_owned(), + po_allocation_group: None, + subscription_ref: None, + policy_ref: "pol-1".to_owned(), + }], + truncated: false, + }; + let v = serde_json::to_value(&dto).expect("serialize"); + assert_eq!( + v["truncated"], + serde_json::json!(false), + "cap signal present" + ); + let row = &v["schedules"][0]; + // Pin EVERY header field's wire name (rename guard). + assert_eq!(row["schedule_id"], serde_json::json!("SCH-9")); + assert_eq!(row["status"], serde_json::json!("ACTIVE")); + assert_eq!(row["version"], serde_json::json!(0)); + assert_eq!(row["revenue_stream"], serde_json::json!("support")); + assert_eq!(row["currency"], serde_json::json!("USD")); + assert_eq!(row["total_deferred_minor"], serde_json::json!(1200)); + assert_eq!(row["recognized_minor"], serde_json::json!(100)); + assert_eq!(row["source_invoice_id"], serde_json::json!("INV-9")); + assert_eq!(row["source_invoice_item_ref"], serde_json::json!("ITEM-9")); + assert_eq!(row["policy_ref"], serde_json::json!("pol-1")); + assert!( + row.get("segments").is_none(), + "the list view is header-only (segments live on the by-id read)" + ); + assert!( + matches!( + row.get("po_allocation_group"), + Some(serde_json::Value::Null) + ), + "an absent optional ref is an explicit null, not a dropped key" + ); + assert!( + matches!(row.get("subscription_ref"), Some(serde_json::Value::Null)), + "subscription_ref absent ⇒ explicit null" + ); +} + +// ── Read-surface view `From` mappings (refund / notes / dispute / +// recognition-run / settlement / entry-header / payer-state) ───────────────────── +// +// Each test builds the full entity `Model` with a DISTINCT sentinel per field, +// converts via `View::from(model)`, and asserts every view field equals the +// source. The EXCLUDED columns (`tenant_id`, the optimistic-`version` counter, +// the entry hash-chain internals, …) are simply not present on the view — the +// struct literal compiles without reading them, which is the implicit guard that +// they stay off the wire. + +/// `RefundView::from(refund::Model)` maps every surfaced field; the entity's +/// `tenant_id` / `created_at_utc` / `version` are intentionally NOT on the view. +#[test] +fn refund_view_from_model_maps_all_fields() { + let model = crate::infra::storage::entity::refund::Model { + tenant_id: uuid::uuid!("11111111-1111-1111-1111-111111111111"), + refund_id: "RFND-1".to_owned(), + psp_refund_id: "PSP-RFND-1".to_owned(), + phase: "CLEARED".to_owned(), + pattern: "A_UNALLOCATED".to_owned(), + payment_id: "PAY-1".to_owned(), + invoice_id: Some("INV-1".to_owned()), + currency: "USD".to_owned(), + amount_minor: 1234, + clearing_state: "SETTLED".to_owned(), + relates_to_refund_id: Some("RFND-0".to_owned()), + reverses_entry_id: Some(uuid::uuid!("22222222-2222-2222-2222-222222222222")), + created_at_utc: chrono::DateTime::from_timestamp(1_700_000_001, 0).expect("ts"), + version: 7, + }; + let view = RefundView::from(model); + assert_eq!(view.refund_id, "RFND-1"); + assert_eq!(view.psp_refund_id, "PSP-RFND-1"); + assert_eq!(view.phase, "CLEARED"); + assert_eq!(view.pattern, "A_UNALLOCATED"); + assert_eq!(view.payment_id, "PAY-1"); + assert_eq!(view.invoice_id.as_deref(), Some("INV-1")); + assert_eq!(view.currency, "USD"); + assert_eq!(view.amount_minor, 1234); + assert_eq!(view.clearing_state, "SETTLED"); + assert_eq!(view.relates_to_refund_id.as_deref(), Some("RFND-0")); + assert_eq!( + view.reverses_entry_id, + Some(uuid::uuid!("22222222-2222-2222-2222-222222222222")) + ); +} + +/// `CreditNoteView::from(credit_note::Model)` maps every surfaced field +/// (including `created_at_utc`); the entity's `tenant_id` is NOT on the view. +#[test] +fn credit_note_view_from_model_maps_all_fields() { + let created = chrono::DateTime::from_timestamp(1_700_000_002, 0).expect("ts"); + let model = crate::infra::storage::entity::credit_note::Model { + tenant_id: uuid::uuid!("11111111-1111-1111-1111-111111111111"), + credit_note_id: "CN-1".to_owned(), + origin_invoice_id: "INV-1".to_owned(), + origin_invoice_item_ref: Some("ITEM-1".to_owned()), + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + amount_minor: 5000, + recognized_part_minor: 3000, + deferred_part_minor: 1500, + split_basis_ref: Some("SCH-1".to_owned()), + reason_code: "GOODWILL".to_owned(), + created_at_utc: created, + }; + let view = CreditNoteView::from(model); + assert_eq!(view.credit_note_id, "CN-1"); + assert_eq!(view.origin_invoice_id, "INV-1"); + assert_eq!(view.origin_invoice_item_ref.as_deref(), Some("ITEM-1")); + assert_eq!(view.revenue_stream, "subscription"); + assert_eq!(view.currency, "USD"); + assert_eq!(view.amount_minor, 5000); + assert_eq!(view.recognized_part_minor, 3000); + assert_eq!(view.deferred_part_minor, 1500); + assert_eq!(view.split_basis_ref.as_deref(), Some("SCH-1")); + assert_eq!(view.reason_code, "GOODWILL"); + assert_eq!(view.created_at_utc, created); +} + +/// `DebitNoteView::from(debit_note::Model)` maps every surfaced field; the entity's +/// `tenant_id` is NOT on the view. The debit note is leaner than the credit note +/// (no `revenue_stream` / `reason_code` / item ref). +#[test] +fn debit_note_view_from_model_maps_all_fields() { + let created = chrono::DateTime::from_timestamp(1_700_000_003, 0).expect("ts"); + let model = crate::infra::storage::entity::debit_note::Model { + tenant_id: uuid::uuid!("11111111-1111-1111-1111-111111111111"), + debit_note_id: "DN-1".to_owned(), + origin_invoice_id: "INV-2".to_owned(), + currency: "EUR".to_owned(), + amount_minor: 9000, + recognized_part_minor: 6000, + deferred_part_minor: 2500, + created_at_utc: created, + }; + let view = DebitNoteView::from(model); + assert_eq!(view.debit_note_id, "DN-1"); + assert_eq!(view.origin_invoice_id, "INV-2"); + assert_eq!(view.currency, "EUR"); + assert_eq!(view.amount_minor, 9000); + assert_eq!(view.recognized_part_minor, 6000); + assert_eq!(view.deferred_part_minor, 2500); + assert_eq!(view.created_at_utc, created); +} + +/// `DisputeView::from(dispute::Model)` maps every surfaced field; the entity's +/// `tenant_id` / `version` (the optimistic-concurrency counter) are NOT on the +/// view. +#[test] +fn dispute_view_from_model_maps_all_fields() { + let model = crate::infra::storage::entity::dispute::Model { + tenant_id: uuid::uuid!("11111111-1111-1111-1111-111111111111"), + dispute_id: "DSP-1".to_owned(), + payment_id: "PAY-1".to_owned(), + currency: "USD".to_owned(), + variant: "CASH_HOLD".to_owned(), + last_phase: "OPENED".to_owned(), + cycle: 2, + disputed_amount_minor: 4200, + cash_hold_minor: 4000, + version: 9, + }; + let view = DisputeView::from(model); + assert_eq!(view.dispute_id, "DSP-1"); + assert_eq!(view.payment_id, "PAY-1"); + assert_eq!(view.currency, "USD"); + assert_eq!(view.variant, "CASH_HOLD"); + assert_eq!(view.last_phase, "OPENED"); + assert_eq!(view.cycle, 2); + assert_eq!(view.disputed_amount_minor, 4200); + assert_eq!(view.cash_hold_minor, 4000); +} + +/// `RecognitionRunView::from(recognition_run::Model)` maps every surfaced field; +/// the entity's `tenant_id` is NOT on the view. +#[test] +fn recognition_run_view_from_model_maps_all_fields() { + let started = chrono::DateTime::from_timestamp(1_700_000_004, 0).expect("ts"); + let run_id = uuid::uuid!("33333333-3333-3333-3333-333333333333"); + let model = crate::infra::storage::entity::recognition_run::Model { + tenant_id: uuid::uuid!("11111111-1111-1111-1111-111111111111"), + period_id: "202606".to_owned(), + run_id, + started_at_utc: started, + status: "DONE".to_owned(), + }; + let view = RecognitionRunView::from(model); + assert_eq!(view.run_id, run_id); + assert_eq!(view.period_id, "202606"); + assert_eq!(view.status, "DONE"); + assert_eq!(view.started_at_utc, started); +} + +/// `SettlementView::from(payment_settlement::Model)` maps every surfaced counter; +/// the entity's `tenant_id` / `version` (the optimistic-concurrency counter) are +/// NOT on the view. +#[test] +fn settlement_view_from_model_maps_all_fields() { + let model = crate::infra::storage::entity::payment_settlement::Model { + tenant_id: uuid::uuid!("11111111-1111-1111-1111-111111111111"), + payment_id: "PAY-1".to_owned(), + currency: "USD".to_owned(), + settled_minor: 10_000, + fee_minor: 300, + allocated_minor: 6000, + refunded_minor: 1500, + refunded_unallocated_minor: 500, + clawed_back_minor: 200, + version: 11, + }; + let view = SettlementView::from(model); + assert_eq!(view.payment_id, "PAY-1"); + assert_eq!(view.currency, "USD"); + assert_eq!(view.settled_minor, 10_000); + assert_eq!(view.fee_minor, 300); + assert_eq!(view.allocated_minor, 6000); + assert_eq!(view.refunded_minor, 1500); + assert_eq!(view.refunded_unallocated_minor, 500); + assert_eq!(view.clawed_back_minor, 200); +} + +/// `EntryHeaderView::from(journal_entry::Model)` maps the surfaced header dims; +/// the entity's `tenant_id`, `legal_entity_id`, `reverses_period_id`, +/// `posted_by_actor_id`, `correlation_id`, `rounding_evidence`, and the +/// tamper-evidence hash-chain internals (`row_hash` / `prev_hash` / +/// `prev_entry_id` / `prev_period_id`) are NOT on the lightweight header view. +#[test] +fn entry_header_view_from_model_maps_all_fields() { + let posted = chrono::DateTime::from_timestamp(1_700_000_005, 0).expect("ts"); + let effective = chrono::NaiveDate::from_ymd_opt(2026, 6, 27).expect("date"); + let entry_id = uuid::uuid!("44444444-4444-4444-4444-444444444444"); + let reverses = uuid::uuid!("55555555-5555-5555-5555-555555555555"); + let model = crate::infra::storage::entity::journal_entry::Model { + entry_id, + tenant_id: uuid::uuid!("11111111-1111-1111-1111-111111111111"), + legal_entity_id: uuid::uuid!("66666666-6666-6666-6666-666666666666"), + period_id: "202606".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: "REFUND".to_owned(), + source_business_id: "RFND-1".to_owned(), + reverses_entry_id: Some(reverses), + reverses_period_id: Some("202605".to_owned()), + posted_at_utc: posted, + effective_at: effective, + origin: "PSP".to_owned(), + posted_by_actor_id: uuid::uuid!("77777777-7777-7777-7777-777777777777"), + correlation_id: uuid::uuid!("88888888-8888-8888-8888-888888888888"), + rounding_evidence: serde_json::json!({ "mode": "BANKERS" }), + created_seq: 42, + row_hash: Some(vec![1, 2, 3]), + prev_hash: Some(vec![4, 5, 6]), + prev_entry_id: Some(uuid::uuid!("99999999-9999-9999-9999-999999999999")), + prev_period_id: Some("202604".to_owned()), + }; + let view = EntryHeaderView::from(model); + assert_eq!(view.entry_id, entry_id); + assert_eq!(view.period_id, "202606"); + assert_eq!(view.entry_currency, "USD"); + assert_eq!(view.source_doc_type, "REFUND"); + assert_eq!(view.source_business_id, "RFND-1"); + assert_eq!(view.reverses_entry_id, Some(reverses)); + assert_eq!(view.posted_at_utc, posted); + assert_eq!(view.effective_at, effective); + assert_eq!(view.origin, "PSP"); + assert_eq!(view.created_seq, 42); +} + +/// `PayerStateView::from(payer_state::Model)` maps every surfaced field; the +/// entity's `tenant_id` is NOT on the view. +#[test] +fn payer_state_view_from_model_maps_all_fields() { + let changed = chrono::DateTime::from_timestamp(1_700_000_006, 0).expect("ts"); + let approver = uuid::uuid!("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + let payer = uuid::uuid!("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + let model = crate::infra::storage::entity::payer_state::Model { + tenant_id: uuid::uuid!("11111111-1111-1111-1111-111111111111"), + payer_tenant_id: payer, + lifecycle_state: "CLOSED".to_owned(), + closed_with_open_balance: true, + approved_by: Some(approver), + changed_at: Some(changed), + }; + let view = PayerStateView::from(model); + assert_eq!(view.payer_tenant_id, payer); + assert_eq!(view.lifecycle_state, "CLOSED"); + assert!(view.closed_with_open_balance); + assert_eq!(view.approved_by, Some(approver)); + assert_eq!(view.changed_at, Some(changed)); +} + +/// `DualControlPolicyView::from_effective(Some)` surfaces the configured version's +/// thresholds + `version`/`effective_from` provenance and is NOT flagged default. +#[test] +fn dual_control_policy_view_from_configured_version() { + let eff = DateTime::from_timestamp(1_700_000_000, 0).expect("ts"); + let view = DualControlPolicyView::from_effective(Some(PolicyVersion { + effective_from: eff, + version: 3, + policy: DualControlPolicy { + d2_threshold_minor: 250_000, + a6_backdating_biz_days: 7, + pending_ttl_seconds: 3_600, + }, + })); + assert_eq!(view.d2_threshold_minor, 250_000); + assert_eq!(view.a6_backdating_biz_days, 7); + assert_eq!(view.pending_ttl_seconds, 3_600); + assert_eq!(view.effective_from, Some(eff)); + assert_eq!(view.version, Some(3)); + assert!(!view.is_default); +} + +/// `DualControlPolicyView::from_effective(None)` renders the ratified platform +/// defaults and flags `is_default` (the tenant has no policy row). +#[test] +fn dual_control_policy_view_from_none_yields_platform_defaults() { + let view = DualControlPolicyView::from_effective(None); + let d = DualControlPolicy::DEFAULT; + assert_eq!(view.d2_threshold_minor, d.d2_threshold_minor); + assert_eq!(view.a6_backdating_biz_days, d.a6_backdating_biz_days); + assert_eq!(view.pending_ttl_seconds, d.pending_ttl_seconds); + assert_eq!(view.effective_from, None); + assert_eq!(view.version, None); + assert!(view.is_default); +} + +// ── FX rate ingest validation (Slice 5) ────────────────────────────────────── + +/// A `snake_case` `POST /fx/rates` body (`fallback_order` omitted). +fn fx_ingest_body(base: &str, quote: &str, provider: &str, rate_micro: i64) -> serde_json::Value { + serde_json::json!({ + "tenant_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "base_currency": base, + "quote_currency": quote, + "provider": provider, + "rate_micro": rate_micro, + "as_of": "2026-06-27T00:00:00Z", + }) +} + +#[test] +fn fx_ingest_valid_body_validates_and_defaults_fallback_order() { + let req: FxRateIngestRequest = + serde_json::from_value(fx_ingest_body("EUR", "USD", "ecb", 1_100_000)).unwrap(); + assert_eq!(req.validate().unwrap(), 0, "fallback_order defaults to 0"); +} + +#[test] +fn fx_ingest_rejects_identity_pair() { + // base == quote is a no-op rate the lock-time short-circuit never reads. + let req: FxRateIngestRequest = + serde_json::from_value(fx_ingest_body("USD", "USD", "ecb", 1_000_000)).unwrap(); + assert!(matches!( + req.validate(), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn fx_ingest_rejects_non_positive_rate() { + let zero: FxRateIngestRequest = + serde_json::from_value(fx_ingest_body("EUR", "USD", "ecb", 0)).unwrap(); + assert!(matches!( + zero.validate(), + Err(DomainError::InvalidRequest(_)) + )); + let negative: FxRateIngestRequest = + serde_json::from_value(fx_ingest_body("EUR", "USD", "ecb", -5)).unwrap(); + assert!(matches!( + negative.validate(), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn fx_ingest_rejects_empty_currency_and_provider() { + let bad_ccy: FxRateIngestRequest = + serde_json::from_value(fx_ingest_body("", "USD", "ecb", 1_000_000)).unwrap(); + assert!(matches!( + bad_ccy.validate(), + Err(DomainError::InvalidRequest(_)) + )); + let bad_provider: FxRateIngestRequest = + serde_json::from_value(fx_ingest_body("EUR", "USD", "", 1_000_000)).unwrap(); + assert!(matches!( + bad_provider.validate(), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn fx_ingest_passes_explicit_fallback_order_through() { + let mut body = fx_ingest_body("EUR", "USD", "ecb", 1_100_000); + body["fallback_order"] = serde_json::json!(2); + let req: FxRateIngestRequest = serde_json::from_value(body).unwrap(); + assert_eq!(req.validate().unwrap(), 2); +} + +#[test] +fn fx_ingest_rejects_negative_fallback_order() { + let mut body = fx_ingest_body("EUR", "USD", "ecb", 1_100_000); + body["fallback_order"] = serde_json::json!(-1); + let req: FxRateIngestRequest = serde_json::from_value(body).unwrap(); + assert!(matches!( + req.validate(), + Err(DomainError::InvalidRequest(_)) + )); +} diff --git a/gears/bss/ledger/ledger/src/api/rest/error.rs b/gears/bss/ledger/ledger/src/api/rest/error.rs new file mode 100644 index 000000000..7d372929c --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/error.rs @@ -0,0 +1,245 @@ +//! REST error mapping for the `bss-ledger` gear: translates authz-gate and +//! body-rejection errors into `CanonicalError`, which the canonical-error +//! middleware renders as an RFC 9457 `Problem`. Domain rejections reach the +//! wire through the single `From for CanonicalError` ladder +//! (`crate::infra::error_mapping`), not here. +//! +//! The `#[resource_error(...)]` GTS id below is cosmetic in P4 — it stamps +//! `context.resource_type` (the error renders regardless). It is reconciled +//! with the gear's GTS registration in P7. + +use toolkit::api::canonical_prelude::{CanonicalError, resource_error}; + +use crate::authz::AuthzError; + +/// Stamps `context.resource_type` on the canonical error. GTS id reconciled +/// with the gear's GTS registration in P7; cosmetic here. +#[resource_error("gts.cf.bss.ledger.ledger.v1~")] +struct LedgerResourceError; + +/// Map an [`AuthzError`] from the PEP gate to a [`CanonicalError`]. +/// `Denied` becomes a 403 carrying the deny reason; `Unavailable` becomes a +/// fail-closed 503 whose diagnostic stays server-side (the wire `Problem` is a +/// generic 503). +pub(crate) fn authz_error_to_canonical(err: AuthzError) -> CanonicalError { + match err { + AuthzError::Denied(reason) => LedgerResourceError::permission_denied() + .with_reason(reason) + .create(), + AuthzError::Unavailable(detail) => { + tracing::error!(detail, "Authorization service unavailable"); + CanonicalError::service_unavailable().create() + } + } +} + +/// Build a 401 `CanonicalError` for requests without an authenticated +/// `SecurityContext` — distinct from a permission denial (403). +pub(crate) fn unauthenticated() -> CanonicalError { + CanonicalError::unauthenticated() + .with_reason("AUTHENTICATION_REQUIRED") + .create() +} + +/// Build a 400 `InvalidArgument` `CanonicalError` carrying a single `body` +/// field-violation for a malformed JSON request body. `code` is the +/// machine-readable reason (`json_syntax_error`, …); `message` is the +/// human-readable diagnostic. +pub(crate) fn json_rejection_canonical(code: &str, message: String) -> CanonicalError { + LedgerResourceError::invalid_argument() + .with_field_violation("body", message, code.to_owned()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) journal entry. The scoped read yields `None` both when the entry +/// truly does not exist and when it lies outside the caller's authorized subtree +/// — the same 404 in either case (no existence leak). +pub(crate) fn entry_not_found(entry_id: uuid::Uuid) -> CanonicalError { + LedgerResourceError::not_found(format!("journal entry {entry_id} not found")) + .with_resource(entry_id.to_string()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) recognition schedule (the `GET /recognition-schedules/{id}` +/// miss). The scoped read yields `None` both when no such schedule exists for +/// `(tenant, schedule_id)` and when it lies outside the caller's authorized +/// subtree — the same 404 in either case (no existence leak). Mirrors +/// [`entry_not_found`]; uses the canonical `not_found` problem+json builder +/// (NOT the fiscal-period `PeriodNotFound` domain variant). +pub(crate) fn recognition_schedule_not_found(schedule_id: &str) -> CanonicalError { + LedgerResourceError::not_found(format!("recognition schedule {schedule_id} not found")) + .with_resource(schedule_id.to_owned()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) audit-pack export. Same 404 whether the export truly does not +/// exist or lies outside the caller's scope (no existence leak). +pub(crate) fn pack_export_not_found(export_id: uuid::Uuid) -> CanonicalError { + LedgerResourceError::not_found(format!("audit-pack export {export_id} not found")) + .with_resource(export_id.to_string()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) invoice exposure (the `GET /invoices/{invoice_id}/exposure` miss). +/// The scoped read yields `None` both when no credit/debit note has ever touched +/// the invoice (so the `invoice_exposure` row was never seeded) and when it lies +/// outside the caller's authorized subtree — the same 404 in either case (no +/// existence leak). Mirrors [`recognition_schedule_not_found`]. +pub(crate) fn invoice_exposure_not_found(invoice_id: &str) -> CanonicalError { + LedgerResourceError::not_found(format!("invoice exposure for {invoice_id} not found")) + .with_resource(invoice_id.to_owned()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) refund (the `GET /refunds/{refund_id}` miss, Group G). The scoped +/// read yields `None` both when no refund with that id exists for `(tenant, +/// refund_id)` and when it lies outside the caller's authorized subtree — the same +/// 404 in either case (no existence leak). Mirrors [`invoice_exposure_not_found`]. +pub(crate) fn refund_not_found(refund_id: &str) -> CanonicalError { + LedgerResourceError::not_found(format!("refund {refund_id} not found")) + .with_resource(refund_id.to_owned()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) credit note (the `GET /credit-notes/{credit_note_id}` miss, read +/// surface R2). The scoped read yields `None` both when no credit note with that +/// id exists for `(tenant, credit_note_id)` and when it lies outside the caller's +/// authorized subtree — the same 404 in either case (no existence leak). Mirrors +/// [`refund_not_found`]. +pub(crate) fn credit_note_not_found(credit_note_id: &str) -> CanonicalError { + LedgerResourceError::not_found(format!("credit note {credit_note_id} not found")) + .with_resource(credit_note_id.to_owned()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) debit note (the `GET /debit-notes/{debit_note_id}` miss, read +/// surface R2). The scoped read yields `None` both when no debit note with that +/// id exists for `(tenant, debit_note_id)` and when it lies outside the caller's +/// authorized subtree — the same 404 in either case (no existence leak). Mirrors +/// [`credit_note_not_found`]. +pub(crate) fn debit_note_not_found(debit_note_id: &str) -> CanonicalError { + LedgerResourceError::not_found(format!("debit note {debit_note_id} not found")) + .with_resource(debit_note_id.to_owned()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) dispute (the `GET /disputes/{dispute_id}` miss, read surface R3). +/// The scoped read yields `None` both when no dispute with that id was ever opened +/// for `(tenant, dispute_id)` and when it lies outside the caller's authorized +/// subtree — the same 404 in either case (no existence leak). Mirrors +/// [`refund_not_found`]. +pub(crate) fn dispute_not_found(dispute_id: &str) -> CanonicalError { + LedgerResourceError::not_found(format!("dispute {dispute_id} not found")) + .with_resource(dispute_id.to_owned()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) recognition run (the `GET /recognition-runs/{run_id}` miss, read +/// surface R4). The scoped read yields `None` both when no run with that id exists +/// for the tenant and when it lies outside the caller's authorized subtree — the +/// same 404 in either case (no existence leak). Takes the `run_id` `Uuid` (the +/// surrogate run id) and formats it. Mirrors [`refund_not_found`] / +/// [`entry_not_found`]. +pub(crate) fn recognition_run_not_found(run_id: uuid::Uuid) -> CanonicalError { + LedgerResourceError::not_found(format!("recognition run {run_id} not found")) + .with_resource(run_id.to_string()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) payment settlement (the `GET /payments/{payment_id}/settlement` +/// miss, read surface R4). The scoped read yields `None` both when the payment was +/// never settled (so no `payment_settlement` row exists for `(tenant, payment_id)`) +/// and when it lies outside the caller's authorized subtree — the same 404 in +/// either case (no existence leak). Mirrors [`refund_not_found`]. +pub(crate) fn settlement_not_found(payment_id: &str) -> CanonicalError { + LedgerResourceError::not_found(format!("settlement for payment {payment_id} not found")) + .with_resource(payment_id.to_owned()) + .create() +} + +/// Build a 404 for an unknown (or scoped-out) payer lifecycle state (the +/// `GET /payers/{payer_tenant_id}/state` miss). Tenant-scoped (SQL-level BOLA): a +/// payer with no recorded state — or outside the caller's subtree — is the same +/// 404 (no existence leak). Mirrors [`refund_not_found`]. +pub(crate) fn payer_state_not_found(payer_tenant_id: uuid::Uuid) -> CanonicalError { + LedgerResourceError::not_found(format!("payer state for {payer_tenant_id} not found")) + .with_resource(payer_tenant_id.to_string()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) FX rate snapshot (the `GET /fx/rate-snapshots/{rateId}` miss, +/// Slice 5). The scoped read yields `None` both when no snapshot with that id +/// exists for `(tenant, rate_id)` and when it lies outside the caller's authorized +/// subtree — the same 404 in either case (no existence leak). Mirrors +/// [`refund_not_found`]. +pub(crate) fn rate_snapshot_not_found(rate_id: uuid::Uuid) -> CanonicalError { + LedgerResourceError::not_found(format!("fx rate snapshot {rate_id} not found")) + .with_resource(rate_id.to_string()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) reconciliation run (the `GET /reconciliation-runs/{run_id}` miss, +/// Slice 7 Phase 3). The scoped read yields `None` both when no run with that id +/// exists for the tenant and when it lies outside the caller's authorized subtree +/// — the same 404 in either case (no existence leak). Mirrors +/// [`recognition_run_not_found`] / [`exception_not_found`]. +pub(crate) fn reconciliation_run_not_found(run_id: uuid::Uuid) -> CanonicalError { + LedgerResourceError::not_found(format!("reconciliation run {run_id} not found")) + .with_resource(run_id.to_string()) + .create() +} + +/// Build a 404 `NotFound` `CanonicalError` for an absent (or foreign-owned, +/// scoped-out) exception-queue row (the `POST /exceptions/{id}/resolution` miss, +/// Slice 7 Phase 2). The scoped read yields `None` both when no exception with that +/// id exists for the tenant and when it lies outside the caller's authorized +/// subtree — the same 404 in either case (no existence leak). Mirrors +/// [`refund_not_found`]. +pub(crate) fn exception_not_found(exception_id: uuid::Uuid) -> CanonicalError { + LedgerResourceError::not_found(format!("exception {exception_id} not found")) + .with_resource(exception_id.to_string()) + .create() +} + +/// Map a [`ReversalError`] from the reversal/mapping-correction handlers to a +/// [`CanonicalError`] — both variants are client errors ⇒ a 400 +/// `InvalidArgument`. Kept here (not in the `DomainError` ladder) because +/// `ReversalError` is a distinct pure-domain error, raised before any +/// `DomainError` is produced. +pub(crate) fn reversal_error_to_canonical( + err: crate::domain::invoice::reversal::ReversalError, +) -> CanonicalError { + use crate::domain::invoice::reversal::ReversalError; + match err { + ReversalError::CannotReverseReversal => LedgerResourceError::invalid_argument() + .with_field_violation( + "entry_id", + "cannot reverse an entry that is itself a reversal", + "CANNOT_REVERSE_REVERSAL", + ) + .create(), + ReversalError::CreditGrantNotReconstructible => LedgerResourceError::invalid_argument() + .with_field_violation( + "entry_id", + "cannot reverse an entry with a REUSABLE_CREDIT line", + "CANNOT_REVERSE_CREDIT_GRANT", + ) + .create(), + } +} + +#[cfg(test)] +#[path = "error_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/api/rest/error_tests.rs b/gears/bss/ledger/ledger/src/api/rest/error_tests.rs new file mode 100644 index 000000000..4a53a00e3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/error_tests.rs @@ -0,0 +1,109 @@ +//! Unit checks for the REST error constructors: each builds a `CanonicalError` +//! of the right HTTP category, and the not-found family stamps the resource id +//! into the rendered `Problem` (so the wire 404 names what was missing without +//! leaking existence — same 404 for absent vs scoped-out). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use toolkit::api::canonical_prelude::{CanonicalError, Problem}; +use uuid::Uuid; + +use super::{ + authz_error_to_canonical, credit_note_not_found, debit_note_not_found, dispute_not_found, + entry_not_found, invoice_exposure_not_found, json_rejection_canonical, pack_export_not_found, + payer_state_not_found, rate_snapshot_not_found, recognition_run_not_found, + recognition_schedule_not_found, refund_not_found, reversal_error_to_canonical, + settlement_not_found, unauthenticated, +}; +use crate::authz::AuthzError; +use crate::domain::invoice::reversal::ReversalError; + +/// The rendered RFC 9457 `Problem` body as a string (for wire-code assertions). +fn body(err: CanonicalError) -> String { + serde_json::to_string(&Problem::from(err)).unwrap() +} + +#[test] +fn authz_denied_is_403_carrying_the_deny_reason() { + let err = authz_error_to_canonical(AuthzError::Denied("LEDGER_PROVISION_DENIED".to_owned())); + assert_eq!(err.status_code(), 403, "a PEP deny must be a 403"); + assert!( + body(err).contains("LEDGER_PROVISION_DENIED"), + "403 must carry the deny reason" + ); +} + +#[test] +fn authz_unavailable_fails_closed_to_503() { + let err = authz_error_to_canonical(AuthzError::Unavailable("pdp rpc timeout".to_owned())); + assert_eq!( + err.status_code(), + 503, + "an unreachable PDP must fail closed to 503 (diagnostic stays server-side)" + ); +} + +#[test] +fn unauthenticated_is_401() { + let err = unauthenticated(); + assert_eq!(err.status_code(), 401); + assert!(body(err).contains("AUTHENTICATION_REQUIRED")); +} + +#[test] +fn json_rejection_is_400_carrying_the_machine_code() { + let err = json_rejection_canonical("json_syntax_error", "expected `,` at line 1".to_owned()); + assert_eq!(err.status_code(), 400); + assert!( + body(err).contains("json_syntax_error"), + "the field-violation must carry the machine code" + ); +} + +#[test] +fn not_found_family_is_404_and_stamps_the_resource_id() { + let entry = Uuid::now_v7(); + assert_eq!(entry_not_found(entry).status_code(), 404); + assert!(body(entry_not_found(entry)).contains(&entry.to_string())); + + assert_eq!(recognition_schedule_not_found("SCH-1").status_code(), 404); + assert!(body(recognition_schedule_not_found("SCH-1")).contains("SCH-1")); + + let export = Uuid::now_v7(); + assert_eq!(pack_export_not_found(export).status_code(), 404); + assert!(body(pack_export_not_found(export)).contains(&export.to_string())); + + assert_eq!(invoice_exposure_not_found("INV-1").status_code(), 404); + assert!(body(invoice_exposure_not_found("INV-1")).contains("INV-1")); + + assert_eq!(refund_not_found("REF-1").status_code(), 404); + assert!(body(refund_not_found("REF-1")).contains("REF-1")); + + assert_eq!(credit_note_not_found("CN-1").status_code(), 404); + assert_eq!(debit_note_not_found("DN-1").status_code(), 404); + assert_eq!(dispute_not_found("DSP-1").status_code(), 404); + + let run = Uuid::now_v7(); + assert_eq!(recognition_run_not_found(run).status_code(), 404); + assert!(body(recognition_run_not_found(run)).contains(&run.to_string())); + + assert_eq!(settlement_not_found("PAY-1").status_code(), 404); + assert!(body(settlement_not_found("PAY-1")).contains("PAY-1")); + + let payer = Uuid::now_v7(); + assert_eq!(payer_state_not_found(payer).status_code(), 404); + + let rate = Uuid::now_v7(); + assert_eq!(rate_snapshot_not_found(rate).status_code(), 404); + assert!(body(rate_snapshot_not_found(rate)).contains(&rate.to_string())); +} + +#[test] +fn reversal_errors_are_400_with_their_wire_codes() { + let cannot = reversal_error_to_canonical(ReversalError::CannotReverseReversal); + assert_eq!(cannot.status_code(), 400); + assert!(body(cannot).contains("CANNOT_REVERSE_REVERSAL")); + + let credit = reversal_error_to_canonical(ReversalError::CreditGrantNotReconstructible); + assert_eq!(credit.status_code(), 400); + assert!(body(credit).contains("CANNOT_REVERSE_CREDIT_GRANT")); +} diff --git a/gears/bss/ledger/ledger/src/api/rest/exceptions.rs b/gears/bss/ledger/ledger/src/api/rest/exceptions.rs new file mode 100644 index 000000000..e8395968f --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/exceptions.rs @@ -0,0 +1,272 @@ +//! Axum handlers + router for the exception-queue dashboard (Slice 7 Phase 2, +//! design §4.6 / §5). +//! +//! - `GET /bss-ledger/v1/exceptions` — list the tenant's exceptions (the Revenue +//! Assurance dashboard / queue), cursor-paginated, with an `OData` `$filter` over +//! `type` / `status` / `business_ref` / `period_id`. Gates on +//! `RECONCILIATION:read`. +//! - `POST /bss-ledger/v1/exceptions/{exception_id}/resolution` — transition an +//! OPEN exception: `ACK` / `RESOLVED` (operator triage), or `APPROVED_EXCEPTION` +//! (Finance — **`GL_WRITEOFF_VARIANCE` only**, the one acknowledge-to-non-block +//! kind, N-pay-5). Gates on `RECONCILIATION:resolve`. +//! +//! Tenant-scoped (SQL-level BOLA): the compiled `RECONCILIATION` scope is the +//! filter, so a foreign id reads as `None` ⇒ 404 (no existence leak). + +use std::sync::Arc; + +use axum::extract::{Extension, Path}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use chrono::{DateTime, Utc}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::odata::OData; +use toolkit::api::operation_builder::OperationBuilderODataExt; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_odata::Page; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::local_client::map_odata_page_err; +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::error::{authz_error_to_canonical, exception_not_found}; +use crate::domain::error::DomainError; +use crate::domain::exception::{ExceptionStatus, ExceptionType}; +use crate::infra::storage::entity::exception_queue; +use crate::infra::storage::repo::ExceptionQueueRepo; +use crate::odata::ExceptionFilterField; + +/// `OpenAPI` tag applied to the exception operations. +const TAG: &str = "BSS Ledger Exceptions"; + +/// Shared per-request state for the exception routes. +#[derive(Clone)] +pub struct ApiState { + /// The exception-queue repository (list / read / resolve). + pub repo: ExceptionQueueRepo, +} + +/// One exception-queue row, projected for the dashboard. PII-free (ids + codes). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ExceptionView { + pub exception_id: Uuid, + pub exception_type: String, + pub business_ref: String, + pub status: String, + pub period_id: Option, + pub opened_at: DateTime, + pub resolved_at: Option>, + pub resolved_by: Option, +} + +impl From for ExceptionView { + fn from(m: exception_queue::Model) -> Self { + Self { + exception_id: m.exception_id, + exception_type: m.exception_type, + business_ref: m.business_ref, + status: m.status, + period_id: m.period_id, + opened_at: m.opened_at, + resolved_at: m.resolved_at, + resolved_by: m.resolved_by, + } + } +} + +/// Resolve-exception request body. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ResolveExceptionRequest { + /// Target status: `ACK`, `RESOLVED`, or `APPROVED_EXCEPTION` + /// (`GL_WRITEOFF_VARIANCE` only). + pub status: String, + /// Operator / Finance reason (audit context; recorded with the actor). + pub reason: Option, +} + +/// Build the Axum router for the exception surface. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::get("/bss-ledger/v1/exceptions") + .operation_id("bss_ledger.list_exceptions") + .summary("List the tenant's close-blocking exceptions (cursor-paginated)") + .description( + "Cursor-paginated list of the caller tenant's exception-queue rows (the \ + Revenue Assurance dashboard / queue). Supports OData `$filter` over \ + `type` (e.g. RECON_MISMATCH), `status` (OPEN / ACK / RESOLVED / \ + APPROVED_EXCEPTION), `business_ref`, and `period_id`. The `$filter` ANDs \ + the caller's authorized subtree, so exceptions outside it are never \ + returned (SQL-level BOLA). Each item is the same `ExceptionView`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_exceptions) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of the tenant's exceptions.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/exceptions/{exception_id}/resolution") + .operation_id("bss_ledger.resolve_exception") + .summary("Resolve / acknowledge / approve an exception") + .description( + "Transitions an OPEN exception: `ACK` / `RESOLVED` (operator triage) or \ + `APPROVED_EXCEPTION` (Finance — GL_WRITEOFF_VARIANCE only, the one \ + acknowledge-to-non-block kind). A resolved exception no longer blocks \ + period close.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("exception_id", "The exception being resolved.") + .json_request::(openapi, "The target status + reason.") + .handler(resolve_exception) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The resolved exception.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// `GET …/exceptions`: list the caller tenant's exceptions (RECONCILIATION:read), +/// cursor-paginated. The `$filter` (`type` / `status` / `business_ref` / +/// `period_id`) ANDs the caller's compiled read scope, so the page never contains +/// a foreign-tenant exception (SQL-level BOLA, no existence leak). Mirrors +/// `refunds::list_refunds`. +async fn list_exceptions( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant = ctx.subject_tenant_id(); + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECONCILIATION, + crate::authz::actions::READ, + None, + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let page = state + .repo + .list_page(&scope, tenant, &odata) + .await + .map_err(map_odata_page_err)?; + Ok(Json(Page { + items: page.items.into_iter().map(ExceptionView::from).collect(), + page_info: page.page_info, + })) +} + +/// `POST …/exceptions/{id}/resolution`: transition an OPEN exception +/// (RECONCILIATION:resolve). Validates the target status + the +/// `APPROVED_EXCEPTION`-is-GL-writeoff-only rule, then applies the transition. +async fn resolve_exception( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(exception_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let tenant = ctx.subject_tenant_id(); + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECONCILIATION, + crate::authz::actions::RESOLVE, + Some(tenant), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // Parse + validate the target status. Only the three resolution states are + // reachable from a handler — you cannot resolve an exception back to OPEN. + let target = ExceptionStatus::parse(&body.status).filter(|s| { + matches!( + s, + ExceptionStatus::Ack | ExceptionStatus::Resolved | ExceptionStatus::ApprovedException + ) + }); + let Some(target) = target else { + return Err(CanonicalError::from(DomainError::InvalidRequest(format!( + "invalid resolution status {:?} (expected ACK, RESOLVED, or APPROVED_EXCEPTION)", + body.status + )))); + }; + + // Read the row (scoped) — a foreign / unknown id is a 404 (no existence leak). + let row = state + .repo + .read(&scope, tenant, exception_id) + .await? + .ok_or_else(|| exception_not_found(exception_id))?; + + // Only an OPEN exception is resolvable (a terminal row is not re-transitioned). + if row.status != ExceptionStatus::Open.as_str() { + return Err(CanonicalError::from(DomainError::InvalidRequest(format!( + "exception {exception_id} is {} (already resolved)", + row.status + )))); + } + + // `APPROVED_EXCEPTION` is the GL-writeoff acknowledge-to-non-block path ONLY + // (N-pay-5): every other type resolves via ACK / RESOLVED. + if target == ExceptionStatus::ApprovedException + && row.exception_type != ExceptionType::GlWriteoffVariance.as_str() + { + return Err(CanonicalError::from(DomainError::InvalidRequest(format!( + "APPROVED_EXCEPTION is only valid for GL_WRITEOFF_VARIANCE (got {})", + row.exception_type + )))); + } + + let actor = ctx.subject_id().to_string(); + state + .repo + .resolve_one(&scope, tenant, exception_id, target.as_str(), &actor) + .await?; + + // Re-read for the resolved view (status / resolved_at / resolved_by now set). + let resolved = state + .repo + .read(&scope, tenant, exception_id) + .await? + .ok_or_else(|| exception_not_found(exception_id))?; + Ok((StatusCode::OK, Json(ExceptionView::from(resolved))).into_response()) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/fx.rs b/gears/bss/ledger/ledger/src/api/rest/fx.rs new file mode 100644 index 000000000..6e62dae53 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/fx.rs @@ -0,0 +1,369 @@ +//! Axum handlers + router for the ledger's FX & multi-currency REST surface +//! (Slice 5, design §5). Two operations under `/bss-ledger/v1`, tenant-scoped +//! WITHOUT a tenant in the path (the vhp-core convention): the write carries +//! `tenant_id` in the **body**, the read takes a `?tenant_id=` **query** param. +//! +//! - `POST /fx/rates` — the SECONDARY manual / seed ingest of one rate into the +//! local `ledger_fx_rate` store (the PRIMARY path is the `RateProviderV1` +//! plugin pull via `RateSyncJob`, decision 2). Upsert-keyed on `(tenant, base, +//! quote, provider)`; idempotent on `(tenant, base, quote, provider, as_of)`. +//! `(ledger, provision)` PEP gate against the body's `tenant_id` — FX rates are +//! ledger reference data, the same family as the currency scales seeded under +//! `ledger/provision` (lean authz reuse, spec §5; no new resource). +//! - `GET /fx/rate-snapshots/{rate_id}` — read one immutable +//! `ledger_fx_rate_snapshot` (the frozen rate a journal line's +//! `rate_snapshot_ref` points at). `(ledger, read)` PEP gate; the compiled read +//! scope is the SQL-level BOLA filter (a foreign-tenant or absent snapshot is +//! the same 404, no existence leak). +//! +//! Routes register through `OperationBuilder` so `/openapi.json` lists each +//! operation with its declared request / response schemas. Mirrors +//! `payments::router` / `credit::router`. + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{ + FxRateIngestRequest, FxRateIngestResponse, FxRateSnapshotResponse, RevaluationRunRequest, + RevaluationRunResponse, RevaluationScopeOutcomeDto, +}; +use crate::api::rest::error::{authz_error_to_canonical, rate_snapshot_not_found}; +use crate::domain::error::DomainError; +use crate::domain::fx::revaluation_mode::RevaluationMode; +use crate::infra::fx::revaluation_run::{ScopeOutcome, ScopeStatus, UnrealizedRevaluationRun}; +use crate::infra::storage::repo::{FxRepo, FxRevaluationModeRepo, NewFxRate}; + +/// `OpenAPI` tag applied to the FX operations. +const TAG: &str = "BSS Ledger FX"; + +/// Shared per-request state for the FX routes. Constructed once at `init()` and +/// shared via `Extension>`. Carries the FX repo — the ingest upsert +/// and the snapshot read go straight to it (there is no in-process client method +/// for reference-rate data, unlike the posting surfaces). +#[derive(Clone)] +pub struct ApiState { + /// FX rate store + immutable snapshot repository. + pub fx_repo: FxRepo, + /// Mode-B unrealized-revaluation runner (the `POST /fx/revaluation-runs` + /// trigger). `Arc` because the runner holds non-`Clone` posting/repo handles. + pub revaluation_run: Arc, + /// Per-tenant FX revaluation mode (VHP-1986): the trigger resolves the target + /// tenant's effective Mode A/B and only runs the revaluation for Mode B. + pub fx_revaluation_mode: FxRevaluationModeRepo, + /// The global `fx.revaluation_enabled` fleet default for a tenant with no + /// explicit mode row (on→ModeB, off→ModeA). + pub fleet_revaluation_enabled: bool, +} + +/// Build the Axum router for the FX surface and register both operations with the +/// supplied `OpenAPI` registry. `state` is attached via an `Extension` layer at +/// the end so the registry sees the route definitions before the per-request +/// state is bound. Mirrors [`crate::api::rest::payments::router`]. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/fx/rates") + .operation_id("bss_ledger.ingest_fx_rate") + .summary("Ingest an FX rate into the local store (secondary manual / seed path)") + .description( + "Upserts one `base → quote` rate into the seller's local \ + `ledger_fx_rate` store for the tenant named by the body's \ + `tenant_id`. This is the SECONDARY ingest path — the primary is the \ + `RateProviderV1` adapter pull driven by the background rate-sync job. \ + Upsert-keyed on `(tenant, base, quote, provider)`: re-posting the same \ + tuple overwrites the quote (`rate_micro` / `as_of` / `fallback_order`), \ + so it is idempotent on `(tenant, base, quote, provider, as_of)`. \ + Rejected (400) on an empty/oversized currency or provider code, a \ + non-positive `rate_micro`, a negative `fallback_order`, or an identity \ + (`base == quote`) pair. `(ledger, provision)` PEP gate against the \ + body's `tenant_id`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The rate to upsert into the local store (tenant in the body).", + ) + .handler(ingest_fx_rate) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The now-current stored rate (key + quote), echoed back", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/fx/rate-snapshots/{rate_id}") + .operation_id("bss_ledger.read_fx_rate_snapshot") + .summary("Read an immutable FX rate snapshot") + .description( + "Returns the immutable `ledger_fx_rate_snapshot` for `{rate_id}` — the \ + frozen rate a journal line's `rate_snapshot_ref` points at, \ + reproducing its exact lock-time translation. `?tenant_id=` defaults to \ + the caller's own. A snapshot that does not exist for `(tenant, \ + rate_id)`, or that lies outside the caller's authorized subtree, is \ + the same 404 (SQL-level BOLA, no existence leak). `(ledger, read)` PEP \ + gate.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("rate_id", "The snapshot's immutable rate id (uuid).") + .query_param( + "tenant_id", + false, + "Target tenant (defaults to the caller's own)", + ) + .handler(read_fx_rate_snapshot) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The immutable rate snapshot", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/fx/revaluation-runs") + .operation_id("bss_ledger.trigger_revaluation") + .summary("Trigger an unrealized (Mode-B) revaluation for a period") + .description( + "Remeasures the tenant's open foreign-currency MONETARY positions \ + `{AR, UNALLOCATED, REUSABLE_CREDIT}` at the period-end rate against \ + their carried functional value, posting one functional-only \ + `FX_UNREALIZED` entry per scope that moved (design §4.5). Idempotent \ + per `(tenant, period_id, scope)` — re-posting the same period replays \ + the already-posted scopes. A no-op when the tenant is Mode-A \ + (`revaluation_enabled = false`). The reversal is a separate \ + next-period JE posted by the background job (decision 7). `period_id` \ + must be an OPEN period (the run posts into it). `(ledger, provision)` \ + PEP gate against the body's `tenant_id` (finance scope, the same \ + family as the rate ingest).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The period to revalue (tenant in the body).", + ) + .handler(trigger_revaluation) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Per-scope revaluation outcomes", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) BEFORE +// the in-handler `require_authenticated` gate, so a malformed body yields 400 even +// for an unauthenticated caller (standard axum extractor ordering; no +// authenticated-only data is disclosed). Mirrors `payments::settle_payment`. +async fn ingest_fx_rate( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id` (tenant in body, not path). + let tenant_id = body.tenant_id; + // (ledger, provision) gate against the TARGET tenant: FX rates are ledger + // reference data (the same family as currency scales seeded under + // `ledger/provision`); a target outside the caller's authorized scope is a + // cross-tenant write and is denied. The upsert re-scopes to `for_tenant` inside + // the repo — this gate is the authorization. + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::LEDGER, + crate::authz::actions::PROVISION, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // Validate the body BEFORE the write (returns the defaulted `fallback_order`), + // then move it into the row parameter object. + let fallback_order = body.validate()?; + let upsert = NewFxRate { + tenant_id, + base_currency: body.base_currency, + quote_currency: body.quote_currency, + provider: body.provider, + rate_micro: body.rate_micro, + as_of: body.as_of, + fallback_order, + }; + state + .fx_repo + .upsert_rate(&upsert) + .await + .map_err(|e| CanonicalError::from(DomainError::Internal(format!("fx rate ingest: {e}"))))?; + + Ok(( + StatusCode::OK, + Json(FxRateIngestResponse { + tenant_id: upsert.tenant_id, + base_currency: upsert.base_currency, + quote_currency: upsert.quote_currency, + provider: upsert.provider, + rate_micro: upsert.rate_micro, + as_of: upsert.as_of, + fallback_order: upsert.fallback_order, + }), + ) + .into_response()) +} + +/// `GET /fx/rate-snapshots/{rate_id}` query parameters. `tenant_id` defaults to +/// the caller's own; the compiled read scope is the SQL-level BOLA filter. +#[derive(Debug, serde::Deserialize)] +struct SnapshotQuery { + tenant_id: Option, +} + +async fn read_fx_rate_snapshot( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(rate_id): Path, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + // (ledger, read) gate: reads pass `owner_tenant_id = None` (the PDP derives the + // scope from the subject + role; the returned scope is the SQL filter), pinning + // the single snapshot row via `resource_id`. `require_constraints = true` so an + // unconstrained allow fail-closes instead of leaking every tenant. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::LEDGER, + crate::authz::actions::READ, + None, + Some(rate_id), + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + let snapshot = state + .fx_repo + .read_snapshot(&scope, tenant_id, rate_id) + .await + .map_err(|e| CanonicalError::from(DomainError::Internal(format!("fx snapshot read: {e}"))))? + .ok_or_else(|| rate_snapshot_not_found(rate_id))?; + + Ok(Json(FxRateSnapshotResponse::from(snapshot))) +} + +// `CanonicalJson` runs (and may 400) BEFORE the in-handler auth gate (standard +// axum extractor order; no authenticated-only data is disclosed). +async fn trigger_revaluation( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = body.tenant_id; + // (ledger, provision) gate against the TARGET tenant: triggering a revaluation + // run is a finance/ops action on the seller's books (the same family as the FX + // rate ingest); the returned scope is the SQL-level write scope for the run. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::LEDGER, + crate::authz::actions::PROVISION, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + body.validate()?; + // VHP-1986 per-tenant gate: resolve the target tenant's effective Mode A/B (an + // explicit row wins; else the fleet default). Mode A defers to the tenant's ERP + // → every scope reports `disabled` (no double-count); Mode B runs the revaluation. + let revalue = state + .fx_revaluation_mode + .read_effective_mode(&scope, tenant_id, chrono::Utc::now()) + .await + .map_err(|e| { + CanonicalError::from(DomainError::Internal(format!( + "read fx revaluation mode: {e}" + ))) + })? + .unwrap_or(RevaluationMode::fleet_default( + state.fleet_revaluation_enabled, + )) + .revalues(); + let report = state + .revaluation_run + .run_period(&ctx, &scope, tenant_id, &body.period_id, revalue) + .await + .map_err(CanonicalError::from)?; + + let scopes = report.scopes.iter().map(scope_outcome_dto).collect(); + Ok(( + StatusCode::OK, + Json(RevaluationRunResponse { + period_id: report.period_id, + scopes, + }), + ) + .into_response()) +} + +/// Map a runner [`ScopeOutcome`] to the wire DTO (status string + entry/grain +/// counts). The reversal statuses never arise on the forward `run_period` path but +/// are mapped for completeness (the enum is shared with the job's reversal pass). +fn scope_outcome_dto(outcome: &ScopeOutcome) -> RevaluationScopeOutcomeDto { + let (status, entries, grains) = match &outcome.status { + ScopeStatus::Disabled => ("disabled", 0, 0), + ScopeStatus::NothingToPost => ("nothing_to_post", 0, 0), + ScopeStatus::Posted { entries, grains } => ( + "posted", + i64::try_from(*entries).unwrap_or(i64::MAX), + i64::try_from(*grains).unwrap_or(i64::MAX), + ), + ScopeStatus::NothingToReverse => ("nothing_to_reverse", 0, 0), + ScopeStatus::ReversalDeferred => ("reversal_deferred", 0, 0), + ScopeStatus::Reversed { entries } => { + ("reversed", i64::try_from(*entries).unwrap_or(i64::MAX), 0) + } + }; + RevaluationScopeOutcomeDto { + scope: outcome.scope.as_token().to_owned(), + status: status.to_owned(), + entries, + grains, + } +} diff --git a/gears/bss/ledger/ledger/src/api/rest/fx_revaluation_mode.rs b/gears/bss/ledger/ledger/src/api/rest/fx_revaluation_mode.rs new file mode 100644 index 000000000..ce661ebe3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/fx_revaluation_mode.rs @@ -0,0 +1,232 @@ +//! Axum handlers + router for the per-tenant FX revaluation mode (VHP-1986). +//! `POST /bss-ledger/v1/fx/revaluation-mode` appends an effective-dated version +//! (`MODE_A` defer-to-ERP | `MODE_B` BSS-is-ledger-of-record); `GET …` reads the +//! tenant's effective mode. The write gates on `(ledger_config, write)`, the read +//! on `(ledger_config, read)` — the shared config-plane resource (it shares the +//! posting-policy config resource), not the `entry` data plane. + +use std::sync::Arc; + +use axum::extract::{Extension, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use chrono::{DateTime, Utc}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::error::authz_error_to_canonical; +use crate::domain::error::DomainError; +use crate::domain::fx::revaluation_mode::RevaluationMode; +use crate::infra::storage::repo::FxRevaluationModeRepo; + +/// `OpenAPI` tag applied to the FX revaluation-mode operations. +const TAG: &str = "BSS Ledger FX Revaluation Mode"; + +/// Shared per-request state for the FX revaluation-mode routes. Constructed once +/// at `init()` and shared via `Extension>`. +#[derive(Clone)] +pub struct ApiState { + /// The FX revaluation-mode repository (read effective + write a version). + pub fx_revaluation_mode: FxRevaluationModeRepo, +} + +/// A new FX revaluation-mode version to write (VHP-1986). `effective_from` +/// defaults to now when omitted. An unknown `revaluation_mode` is rejected (400), +/// never coerced. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct SetFxRevaluationModeRequest { + /// `MODE_A` (defer the period-end revaluation to the tenant's ERP — the + /// fail-safe default) or `MODE_B` (BSS is the ledger of record and runs the + /// unrealized revaluation). + pub revaluation_mode: String, + /// When this version takes effect; defaults to now. + pub effective_from: Option>, +} + +/// The written FX revaluation-mode version (the minted `version` + the value it +/// carries; the resolver picks the latest `effective_from`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct FxRevaluationModeResponse { + /// The minted version (`max + 1`, `0` for the first). + pub version: i64, + /// The instant this version takes effect. + pub effective_from: DateTime, + /// `MODE_A` | `MODE_B`. + pub revaluation_mode: String, +} + +/// The tenant's effective FX revaluation mode (read-surface) — the configured +/// version in force, or the gear default (`MODE_A`) when the tenant has set none. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct FxRevaluationModeView { + /// `MODE_A` | `MODE_B`. + pub revaluation_mode: String, +} + +/// The `?tenant_id=` query for `GET /fx/revaluation-mode`: the tenant whose +/// effective mode to read — the caller's own when omitted. +#[derive(Debug, serde::Deserialize)] +struct ModeQuery { + tenant_id: Option, +} + +/// Build the Axum router for the FX revaluation-mode surface and register its +/// operations with the supplied `OpenAPI` registry. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/fx/revaluation-mode") + .operation_id("bss_ledger.set_fx_revaluation_mode") + .summary("Set the tenant FX revaluation mode") + .description( + "Writes a new effective-dated FX revaluation-mode version (`MODE_A` \ + defer-to-ERP | `MODE_B` BSS-is-ledger-of-record) for the caller's \ + tenant (VHP-1986). Append-only: a new version supersedes; the \ + revaluation job / period-close pick the latest effective_from (highest \ + version on a tie). An unknown mode is rejected (400), never coerced. \ + Requires `config_write.v1`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "The mode version to write.") + .handler(set_mode) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The written mode version.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/fx/revaluation-mode") + .operation_id("bss_ledger.get_fx_revaluation_mode") + .summary("Read the effective FX revaluation mode") + .description( + "Returns the tenant's EFFECTIVE FX revaluation mode: the version in \ + force now (latest `effective_from <= now`, highest `version` on a \ + tie), or the gear default (`MODE_A`, fail-safe off) when the tenant \ + has set no mode row. `tenant_id` defaults to the caller's own. Gates \ + on `(ledger_config, read)` — the shared config-plane resource, not \ + an `entry` data-plane read; tenant-scoped (SQL-level BOLA) so a tenant \ + outside the caller's subtree reads the gear default (no leak). Always \ + `200`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Tenant whose effective mode to read (the caller's own by default)", + ) + .handler(get_mode) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The effective FX revaluation mode (configured version or gear default).", + ) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// `POST /fx/revaluation-mode`: write a new effective-dated version. Gates on +/// `(fx_revaluation_mode, write)`; validates the request into the domain type (400 +/// on a bad mode) before the append. +async fn set_mode( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = ctx.subject_tenant_id(); + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::LEDGER_CONFIG, + crate::authz::actions::WRITE, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let mode = RevaluationMode::parse(&body.revaluation_mode) + .map_err(|e| CanonicalError::from(DomainError::InvalidRequest(e.to_string())))?; + let effective_from = body.effective_from.unwrap_or_else(Utc::now); + let version = state + .fx_revaluation_mode + .write_version(&scope, tenant_id, mode, effective_from) + .await + .map_err(|e| { + CanonicalError::from(DomainError::Internal(format!( + "write fx revaluation mode: {e}" + ))) + })?; + Ok(( + StatusCode::OK, + Json(FxRevaluationModeResponse { + version, + effective_from, + revaluation_mode: body.revaluation_mode, + }), + ) + .into_response()) +} + +/// `GET /fx/revaluation-mode`: read the caller tenant's effective mode. Gates on +/// `(fx_revaluation_mode, read)`; binds the compiled scope as the SQL-level BOLA +/// filter. Always `200` — the effective mode is the configured version or the gear +/// default (`MODE_A`). +async fn get_mode( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::LEDGER_CONFIG, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let effective = state + .fx_revaluation_mode + .read_effective_mode(&scope, tenant_id, Utc::now()) + .await + .map_err(|e| { + CanonicalError::from(DomainError::Internal(format!( + "read fx revaluation mode: {e}" + ))) + })? + // The per-tenant configured mode, or the gear default `MODE_A` when unset. + // (A fleet-wide `fx.revaluation_enabled` may elevate unconfigured tenants + // to ModeB at the revaluation-job level; this read reports the per-tenant + // configuration, not the fleet-resolved value.) + .unwrap_or(RevaluationMode::ModeA); + Ok(Json(FxRevaluationModeView { + revaluation_mode: effective.as_str().to_owned(), + })) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/journal_entries.rs b/gears/bss/ledger/ledger/src/api/rest/journal_entries.rs new file mode 100644 index 000000000..ba528f370 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/journal_entries.rs @@ -0,0 +1,1087 @@ +//! Axum handlers + router for the ledger's journal-entry / balance REST surface +//! (architecture §6). Eight operations under `/bss-ledger/v1`, all tenant-scoped +//! WITHOUT a tenant in the path (the vhp-core convention, matching the +//! provisioning surface): writes carry `tenant_id` in the **body**, reads take a +//! `?tenant_id=` **query** param (the caller's own tenant by default); both are +//! PDP-`In` scoped (SQL-level BOLA). +//! +//! Writes (post / reverse / mapping-correction): +//! - `POST /journal-entries` — body `tenant_id`; PEP `(entry, post)` against it; +//! drives the `InvoicePostService` (metrics + suspense). `201` fresh / +//! `200` idempotent replay. +//! - `POST /journal-entries/{entryId}/reversals` — `{entryId}` is the reversed +//! entry id (NOT a tenant); tenant from the auth context; PEP `(entry, +//! reverse)`. Reads the original, builds a strict line-negation reversal, +//! posts it. A reverse-of-a-reversal is a 400 `CANNOT_REVERSE_REVERSAL`. +//! - `POST /journal-entries/{entryId}/mapping-corrections` — same gate; a +//! reversal of the mis-mapped original followed by a corrected re-post. +//! - `PATCH /journal-entries/{entryId}/annotation` — PEP `(entry, annotate)`; sets +//! ONE typed controlled non-financial note (`description`) on the entry (or a +//! line). Records the current-state `entry_annotation` row + a `metadata-change` +//! secured-audit record in one SERIALIZABLE txn; the journal stays byte-identical. +//! A `description` carrying raw customer PII is refused `PII_IN_METADATA_VALUE`. +//! +//! Reads (`(entry, read)`, PDP-scoped): +//! - `GET /journal-entries/{entryId}` — tenant from the context. +//! - `GET /journal-lines?tenant_id=&$filter=&$orderby=&cursor=&limit=` — canonical +//! `$filter` over `payer_tenant_id`/`account_class`/`period_id`/`invoice_id`. +//! - `GET /balances?tenant_id=&$filter=&$orderby=&cursor=&limit=` — `$filter` over +//! `account_class`/`currency`. +//! - `GET /balances/ar-aging?tenant_id=&payer=` — buckets via +//! [`crate::domain::invoice::aging::ar_aging`]. +//! +//! Routes register through `OperationBuilder` so `/openapi.json` lists each +//! operation with its declared request / response schemas. + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use bss_ledger_sdk::api::LedgerClientV1; +use chrono::Utc; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::odata::OData; +use toolkit::api::operation_builder::OperationBuilderODataExt; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_odata::Page; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::local_client::map_odata_page_err; +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{ + ArAgingDto, BalanceDto, EntryAnnotationRequestDto, EntryDto, EntryHeaderView, LineDto, + MappingCorrectionRequestDto, MaterializedScheduleDto, PostInvoiceRequestDto, + PostInvoiceResponseDto, PostingRefDto, ReversalRequestDto, +}; +use crate::api::rest::error::{ + authz_error_to_canonical, entry_not_found, reversal_error_to_canonical, +}; +use crate::domain::invoice::aging::ar_aging; +use crate::domain::invoice::builder::build_invoice_entry; +use crate::domain::invoice::mapping::resolve; +use crate::domain::invoice::policy::AgingThresholds; +use crate::domain::invoice::reversal::{build_mapping_correction, build_reversal}; +use crate::infra::invoice_post::InvoicePoster; +use crate::infra::storage::repo::{JournalRepo, PostingPolicyRepo}; +use crate::odata::{BalanceFilterField, JournalEntryFilterField, JournalLineFilterField}; + +/// `OpenAPI` tag applied to the journal-entry operations. +const TAG: &str = "BSS Ledger Journal"; + +/// Shared per-request state for the journal-entry routes. Built once at `init()` +/// and shared via `Extension>`. Carries BOTH the in-process +/// data-access client (reads + `get_entry`) and the `InvoicePostService` (the +/// write orchestrator that emits metrics + handles suspense on the post path). +#[derive(Clone)] +pub struct ApiState { + /// In-process data-access client (reads: entry / lines / balances / AR). + pub client: Arc, + /// Invoice-post write port (writes: post / reversal / mapping-correction). + /// A trait object so the router tests can stub the post path without a DB. + pub posting: Arc, + /// Dual-control lifecycle engine (VHP-1852): the reverse gate routes a + /// high-value reversal to the preparer→approver queue instead of posting it. + /// `None` disables the gate (router unit tests without a governance DB). + pub approval: Option>, + /// Typed controlled-annotation write port (Group 2B; the `PATCH …/annotation` + /// surface). Records the `entry_annotation` current-state row + a + /// `metadata-change` secured-audit record in one `SERIALIZABLE` transaction. + /// A trait object so the router tests can stub it without a DB. + pub annotation: Arc, + /// The journal repo — the `GET /journal-entries` HEADER-list read source (R5): + /// a plain scoped read over its own db clone, called DIRECTLY from the handler + /// (NOT through `client`), mirroring the refund / dispute read-surface repos. + /// The by-id `GET /journal-entries/{entryId}` read still goes through `client`. + /// `Option` ONLY so the stub-based REST tests (which carry no DB) can build + /// `ApiState` with `None` (mirrors `approval`); production ALWAYS wires `Some` + /// (see `module.rs`). + pub journal_repo: Option, + /// Tenant posting-policy repo (VHP-1853) — the AR-aging read resolves the + /// tenant's configured bucket thresholds through it. `Option` for the stub + /// REST tests (`None` ⇒ the gear default buckets); production wires `Some`. + pub posting_policy: Option, +} + +/// Build the Axum router for the journal-entry surface and register every +/// operation with the supplied `OpenAPI` registry. `state` is attached via an +/// `Extension` layer at the end so the registry sees the route definitions +/// before the per-request state is bound. +#[allow(clippy::too_many_lines)] // one builder chain per operation; flat is clearer than helpers +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/journal-entries") + .operation_id("bss_ledger.post_invoice") + .summary("Post a fully-recognized invoice (Variant A)") + .description( + "Builds + posts the balanced direct-split entry (DR AR / CR Revenue \ + per stream / CR Tax) for the invoice in the body. The target seller \ + ledger is the body's `tenant_id`. Idempotent on the invoice id: a \ + re-post returns the prior posting reference (200) instead of a new \ + one (201).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "The invoice to post.") + .handler(post_invoice) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference + the recognition schedules materialized by the post \ + (201 fresh post)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior post (with its materialized schedules)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/journal-entries/{entryId}/reversals") + .operation_id("bss_ledger.reverse_entry") + .summary("Reverse a posted entry (strict line-negation)") + .description( + "Posts the reversing entry for `{entryId}`: the same accounts with \ + each line's side flipped and its amount kept positive. The tenant is \ + the caller's own. Reversing an entry that is itself a reversal is \ + rejected (400 CANNOT_REVERSE_REVERSAL) — correct forward by \ + re-posting, never stack reversals.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Reversal reason + optional target period.") + .handler(reverse_entry) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference of the reversal (201 / 200 replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior post", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/journal-entries/{entryId}/mapping-corrections") + .operation_id("bss_ledger.correct_mapping") + .summary("Correct a mis-mapped entry (reverse + corrected re-post)") + .description( + "Clears the mis-mapped original `{entryId}` with a strict reversal, \ + then re-posts the corrected lines (MAPPING_CORRECTION, keyed on the \ + invoice id + a stable correction id so a retry replays). The tenant \ + is the caller's own.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "Correction reason + the corrected lines.", + ) + .handler(correct_mapping) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference of the corrected re-post (201 / 200 replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior post", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/journal-entries/{entryId}") + .operation_id("bss_ledger.get_entry") + .summary("Read a posted entry with its lines") + .description( + "Returns the journal entry `{entryId}` (header + lines + audit dims) \ + for the caller's own tenant. An entry outside the caller's authorized \ + subtree yields 404 (no existence leak).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .handler(get_entry) + .json_response_with_schema::(openapi, StatusCode::OK, "The entry with its lines") + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/journal-entries") + .operation_id("bss_ledger.list_journal_entries") + .summary("List journal entry headers (cursor-paginated)") + .description( + "Cursor-paginated list of journal entry HEADERS for the `tenant_id` \ + query (the caller's own by default). Supports OData `$filter` over \ + `source_doc_type`, `source_business_id`, and `period_id` — the \ + header-only dims that enable cross-cuts like \"all `MANUAL_ADJUSTMENT` \ + entries\" or \"all `REFUND` / `CREDIT_NOTE` entries\" (these live on \ + the entry HEADER, not on `journal_line`). Each item is a lightweight \ + `EntryHeaderView` (NO lines, NO hash-chain fields); read the full \ + entry with its lines via `GET /journal-entries/{entryId}`. The \ + `$filter` ANDs the caller's authorized subtree, so headers outside it \ + are never returned (SQL-level BOLA).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Target tenant (defaults to the caller's own)", + ) + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_journal_entries) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of journal entry headers", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::patch("/bss-ledger/v1/journal-entries/{entryId}/annotation") + .operation_id("bss_ledger.set_entry_annotation") + .summary("Set a controlled non-financial annotation") + .description( + "Sets ONE typed controlled non-financial note (`description`) on the entry \ + (or one of its lines via `target_kind=LINE` + `target_line_id`). The note \ + is screened for raw customer PII before any write (PII_IN_METADATA_VALUE). \ + Every change is recorded in the `entry_annotation` current-state row + a \ + secured-audit record, all under one SERIALIZABLE transaction. The gate is \ + `(entry, annotate)` against the entry's tenant.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "The annotation to set.") + .handler(set_entry_annotation) + .no_content_response( + StatusCode::NO_CONTENT, + "The metadata change was recorded (no body)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_409(openapi) + .error_422(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/journal-lines") + .operation_id("bss_ledger.list_lines") + .summary("List journal lines (cursor-paginated)") + .description( + "Cursor-paginated list of journal lines for the `tenant_id` query \ + (the caller's own by default). Supports OData `$filter` over \ + `payer_tenant_id`, `account_class`, `period_id`, and `invoice_id`. \ + The `$filter` ANDs the caller's authorized subtree, so rows outside \ + it are never returned (SQL-level BOLA).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Target tenant (defaults to the caller's own)", + ) + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_lines) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of journal lines", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/balances") + .operation_id("bss_ledger.list_balances") + .summary("List account-balance cache rows (cursor-paginated)") + .description( + "Cursor-paginated list of the account-balance cache rows for the \ + `tenant_id` query (the caller's own by default). Supports OData \ + `$filter` over `account_class` and `currency`. The `$filter` ANDs \ + the caller's authorized subtree, so rows outside it are excluded \ + (SQL-level BOLA). Each row carries BOTH the transaction-currency \ + `balance_minor` and the Slice-5 functional valuation \ + (`functional_balance_minor` / `functional_currency`); the latter is \ + `null` on a single-currency grain, where the functional value equals \ + `balance_minor` by identity (`?valuation=functional` fallback, P1 \ + decision 8).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Target tenant (defaults to the caller's own)", + ) + .query_param( + "valuation", + false, + "Valuation lens: `transaction` (default) or `functional`. Both columns \ + are always returned; this selects which the client should read \ + (functional falls back to the transaction balance on a \ + single-currency grain).", + ) + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_balances) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of account-balance cache rows in scope", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/balances/ar-aging") + .operation_id("bss_ledger.ar_aging") + .summary("AR aging buckets") + .description( + "Buckets the open per-invoice AR for the `tenant_id` query (the \ + caller's own by default), optionally narrowed to one `payer`, into \ + days-past-due buckets (current / 1-30 / 31-60 / 61-90 / 90+) per \ + (payer, currency).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Target tenant (defaults to the caller's own)", + ) + .query_param("payer", false, "Narrow to one payer tenant id") + .handler(ar_aging_handler) + .json_response_with_schema::(openapi, StatusCode::OK, "The aged AR buckets") + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// A posting reference rendered with the right status: `201 Created` for a fresh +/// post, `200 OK` for an idempotent replay of a prior post. +fn posting_response(reference: bss_ledger_sdk::PostingRef) -> Response { + let status = if reference.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + (status, Json(PostingRefDto::from(reference))).into_response() +} + +/// The invoice-post response: like [`posting_response`] (`201` fresh / `200` +/// replay) but carrying the recognition schedules the post materialized so a +/// REST client learns the server-minted `schedule_id`(s) (`schedules` is empty +/// for a point-in-time invoice). +fn post_invoice_response( + reference: bss_ledger_sdk::PostingRef, + schedules: Vec, +) -> Response { + let status = if reference.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + let body = PostInvoiceResponseDto { + entry_id: reference.entry_id, + created_seq: reference.created_seq, + replayed: reference.replayed, + schedules, + }; + (status, Json(body)).into_response() +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) +// BEFORE the in-handler `require_authenticated` gate, so a malformed body yields +// 400 even for an unauthenticated caller (standard axum extractor ordering; no +// authenticated-only data is disclosed). +async fn post_invoice( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id` (tenant in body, not path). + let tenant_id = body.tenant_id; + // Captured before `into_domain` consumes the body: whether to look up the + // materialized schedules after the post (skip the extra read for a + // point-in-time invoice that mints none), and the invoice id to filter by. + let has_recognition = body.items.iter().any(|it| it.recognition.is_some()); + let invoice_id = body.invoice_id.clone(); + // (entry, post) PEP gate against the TARGET tenant: a parent posts into a + // seller in its authorized subtree; a target outside the caller's scope is a + // cross-tenant write and is denied. The returned scope threads into the + // scoped post (the SQL-level BOLA filter). + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::POST, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // `posted_by_actor_id` is the authenticated subject, stamped server-side — + // never read from the body (matching reverse_entry/correct_mapping, which + // also stamp the actor from `ctx.subject_id()`). + let inv = body + .into_domain(ctx.subject_id()) + .map_err(CanonicalError::from)?; + + // Dual-control gate (VHP-1852, Group J): a post whose `effective_at` predates + // the tenant's A6 backdating window is a *material backdating* — route it to + // the preparer→approver queue (409) instead of posting inline. The ledger + // object does not exist yet (the gate fires before the post) and the source + // invoice is external (pushed in the body, never pulled), so the whole post is + // snapshotted into the intent for replay on approve. A current-dated post (the + // common case) resolves to `None` and stays single-actor, unchanged. + if let Some(approval) = &state.approval { + let intent = crate::domain::approval::intent::ApprovalIntent::MaterialBackdating( + crate::domain::approval::intent::BackdatedPost::Invoice( + crate::domain::approval::intent::BackdatedInvoiceSnapshot::from(&inv), + ), + ); + let facts = crate::domain::approval::policy::OperationFacts { + kind: crate::domain::approval::ApprovalKind::MaterialBackdating, + amount_usd_eq_minor: Some(inv.gross_minor()), + effective_at: Some(inv.effective_at), + has_outstanding_balance: false, + }; + let reason = format!( + "material backdating of invoice {} to {}", + inv.invoice_id, inv.effective_at + ); + if let Some(approval_id) = approval + .gate(&ctx, &scope, intent, facts, reason) + .await + .map_err(CanonicalError::from)? + { + return Err(CanonicalError::from( + crate::domain::error::DomainError::DualControlRequired(format!( + "material backdating requires dual-control approval: {approval_id}" + )), + )); + } + } + + // The 201/200 response echoes the materialized recognition schedules, which + // the in-process client reads under `(recognition, read)` (see + // `list_recognition_schedules` → `read_recognition_scope`). Preflight that same + // grant HERE — before the post commits — so a post-only caller fails fast with + // 403 instead of committing the invoice and THEN 403-ing on the echo read (a + // committed-but-failed response with ambiguous retry semantics). Only a + // deferred invoice echoes a schedule, so only it additionally requires + // `(recognition, read)`. Mirror the echo read's args exactly (no target-tenant + // anchor) so the preflight neither over- nor under-approves relative to it. + if has_recognition { + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::READ, + None, + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + } + + // `payer_open = true` at the REST seam: the gear has no payer-state reader, + // and the foundation account-lifecycle invariant is the authority for a + // genuinely closed payer (a closed AR account is rejected at post time). The + // gear-side fast-path payer gate is exercised by the Group C integration + // test, which drives `InvoicePostService::post_invoice` directly. + let reference = state + .posting + .post_invoice(&ctx, &scope, &inv, true) + .await + .map_err(CanonicalError::from)?; + // Surface the schedule_id(s) this post materialized so a REST client can read + // / change them without subscribing to the event bus (the ids are minted + // server-side in the post txn). Read-after-commit by `(tenant, invoice_id)` — + // covers both a fresh post and an idempotent replay; skipped entirely for a + // point-in-time invoice that mints no schedule. The same `(entry, read)` scope + // the by-id / list reads run under. + let schedules = if has_recognition { + state + .client + .list_recognition_schedules(&ctx, tenant_id, Some(invoice_id), None) + .await? + .schedules + .into_iter() + .map(MaterializedScheduleDto::from) + .collect() + } else { + Vec::new() + }; + Ok(post_invoice_response(reference, schedules)) +} + +async fn reverse_entry( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(entry_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // Tenant from the auth context; the scoped `get_entry` read enforces BOLA + // (a foreign entry resolves to None ⇒ 404, no existence leak). + let tenant_id = ctx.subject_tenant_id(); + let original = state + .client + .get_entry(&ctx, tenant_id, entry_id) + .await? + .ok_or_else(|| entry_not_found(entry_id))?; + + // (entry, reverse) gate against the original's owning tenant. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::REVERSE, + Some(original.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // Dual-control gate (VHP-1852): a reversal whose magnitude crosses the D2 + // threshold routes to the preparer→approver queue (409) instead of posting + // inline; below threshold it stays single-actor (unchanged). + if let Some(approval) = &state.approval { + let reverse_amount: i64 = original + .lines + .iter() + .filter(|l| l.side == bss_ledger_sdk::Side::Debit) + .map(|l| l.amount_minor) + .sum(); + let intent = crate::domain::approval::intent::ApprovalIntent::Reverse( + crate::domain::approval::intent::ReverseIntent { + entry_id: original.entry_id, + into_period_id: body.period_id.clone(), + effective_at: body.effective_at, + reason: body.reason.clone(), + }, + ); + let facts = crate::domain::approval::policy::OperationFacts { + kind: crate::domain::approval::ApprovalKind::Reverse, + amount_usd_eq_minor: Some(reverse_amount), + effective_at: None, + has_outstanding_balance: false, + }; + if let Some(approval_id) = approval + .gate(&ctx, &scope, intent, facts, body.reason.clone()) + .await + .map_err(CanonicalError::from)? + { + return Err(CanonicalError::from( + crate::domain::error::DomainError::DualControlRequired(format!( + "reversal requires dual-control approval: {approval_id}" + )), + )); + } + } + + let into_period = body.period_id.unwrap_or_else(|| original.period_id.clone()); + let effective_on = body.effective_at.unwrap_or_else(|| Utc::now().date_naive()); + let reversal = build_reversal( + &original, + into_period, + effective_on, + ctx.subject_id(), + original.correlation_id, + ) + .map_err(reversal_error_to_canonical)?; + + let reference = state + .posting + .post_reversal(&ctx, &scope, reversal, Some(body.reason)) + .await + .map_err(CanonicalError::from)?; + Ok(posting_response(reference)) +} + +/// `MAPPING_CORRECTION` is **two posts in two transactions** — a reversal of the +/// mis-mapped original, then a corrected re-post — made safe by **idempotent +/// retry**, not a single atomic transaction (architecture §4.2 / I-13). The +/// `correction_id = hash(original, reversal)` is computed from the **replayed** +/// reversal id, so re-issuing the same request after a crash between the two +/// posts replays the reversal (no double) and deterministically re-keys the +/// correction (posts or replays) — the flow self-heals on retry. Residual: a +/// crash with no retry leaves a dangling reversal (accepted trade-off of the +/// idempotent-retry design; true single-txn atomicity would need a foundation +/// multi-entry-per-txn engine the architecture deliberately avoided). +async fn correct_mapping( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(entry_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = ctx.subject_tenant_id(); + let original = state + .client + .get_entry(&ctx, tenant_id, entry_id) + .await? + .ok_or_else(|| entry_not_found(entry_id))?; + + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::REVERSE, + Some(original.tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // Re-map the corrected items to their GL targets (parse class literals at + // the boundary ⇒ 400 on a bad literal) before any ledger effect. + let corrected_items = body + .corrected_items_into_domain() + .map_err(CanonicalError::from)?; + + let into_period = body + .period_id + .clone() + .unwrap_or_else(|| original.period_id.clone()); + let effective_on = body.effective_at.unwrap_or_else(|| Utc::now().date_naive()); + + // Cross-currency mapping-correction is not yet supported: the reversal half + // (step 1) clears the original's functional at the original rate, but the + // corrected re-post (step 2) is freshly rebuilt and would re-book + // transaction-only — dropping the invoice's functional (a silent functional + // drift on the corrected grains, which `m031` cannot catch because they seed as + // single-currency). Reject up front until Slice 7 carries the functional onto + // the corrected lines; single-currency corrections are unaffected. + if original + .lines + .iter() + .any(|l| l.functional_currency.is_some()) + { + return Err(CanonicalError::from( + crate::domain::error::DomainError::FxOperationUnsupported(format!( + "cross-currency mapping correction for entry {} is not yet supported \ + (the corrected re-post cannot carry the original functional rate — Slice 7)", + original.entry_id + )), + )); + } + + // 1. Reverse the mis-mapped original (clears the bad mapping). + let reversal = build_reversal( + &original, + into_period.clone(), + effective_on, + ctx.subject_id(), + original.correlation_id, + ) + .map_err(reversal_error_to_canonical)?; + let reversal_ref = state + .posting + // A mapping-correction's reversal leg is internal plumbing, not a §6 + // reversal — pass `None` so it announces no `entry.reversed`. + .post_reversal(&ctx, &scope, reversal, None) + .await + .map_err(CanonicalError::from)?; + + // 2. Re-post the corrected lines (MAPPING_CORRECTION). The invoice id is the + // original's AR `source_business_id`; the corrected lines are rebuilt as a + // fresh direct-split entry, then re-keyed under the correction. + let invoice_id = &original.source_business_id; + let mapped: Vec<_> = corrected_items.iter().map(resolve).collect(); + let rebuilt = corrected_invoice(&original, invoice_id, &corrected_items, &mapped); + let correction = build_mapping_correction( + &original, + reversal_ref.entry_id, + invoice_id, + into_period, + effective_on, + ctx.subject_id(), + original.correlation_id, + rebuilt.lines, + ); + // The corrected re-post's lines carry nil placeholder account_ids (freshly + // built); `post_correction` binds them from the chart before posting. + let reference = state + .posting + .post_correction(&ctx, &scope, correction) + .await + .map_err(CanonicalError::from)?; + Ok(posting_response(reference)) +} + +/// Build the corrected re-post lines as a direct-split entry over the original's +/// payer/seller, so `build_mapping_correction` can re-key + re-head them. The +/// corrected lines still need their chart `account_id`s bound; that binding is +/// the `InvoicePostService`'s job on the post path, so the rebuilt entry carries +/// the nil placeholders the builder emits. +fn corrected_invoice( + original: &bss_ledger_sdk::EntryView, + invoice_id: &str, + items: &[crate::domain::invoice::builder::InvoiceItem], + mapped: &[crate::domain::invoice::mapping::MappedLine], +) -> bss_ledger_sdk::PostEntry { + // Reconstruct a minimal PostedInvoice over the original's dims. The payer + + // seller come from the original AR line / entry tenant; tax is empty (the + // correction re-books the recognized revenue split — a tax correction is a + // separate reversal). + let payer = original + .lines + .iter() + .find(|l| l.account_class == bss_ledger_sdk::AccountClass::Ar) + .map_or(original.tenant_id, |l| l.payer_tenant_id); + let inv = crate::domain::invoice::builder::PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: payer, + resource_tenant_id: None, + seller_tenant_id: original.tenant_id, + effective_at: original.effective_at, + due_date: None, + period_id: original.period_id.clone(), + items: items.to_vec(), + tax: Vec::new(), + posted_by_actor_id: original.posted_by_actor_id, + correlation_id: original.correlation_id, + }; + build_invoice_entry(&inv, mapped) +} + +async fn get_entry( + Extension(state): Extension>, + extension_ctx: Option>, + Path(entry_id): Path, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + // Tenant from the context; the client's PDP `In` scope is the SQL-level BOLA + // filter (a foreign entry resolves to None ⇒ 404). + let tenant_id = ctx.subject_tenant_id(); + let entry = state + .client + .get_entry(&ctx, tenant_id, entry_id) + .await? + .ok_or_else(|| entry_not_found(entry_id))?; + Ok(Json(EntryDto::from(entry))) +} + +/// `PATCH /journal-entries/{entryId}/annotation`: set the typed controlled +/// `description` note on the entry (or one of its lines). +/// +/// Flow: authenticate → read the entry (404 `entry_not_found` if absent or +/// outside the caller's subtree — the scoped read is the SQL-level BOLA) → PEP +/// `(entry, annotate)` gate against the entry's OWN tenant → resolve the target +/// (`ENTRY` ⇒ the entry id + its period; `LINE` ⇒ a line of the entry) → +/// `AnnotationService::set` (pre-write PII screen, then one SERIALIZABLE upsert +/// + audit write). 204 No Content on success. +/// +/// The `metadata-change` secured-audit record's `correlation_id` is the +/// annotated entry's OWN `journal_entry.correlation_id` (read back above), NOT a +/// request trace header — so the forensic record joins back to the entry it +/// annotated by construction (S-1 cross-trace), independent of any client header. +async fn set_entry_annotation( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(entry_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + + let caller_tenant = ctx.subject_tenant_id(); + let entry = state + .client + .get_entry(&ctx, caller_tenant, entry_id) + .await? + .ok_or_else(|| entry_not_found(entry_id))?; + + // (entry, annotate) PEP gate against the entry's OWN owning tenant. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::ANNOTATE, + Some(entry.tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let kind_literal = body.target_kind.as_deref().unwrap_or("ENTRY"); + let target = crate::infra::annotation::AnnotationTarget::parse(kind_literal) + .map_err(CanonicalError::from)?; + let target_id = match target { + crate::infra::annotation::AnnotationTarget::Entry => entry.entry_id, + crate::infra::annotation::AnnotationTarget::Line => { + let line_id = body.target_line_id.ok_or_else(|| { + crate::domain::error::DomainError::InvalidRequest( + "target_line_id is required when target_kind = LINE".to_owned(), + ) + })?; + if !entry.lines.iter().any(|l| l.line_id == line_id) { + return Err(entry_not_found(line_id)); + } + line_id + } + }; + + let actor_ref = ctx.subject_id().to_string(); + state + .annotation + .set( + &ctx, + &scope, + entry.tenant_id, + target_id, + entry.period_id.clone(), + target, + body.description, + actor_ref, + Some(body.reason), + // S-1 cross-trace: anchor the audit record on the annotated entry's + // OWN correlation_id, so the `metadata-change` record joins back to + // `journal_entry` by construction (not by a client-supplied header). + Some(entry.correlation_id), + ) + .await + .map_err(CanonicalError::from)?; + + Ok(StatusCode::NO_CONTENT.into_response()) +} + +/// `GET /journal-lines` non-OData query: the target tenant (the caller's own +/// when omitted). The `OData` `$filter` / `$orderby` / `limit` / `cursor` are +/// parsed separately by the `OData` extractor from the same query string; +/// `tenant_id` stays a plain param alongside them (the RBAC list convention). +#[derive(Debug, serde::Deserialize)] +struct LinesQuery { + tenant_id: Option, +} + +async fn list_lines( + Extension(state): Extension>, + extension_ctx: Option>, + Query(query): Query, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + let page = state.client.list_lines(&ctx, tenant_id, &odata).await?; + Ok(Json(Page { + items: page.items.into_iter().map(LineDto::from).collect(), + page_info: page.page_info, + })) +} + +/// `GET /journal-entries` non-OData query: the target tenant (the caller's own +/// when omitted). The `OData` `$filter` / `$orderby` / `limit` / `cursor` are +/// parsed separately by the `OData` extractor from the same query string; +/// `tenant_id` stays a plain param alongside them (the list convention). NOTE: the +/// same path also serves `POST /journal-entries` (the invoice-post write) and is +/// the prefix of `GET /journal-entries/{entryId}` (the by-id read) — this is the +/// HEADER-list `GET` over the bare collection. +#[derive(Debug, serde::Deserialize)] +struct JournalEntriesQuery { + tenant_id: Option, +} + +/// `GET /journal-entries`: cursor-paginated list of journal entry HEADERS (R5). +/// +/// Unlike `list_lines` (which goes through `state.client`, the client gating the +/// PEP internally), this calls `state.journal_repo` DIRECTLY, so the handler-layer +/// `(entry, read)` gate is the authority — mirroring `disputes::list_disputes` / +/// `refunds::list_refunds`. The returned scope is the SQL-level BOLA filter the +/// repo binds, so the page never contains a foreign-tenant header (no existence +/// leak). The `$filter` over `source_doc_type` / `source_business_id` / `period_id` +/// is additive over that scope (it never replaces it). +async fn list_journal_entries( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + // (entry, read) PEP gate against the target tenant — the SAME action the by-id + // entry read / journal-lines / balances run under. The returned scope is the + // SQL-level BOLA filter the repo binds, so the page never contains a foreign- + // tenant header (no existence leak), mirroring `disputes::list_disputes`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let page = state + .journal_repo + .as_ref() + .ok_or_else(|| CanonicalError::internal("journal repository not configured").create())? + .list_entries(&scope, tenant_id, &odata) + .await + .map_err(map_odata_page_err)?; + Ok(Json(Page { + items: page.items.into_iter().map(EntryHeaderView::from).collect(), + page_info: page.page_info, + })) +} + +/// `GET /balances` non-OData query: the target tenant (the caller's own when +/// omitted). The `OData` `$filter` / `$orderby` / `limit` / `cursor` are parsed +/// separately by the `OData` extractor; `tenant_id` stays a plain param. +#[derive(Debug, serde::Deserialize)] +struct BalancesQuery { + tenant_id: Option, +} + +async fn list_balances( + Extension(state): Extension>, + extension_ctx: Option>, + Query(query): Query, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + let page = state.client.list_balances(&ctx, tenant_id, &odata).await?; + Ok(Json(Page { + items: page.items.into_iter().map(BalanceDto::from).collect(), + page_info: page.page_info, + })) +} + +/// `GET /balances/ar-aging` query parameters. NOTE: ar-aging is a **computed +/// bucket aggregate** (a report), not a paginated row collection — it folds the +/// open per-invoice AR into days-past-due buckets per `(payer, currency)`. It +/// deliberately keeps plain `?tenant_id=&payer=` query params (no `OData` +/// `$filter` / `Page` envelope); the row-collection lists (`journal-lines` / +/// `balances` / `accounts`) are the `OData` surfaces. +#[derive(Debug, serde::Deserialize)] +struct ArAgingQuery { + tenant_id: Option, + payer: Option, +} + +async fn ar_aging_handler( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + let rows = state + .client + .list_ar_invoice_balances(&ctx, tenant_id, query.payer) + .await?; + // VHP-1853: bucket by the tenant's configured AR-aging thresholds (the gear + // default when no policy row / a stub state). The policy read is scoped on + // `ENTRY:read` — the same grant the AR balance read above already requires, + // so this adds no new authorization surface. + let thresholds = match &state.posting_policy { + Some(repo) => { + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + repo.read_effective_policy(&scope, tenant_id, Utc::now()) + .await + .map_err(|e| { + CanonicalError::from(crate::domain::error::DomainError::Internal(format!( + "read posting policy: {e}" + ))) + })? + .aging_thresholds + } + None => AgingThresholds::default(), + }; + let buckets = ar_aging(&rows, Utc::now().date_naive(), &thresholds); + Ok(Json(ArAgingDto::from(buckets))) +} + +#[cfg(test)] +#[path = "journal_entries_tests.rs"] +mod metadata_tests; diff --git a/gears/bss/ledger/ledger/src/api/rest/journal_entries_tests.rs b/gears/bss/ledger/ledger/src/api/rest/journal_entries_tests.rs new file mode 100644 index 000000000..92f570356 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/journal_entries_tests.rs @@ -0,0 +1,54 @@ +//! Unit tests for the `PATCH …/annotation` request DTO + the handler-side +//! target-kind parse (the DB write is an integration concern, exercised in +//! `tests/postgres_entry_annotation.rs`). +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use crate::api::rest::dto::EntryAnnotationRequestDto; +use crate::infra::annotation::AnnotationTarget; + +#[test] +fn annotation_dto_deserializes_description() { + let body = serde_json::json!({ + "description": "ops note", + "reason": "audit reason", + }); + let dto: EntryAnnotationRequestDto = serde_json::from_value(body).unwrap(); + assert_eq!(dto.description.as_deref(), Some("ops note")); + let kind_literal = dto.target_kind.as_deref().unwrap_or("ENTRY"); + assert_eq!( + AnnotationTarget::parse(kind_literal).unwrap(), + AnnotationTarget::Entry + ); +} + +#[test] +fn annotation_dto_null_description_clears() { + let body = serde_json::json!({ "description": null, "reason": "clear it" }); + let dto: EntryAnnotationRequestDto = serde_json::from_value(body).unwrap(); + assert_eq!(dto.description, None); +} + +#[test] +fn annotation_dto_line_target_parses() { + let line = uuid::Uuid::now_v7(); + let body = serde_json::json!({ + "description": "line note", + "target_kind": "LINE", + "target_line_id": line, + "reason": "audit reason", + }); + let dto: EntryAnnotationRequestDto = serde_json::from_value(body).unwrap(); + assert_eq!(dto.target_line_id, Some(line)); + assert_eq!( + AnnotationTarget::parse(dto.target_kind.as_deref().unwrap()).unwrap(), + AnnotationTarget::Line + ); +} + +#[test] +fn annotation_dto_rejects_bogus_kind() { + assert!(matches!( + AnnotationTarget::parse("BOGUS"), + Err(crate::domain::error::DomainError::InvalidRequest(_)) + )); +} diff --git a/gears/bss/ledger/ledger/src/api/rest/payers.rs b/gears/bss/ledger/ledger/src/api/rest/payers.rs new file mode 100644 index 000000000..79f37ff59 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/payers.rs @@ -0,0 +1,227 @@ +//! Axum handler + router for payer-closure (VHP-1852 Phase 2). +//! `POST /bss-ledger/v1/payers/{payer_tenant_id}/close` — set the payer's +//! lifecycle to CLOSED for the caller's own tenant. Closing a payer that still +//! holds a balance routes through dual-control (409 `DUAL_CONTROL_REQUIRED` → +//! approve → executor writes CLOSED); a clean payer closes inline (200). + +use std::sync::Arc; + +use axum::extract::{Extension, Path}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::PayerStateView; +use crate::api::rest::error::{authz_error_to_canonical, payer_state_not_found}; +use crate::domain::approval::ApprovalKind; +use crate::domain::approval::intent::{ApprovalIntent, PayerClosureIntent}; +use crate::domain::approval::policy::OperationFacts; +use crate::domain::error::DomainError; +use crate::domain::status::PAYER_LIFECYCLE_CLOSED; +use crate::infra::approval::service::ApprovalService; +use crate::infra::storage::repo::PayerStateRepo; + +/// `OpenAPI` tag applied to the payer operations. +const TAG: &str = "BSS Ledger Payers"; + +/// Shared per-request state for the payer routes. +#[derive(Clone)] +pub struct ApiState { + /// Dual-control engine (closure-with-balance routes through it). + pub approval: Arc, + /// Payer lifecycle + balance reads/writes. + pub payer_state: PayerStateRepo, +} + +/// Close-payer request body. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct ClosePayerRequest { + /// Customer-balance disposition election — recorded when closing with a + /// positive balance (design 01 §4.2). + pub disposition: Option, +} + +/// Close-payer response (inline-close path). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ClosePayerResponse { + pub payer_tenant_id: Uuid, + pub lifecycle_state: String, + pub closed_with_open_balance: bool, +} + +/// Build the Axum router for the payer surface. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + router = OperationBuilder::post("/bss-ledger/v1/payers/{payer_tenant_id}/close") + .operation_id("bss_ledger.close_payer") + .summary("Close a payer's ledger lifecycle") + .description( + "Sets the payer lifecycle to CLOSED for the caller's tenant. Closing a \ + payer that still holds a balance routes through dual-control \ + (409 DUAL_CONTROL_REQUIRED → approve); a clean payer closes inline (200).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("payer_tenant_id", "The payer being closed.") + .json_request::(openapi, "Optional customer-balance disposition.") + .handler(close_payer) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The payer was closed inline (no outstanding balance).", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/payers/{payer_tenant_id}/state") + .operation_id("bss_ledger.get_payer_state") + .summary("Read a payer's ledger lifecycle state") + .description( + "Returns the payer's ledger lifecycle state (`OPEN` / `CLOSED`) for the \ + caller's seller tenant, plus whether a close was approved over an \ + outstanding balance. Tenant-scoped (SQL-level BOLA): a payer with no \ + recorded state — or outside the caller's authorized subtree — yields a \ + 404 (no existence leak).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param( + "payer_tenant_id", + "The payer whose lifecycle state to read.", + ) + .handler(get_payer_state) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The payer's lifecycle state.", + ) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// `GET /payers/{payer_tenant_id}/state` (read-surface): read one payer's ledger +/// lifecycle state for the caller's seller tenant. Gates on `(entry, read)` — the +/// data-plane read action the refund / note / dispute reads use — and binds the +/// compiled scope as the SQL-level BOLA filter, so a payer outside the caller's +/// subtree resolves to `None` ⇒ 404 (no existence leak). The seller tenant is the +/// caller's own (`ctx.subject_tenant_id()`, mirroring `close_payer`). +async fn get_payer_state( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(payer_tenant_id): Path, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = ctx.subject_tenant_id(); + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let row = state + .payer_state + .read(&scope, tenant_id, payer_tenant_id) + .await? + .ok_or_else(|| payer_state_not_found(payer_tenant_id))?; + Ok(Json(PayerStateView::from(row))) +} + +async fn close_payer( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(payer_tenant_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = ctx.subject_tenant_id(); + // Closer needs data-plane write authority. MVP reuses the entry-post + // permission; a dedicated payer-close permission is a follow-up. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::POST, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let has_balance = state + .payer_state + .has_outstanding_balance(&scope, tenant_id, payer_tenant_id) + .await + .map_err(CanonicalError::from)?; + + // Dual-control gate: closing WITH a balance needs sign-off; a clean payer + // closes inline. + let intent = ApprovalIntent::PayerClosure(PayerClosureIntent { + tenant_id, + payer_tenant_id, + closed_with_open_balance: has_balance, + disposition: body.disposition.clone(), + }); + let facts = OperationFacts { + kind: ApprovalKind::PayerClosure, + amount_usd_eq_minor: None, + effective_at: None, + has_outstanding_balance: has_balance, + }; + if let Some(approval_id) = state + .approval + .gate(&ctx, &scope, intent, facts, "payer-closure".to_owned()) + .await + .map_err(CanonicalError::from)? + { + return Err(CanonicalError::from(DomainError::DualControlRequired( + format!("payer closure requires dual-control approval: {approval_id}"), + ))); + } + + state + .payer_state + .close( + &scope, + tenant_id, + payer_tenant_id, + ctx.subject_id(), + has_balance, + ) + .await + .map_err(CanonicalError::from)?; + Ok(( + StatusCode::OK, + Json(ClosePayerResponse { + payer_tenant_id, + lifecycle_state: PAYER_LIFECYCLE_CLOSED.to_owned(), + closed_with_open_balance: has_balance, + }), + ) + .into_response()) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/payments.rs b/gears/bss/ledger/ledger/src/api/rest/payments.rs new file mode 100644 index 000000000..7f82f11ea --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/payments.rs @@ -0,0 +1,503 @@ +//! Axum handlers + router for the ledger's payment REST surface (architecture +//! §6, money-in / money-out). Four operations under `/bss-ledger/v1`, all +//! tenant-scoped WITHOUT a tenant in the path (the vhp-core convention, matching +//! the journal-entry / provisioning surfaces): writes carry `tenant_id` in the +//! **body**, the unallocated read takes a `?tenant_id=` **query** param. +//! +//! Writes (settle / allocate), `(payment, write)` PEP gate against the body's +//! `tenant_id`: +//! - `POST /payments` — settle a received receipt into the payer's unallocated +//! pool. Idempotent on `payment_id`: a re-settle replays (`200`), a fresh +//! settle is `201`. +//! - `POST /payments/{payment_id}/allocations` — drain the pool to open AR +//! oldest-first. `{payment_id}` from the path, the rest from the body. Always +//! `201` (the recorded splits). +//! +//! Reads (`(payment, read)`, PDP-scoped in the client — the SQL-level BOLA +//! filter, so no separate handler gate, matching the journal-entry reads): +//! - `GET /payments/{payment_id}/allocations` — the recorded splits. +//! - `GET /balances/unallocated?tenant_id=&payer_tenant_id=¤cy=` — the +//! payer's still-undrained pool. +//! +//! Routes register through `OperationBuilder` so `/openapi.json` lists each +//! operation with its declared request / response schemas. + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use bss_ledger_sdk::api::LedgerClientV1; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{ + AllocatePaymentRequest, AllocatePaymentResponse, AllocationQueuedResponse, + PaymentAllocationsDto, ReturnPaymentRequest, ReturnPaymentResponse, SettlePaymentRequest, + SettlePaymentResponse, SettlementView, UnallocatedDto, +}; +use crate::api::rest::error::{authz_error_to_canonical, settlement_not_found}; +use crate::infra::storage::repo::PaymentRepo; + +/// `OpenAPI` tag applied to the payment operations. +const TAG: &str = "BSS Ledger Payments"; + +/// Shared per-request state for the payment routes. Constructed once at `init()` +/// and shared via `Extension>`. Carries the in-process +/// data-access client (settle / allocate / list / read-unallocated all go +/// through it — the client gates the PEP and orchestrates the post). +#[derive(Clone)] +pub struct ApiState { + /// In-process data-access client (the gear's own local impl). + pub client: Arc, + /// The payment repo — the `GET /payments/{payment_id}/settlement` by-payment + /// read source (the `payment_settlement` counters row). A plain scoped read, + /// mirroring [`crate::api::rest::disputes::ApiState`]'s `dispute_repo`. + pub payment_repo: PaymentRepo, +} + +/// Build the Axum router for the payment surface and register every operation +/// with the supplied `OpenAPI` registry. `state` is attached via an `Extension` +/// layer at the end so the registry sees the route definitions before the +/// per-request state is bound. +#[allow(clippy::too_many_lines)] // one builder chain per operation; flat is clearer than helpers +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/payments") + .operation_id("bss_ledger.settle_payment") + .summary("Settle a received payment (money-in)") + .description( + "Records a received receipt into the payer's unallocated pool \ + (DR CASH_CLEARING net, DR PSP_FEE_EXPENSE fee, CR UNALLOCATED \ + gross) for the seller named by the body's `tenant_id`. Idempotent \ + on `payment_id`: a re-settle returns the prior posting reference \ + (200) instead of a new one (201). A settlement records money \ + already received and lands even for a closed payer.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "The settled payment to record.") + .handler(settle_payment) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference (201 fresh settle / 200 idempotent replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior settle", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/payments/{payment_id}/returns") + .operation_id("bss_ledger.return_payment") + .summary("Return a settled payment (claw a receipt back)") + .description( + "Reverses a money-in: claws `{payment_id}`'s settled receipt back out \ + of the payer's unallocated pool (DR UNALLOCATED, CR CASH_CLEARING) \ + and decrements the payment's settled total, for the seller named by \ + the body's `tenant_id`. Idempotent on `psp_return_id`: a re-post \ + returns the prior posting reference (200) instead of a new one (201). \ + A return records money already moved and lands even for a closed \ + payer. Rejected when the return exceeds the still-returnable settled \ + amount (SETTLEMENT_RETURN_OVER_ALLOCATED).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("payment_id", "The settled payment to claw back.") + .json_request::(openapi, "The return to record + idempotency key.") + .handler(return_payment) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference (201 fresh return / 200 idempotent replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior return", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/payments/{payment_id}/allocations") + .operation_id("bss_ledger.allocate_payment") + .summary("Allocate a settled payment to open receivables (money-out)") + .description( + "Drains the payment's unallocated pool into the payer's open AR \ + (DR UNALLOCATED, CR AR per invoice) for `{payment_id}`. By default \ + the lump is applied by the tenant's precedence policy and \ + `hint_invoice_id` jumps one invoice to the front of the fill order; \ + supply `splits` to bypass the policy with a caller-computed split \ + (validated against the open receivables). Idempotent on \ + `allocation_id`. When the payment is already settled the allocation \ + posts inline (201). When it is NOT yet settled the request is durably \ + QUEUED for a later drain (202 `allocation-queued`, §4.7 \ + allocation-before-settlement) instead of being rejected. Rejected \ + (400) when the allocation would exceed what was settled \ + (ALLOCATION_EXCEEDS_SETTLED), spans too many invoices \ + (ALLOCATION_TOO_LARGE), mismatches the settled currency \ + (ALLOCATION_CURRENCY_MISMATCH), or carries an invalid caller split \ + (ALLOCATION_SPLIT_INVALID).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("payment_id", "The payment to allocate from.") + .json_request::(openapi, "The lump to allocate + idempotency key.") + .handler(allocate_payment) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "The posting reference plus the per-invoice splits applied (payment settled)", + ) + .json_response_with_schema::( + openapi, + StatusCode::ACCEPTED, + "Allocation queued for a later drain (payment not yet settled)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/payments/{payment_id}/allocations") + .operation_id("bss_ledger.list_payment_allocations") + .summary("List a payment's recorded allocations") + .description( + "Returns the per-invoice splits recorded against `{payment_id}` for \ + the caller's own tenant. A payment outside the caller's authorized \ + subtree yields an empty list (SQL-level BOLA, no existence leak).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("payment_id", "The payment whose allocations to list.") + .handler(list_payment_allocations) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The recorded allocation splits for the payment", + ) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/balances/unallocated") + .operation_id("bss_ledger.read_unallocated") + .summary("Read a payer's unallocated pool balance") + .description( + "Returns the still-undrained portion of the payer's settled receipts \ + for one currency — `?tenant_id=` (the caller's own by default), \ + `?payer_tenant_id=`, `?currency=`. A payer outside the caller's \ + authorized subtree yields a zero balance (SQL-level BOLA).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Target tenant (defaults to the caller's own)", + ) + .query_param("payer_tenant_id", true, "The payer whose pool to read") + .query_param("currency", true, "The pool currency (ISO 4217 code)") + .handler(read_unallocated) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The payer's unallocated pool balance for the currency", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/payments/{payment_id}/settlement") + .operation_id("bss_ledger.get_payment_settlement") + .summary("Read a payment's settlement counters") + .description( + "Returns the per-payment money-out serialization counters recorded for \ + `{payment_id}` — the settled / fee / allocated / refunded / \ + clawed-back running totals the money-out caps serialize against (drawn \ + from the `payment_settlement` row). The owning seller `tenant_id` is \ + required in the query (the settlement PK is `(tenant_id, \ + payment_id)`). Tenant-scoped (SQL-level BOLA): a payment that was \ + never settled — or one outside the caller's authorized subtree — yields \ + a 404 (no existence leak). Mirrors `get_refund`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param( + "payment_id", + "The payment whose settlement counters to read.", + ) + .query_param( + "tenant_id", + true, + "The payment's owning seller tenant (the settlement PK's tenant half).", + ) + .handler(get_payment_settlement) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The payment's settlement counters", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// A settlement posting reference rendered with the right status: `201 Created` +/// for a fresh settle, `200 OK` for an idempotent replay of a prior settle. +fn settle_response(reference: bss_ledger_sdk::PostingRef) -> Response { + let status = if reference.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + (status, Json(SettlePaymentResponse::from(reference))).into_response() +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) +// BEFORE the in-handler `require_authenticated` gate, so a malformed body yields +// 400 even for an unauthenticated caller (standard axum extractor ordering; no +// authenticated-only data is disclosed). +async fn settle_payment( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id` (tenant in body, not path). + let tenant_id = body.tenant_id; + // (payment, write) PEP gate against the TARGET tenant: a parent settles into + // a seller in its authorized subtree; a target outside the caller's scope is + // a cross-tenant write and is denied. The in-process client gates again + // (defence-in-depth, matching the provisioning surface). + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::PAYMENT, + crate::authz::actions::WRITE, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let cmd = body.into_sdk()?; + let reference = state.client.settle_payment(&ctx, cmd).await?; + Ok(settle_response(reference)) +} + +/// A settlement-return posting reference rendered with the right status: `201 +/// Created` for a fresh return, `200 OK` for an idempotent replay of a prior +/// return (mirrors `settle_response`). +fn return_response(reference: bss_ledger_sdk::PostingRef) -> Response { + let status = if reference.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + (status, Json(ReturnPaymentResponse::from(reference))).into_response() +} + +async fn return_payment( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(payment_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id`; `payment_id` is the path id of + // the original settled payment being clawed back. + let tenant_id = body.tenant_id; + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::PAYMENT, + crate::authz::actions::WRITE, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let cmd = body.into_sdk(payment_id)?; + let reference = state.client.return_payment(&ctx, cmd).await?; + Ok(return_response(reference)) +} + +async fn allocate_payment( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(payment_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id`; `payment_id` is the path id. + let tenant_id = body.tenant_id; + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::PAYMENT, + crate::authz::actions::WRITE, + Some(tenant_id), + None, + true, + ) + .await + .map_err(authz_error_to_canonical)?; + // The allocation's `payment_id` comes from the PATH; the body carries the + // tenant / payer / lump / idempotency key. + let cmd = body.into_sdk(payment_id)?; + let outcome = state.client.allocate_payment(&ctx, cmd).await?; + Ok(allocate_response(outcome)) +} + +/// An allocate outcome rendered with the right status: `201 Created` + the +/// posting handle + splits when the payment was settled (the allocation posted +/// inline), or `202 Accepted` + the `allocation-queued` body when the payment was +/// not yet settled (the allocation was durably queued for a later drain, §4.7). +/// Mirrors `settle_response`'s status-varying rendering. +fn allocate_response(outcome: bss_ledger_sdk::AllocateOutcome) -> Response { + match outcome { + bss_ledger_sdk::AllocateOutcome::Applied(applied) => ( + StatusCode::CREATED, + Json(AllocatePaymentResponse::from(applied)), + ) + .into_response(), + bss_ledger_sdk::AllocateOutcome::Queued(queued) => ( + StatusCode::ACCEPTED, + Json(AllocationQueuedResponse::from(queued)), + ) + .into_response(), + } +} + +async fn list_payment_allocations( + Extension(state): Extension>, + extension_ctx: Option>, + Path(payment_id): Path, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + // Tenant from the context; the client's PDP `In` read scope is the SQL-level + // BOLA filter (a foreign payment resolves to an empty list, no leak), so + // there is no separate target-anchored gate here (matching the journal-entry + // reads). `(payment, read)` is enforced inside the client. + let tenant_id = ctx.subject_tenant_id(); + let rows = state + .client + .list_payment_allocations(&ctx, tenant_id, payment_id) + .await?; + Ok(Json(PaymentAllocationsDto::from(rows))) +} + +/// `GET /balances/unallocated` query parameters. `tenant_id` defaults to the +/// caller's own; `payer_tenant_id` + `currency` are required (they identify the +/// pool grain). The client's PDP read scope is the SQL-level BOLA filter. +#[derive(Debug, serde::Deserialize)] +struct UnallocatedQuery { + tenant_id: Option, + payer_tenant_id: Uuid, + currency: String, +} + +async fn read_unallocated( + Extension(state): Extension>, + extension_ctx: Option>, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + // Reject a malformed currency code at the boundary: an unvalidated code would + // silently match zero rows instead of a clean 400. Non-ISO/crypto codes are + // admitted by the scale registry, so accept any non-empty ASCII code (≤10 + // chars) rather than strict ISO-4217. + if query.currency.is_empty() || query.currency.len() > 10 || !query.currency.is_ascii() { + return Err(crate::domain::error::DomainError::InvalidRequest(format!( + "currency must be a non-empty ASCII code of at most 10 chars, got {:?}", + query.currency + )) + .into()); + } + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + let view = state + .client + .read_unallocated(&ctx, tenant_id, query.payer_tenant_id, query.currency) + .await?; + Ok(Json(UnallocatedDto::from(view))) +} + +/// `GET /payments/{payment_id}/settlement` query parameters: the payment's owning +/// seller `tenant_id` (the settlement PK is `(tenant_id, payment_id)`, so the +/// tenant is REQUIRED in the query — unlike the unallocated read, which defaults it +/// to the caller's own). `payment_id` is the path param. +#[derive(Debug, serde::Deserialize)] +struct SettlementQuery { + tenant_id: Uuid, +} + +async fn get_payment_settlement( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(payment_id): Path, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id; + // (payment, read) PEP gate against the payment's owning tenant — the settlement + // record IS payment-cache data (money-out counters), so it gates on the `payment` + // resource like the allocations / unallocated reads, NOT the `entry` data-plane + // read. The returned scope is the SQL-level BOLA filter the repo binds, so a + // foreign-tenant settlement resolves to None ⇒ 404 (no existence leak). + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::PAYMENT, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let settlement = state + .payment_repo + .read_settlement(&scope, tenant_id, &payment_id) + .await + .map_err(|e| { + crate::domain::error::DomainError::Internal(format!("read payment settlement: {e}")) + })? + .ok_or_else(|| settlement_not_found(&payment_id))?; + Ok(Json(SettlementView::from(settlement))) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/posting_policy.rs b/gears/bss/ledger/ledger/src/api/rest/posting_policy.rs new file mode 100644 index 000000000..429e0e747 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/posting_policy.rs @@ -0,0 +1,240 @@ +//! Axum handlers + router for the tenant invoice-posting policy (VHP-1853). +//! `POST /bss-ledger/v1/posting-policy` appends an effective-dated version (the +//! missing-mapping mode + the AR-aging bucket thresholds); `GET …` reads the +//! caller tenant's effective policy. Tenant-scoped to the caller's own tenant +//! (no tenant in the path/body for the write). The write gates on +//! `(ledger_config, write)`, the read on `(ledger_config, read)` — the shared +//! config-plane resource (the FX revaluation mode shares it; dual-control policy +//! stays separate), not the `entry` data plane. + +use std::sync::Arc; + +use axum::extract::{Extension, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use chrono::{DateTime, Utc}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::error::authz_error_to_canonical; +use crate::domain::error::DomainError; +use crate::domain::invoice::policy::{AgingThresholds, MissingMappingMode, PostingPolicy}; +use crate::infra::storage::repo::PostingPolicyRepo; + +/// `OpenAPI` tag applied to the posting-policy operations. +const TAG: &str = "BSS Ledger Posting Policy"; + +/// Shared per-request state for the posting-policy routes. Constructed once at +/// `init()` and shared via `Extension>`. +#[derive(Clone)] +pub struct ApiState { + /// The posting-policy repository (read effective + write a version). + pub posting_policy: PostingPolicyRepo, +} + +/// A new posting-policy version to write (VHP-1853). `effective_from` defaults to +/// now when omitted. An unknown `missing_mapping_mode`, or empty / non-positive / +/// non-monotone `ar_aging_thresholds`, is rejected (400), never coerced. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct SetPostingPolicyRequest { + /// `SUSPENSE` (route unmapped items to suspense — the default) or `HARD_BLOCK` + /// (reject a post that carries an unmapped item). + pub missing_mapping_mode: String, + /// AR-aging bucket upper-bounds: strictly increasing positive day counts + /// (e.g. `[30, 60, 90]` → `current / 1-30 / 31-60 / 61-90 / 90+`). + pub ar_aging_thresholds: Vec, + /// When this version takes effect; defaults to now. + pub effective_from: Option>, +} + +/// The written posting-policy version (the minted `version` + the values it +/// carries; the resolver picks the latest `effective_from`). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PostingPolicyResponse { + /// The minted version (`max + 1`, `0` for the first). + pub version: i64, + /// The instant this version takes effect. + pub effective_from: DateTime, + /// `SUSPENSE` | `HARD_BLOCK`. + pub missing_mapping_mode: String, + /// The AR-aging bucket upper-bounds. + pub ar_aging_thresholds: Vec, +} + +/// The tenant's effective posting policy (read-surface) — the configured version +/// in force, or the gear defaults when the tenant has set none. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PostingPolicyView { + /// `SUSPENSE` | `HARD_BLOCK`. + pub missing_mapping_mode: String, + /// The AR-aging bucket upper-bounds. + pub ar_aging_thresholds: Vec, +} + +/// The `?tenant_id=` query for `GET /posting-policy`: the tenant whose effective +/// policy to read — the caller's own when omitted. +#[derive(Debug, serde::Deserialize)] +struct PolicyQuery { + tenant_id: Option, +} + +/// Build the Axum router for the posting-policy surface and register its +/// operations with the supplied `OpenAPI` registry. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/posting-policy") + .operation_id("bss_ledger.set_posting_policy") + .summary("Set the tenant invoice-posting policy") + .description( + "Writes a new effective-dated invoice-posting policy version (the \ + missing-mapping mode + the AR-aging bucket thresholds) for the \ + caller's tenant (VHP-1853). Append-only: a new version supersedes; \ + the orchestrator / aging read pick the latest effective_from (highest \ + version on a tie). An unknown mode or empty / non-positive / \ + non-monotone thresholds are rejected (400), never coerced. Requires \ + `config_write.v1`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "The policy version to write.") + .handler(set_policy) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The written policy version.", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/posting-policy") + .operation_id("bss_ledger.get_posting_policy") + .summary("Read the effective invoice-posting policy") + .description( + "Returns the tenant's EFFECTIVE invoice-posting policy: the version in \ + force now (latest `effective_from <= now`, highest `version` on a \ + tie), or the gear defaults (`SUSPENSE` + `30,60,90`) when the tenant \ + has set no policy row. `tenant_id` defaults to the caller's own. Gates \ + on `(ledger_config, read)` — the shared config-plane resource, not an \ + `entry` data-plane read; tenant-scoped (SQL-level BOLA) so a tenant \ + outside the caller's subtree reads the gear defaults (no leak). \ + Always `200`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Tenant whose effective policy to read (the caller's own by default)", + ) + .handler(get_policy) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The effective invoice-posting policy (configured version or gear defaults).", + ) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// `POST /posting-policy`: write a new effective-dated version. Gates on +/// `(posting_policy, write)`; validates the request into the domain types (400 on +/// a bad mode / thresholds) before the append. +async fn set_policy( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = ctx.subject_tenant_id(); + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::LEDGER_CONFIG, + crate::authz::actions::WRITE, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let missing_mapping_mode = MissingMappingMode::parse(&body.missing_mapping_mode) + .map_err(|e| CanonicalError::from(DomainError::InvalidRequest(e.to_string())))?; + let aging_thresholds = AgingThresholds::new(body.ar_aging_thresholds.clone()) + .map_err(|e| CanonicalError::from(DomainError::InvalidRequest(e.to_string())))?; + let policy = PostingPolicy { + missing_mapping_mode, + aging_thresholds, + }; + let effective_from = body.effective_from.unwrap_or_else(Utc::now); + let version = state + .posting_policy + .write_version(&scope, tenant_id, &policy, effective_from) + .await + .map_err(|e| { + CanonicalError::from(DomainError::Internal(format!("write posting policy: {e}"))) + })?; + Ok(( + StatusCode::OK, + Json(PostingPolicyResponse { + version, + effective_from, + missing_mapping_mode: body.missing_mapping_mode, + ar_aging_thresholds: body.ar_aging_thresholds, + }), + ) + .into_response()) +} + +/// `GET /posting-policy`: read the caller tenant's effective policy. Gates on +/// `(posting_policy, read)`; binds the compiled scope as the SQL-level BOLA +/// filter. Always `200` — the effective policy is the configured version or the +/// gear defaults. +async fn get_policy( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::LEDGER_CONFIG, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let effective = state + .posting_policy + .read_effective_policy(&scope, tenant_id, Utc::now()) + .await + .map_err(|e| { + CanonicalError::from(DomainError::Internal(format!("read posting policy: {e}"))) + })?; + Ok(Json(PostingPolicyView { + missing_mapping_mode: effective.missing_mapping_mode.as_str().to_owned(), + ar_aging_thresholds: effective.aging_thresholds.bounds().to_vec(), + })) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/provisioning.rs b/gears/bss/ledger/ledger/src/api/rest/provisioning.rs new file mode 100644 index 000000000..ac81a7b60 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/provisioning.rs @@ -0,0 +1,193 @@ +//! Axum handlers + router for the gear's tenant-scoped REST surface: +//! `POST /bss-ledger/v1/provisioning` (seed the ledger; target tenant in the +//! body) and `GET /bss-ledger/v1/accounts?tenant_id=…` (list the chart of +//! accounts; target tenant from the query, the caller's own by default). +//! Tenant is carried in body/query (not the path) per the vhp-core REST +//! convention (RBAC/RMS) — see the gear's invoice-posting impl design. +//! +//! The gear's first REST surface. Translates the provisioning request into the +//! in-process `LedgerClientV1::provision` call and +//! renders the per-grain created-vs-existing summary. Requests without an +//! authenticated `SecurityContext` are rejected with 401; the `billing-setup` +//! PEP gate (`(ledger, provision)` against the TARGET tenant, which must +//! lie in the caller's authorized subtree) rejects an unauthorized or +//! cross-tenant caller with 403 (503 if the PDP is unreachable). +//! +//! Routes are registered through `toolkit::api::operation_builder::OperationBuilder` +//! so the `OpenAPI` document at `/openapi.json` lists the operation with its +//! declared request / response schemas. + +use std::sync::Arc; + +use axum::extract::{Extension, Query}; +use axum::{Json, Router, http::StatusCode}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::odata::OData; +use toolkit::api::operation_builder::OperationBuilderODataExt; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_odata::Page; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{AccountInfoDto, ProvisioningRequestDto, ProvisioningResultDto}; +use crate::api::rest::error::authz_error_to_canonical; +use crate::odata::AccountInfoFilterField; + +/// `OpenAPI` tag applied to the provisioning operation. +const TAG: &str = "BSS Ledger Provisioning"; + +/// Shared per-request state for the provisioning route. Constructed once at +/// `init()` and shared via `Extension>`. +#[derive(Clone)] +pub struct ApiState { + /// In-process data-access client (the gear's own local impl). + pub client: std::sync::Arc, +} + +/// Build the Axum router for the provisioning endpoint and register the +/// operation with the supplied `OpenAPI` registry. `state` is attached via an +/// `Extension` layer at the end so the registry sees the route definition +/// before the per-request state is bound. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/provisioning") + .operation_id("bss_ledger.provision") + .summary("Provision a seller legal-entity (idempotent, additive)") + .description( + "Seeds the chart of accounts, non-ISO currency scales, the \ + fiscal-calendar config, and the initial OPEN fiscal period for the \ + seller legal-entity named by the body's `tenant_id`. Re-calls are \ + additive: existing rows are a no-op.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "Accounts, currency scales, and fiscal-calendar config to seed.", + ) + .handler(provision) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Provisioning summary (created vs. existing per grain)", + ) + // Malformed body / bad enum literals / out-of-range scale all surface + // as canonical `InvalidArgument` = HTTP 400 (the canonical taxonomy has + // no 422; this projects the architecture's RFC-9457 codes onto the + // platform error layer, like every other vhp-core gear). + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/accounts") + .operation_id("bss_ledger.list_accounts") + .summary("List a tenant's chart of accounts (with ids)") + .description( + "Cursor-paginated list of the target tenant's chart-of-accounts \ + entries — each account's persistent id + coordinate — so a caller \ + can resolve the ids it posts to / reads balances for. The target is \ + the `tenant_id` query param (the caller's own tenant by default). \ + Supports OData `$filter` over `account_class`, `currency`, \ + `revenue_stream`, and `lifecycle_state`. Tenant-scoped: the \ + `$filter` ANDs the caller's authorized subtree, so a tenant outside \ + it yields an empty page (no leak, not a 403).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Target tenant whose accounts to list (defaults to the caller's own tenant)", + ) + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_accounts) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of the tenant's chart of accounts with ids", + ) + // A malformed `tenant_id` query / `$filter` / cursor surfaces as a 400. + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) +// BEFORE the in-handler `require_authenticated` gate, so a malformed body +// yields 400 even for an unauthenticated caller. Accepted: standard axum +// extractor ordering, and no authenticated-only data is disclosed. +async fn provision( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id` (tenant in body, not path). + let tenant_id = body.tenant_id; + // billing-setup PEP gate: authorize (ledger, provision) against the + // TARGET tenant. require_constraints=true so the degraded flat-In PDP scope + // must contain the target — a parent provisions a seller in its authorized + // subtree; a target outside the caller's scope is a cross-tenant write and + // is denied. Self-provision is the case target ∈ the caller's own subtree. + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::LEDGER, + crate::authz::actions::PROVISION, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let req = body.into_request().map_err(CanonicalError::from)?; + let outcome = state.client.provision(&ctx, req).await?; + Ok(Json(outcome.into())) +} + +/// `GET …/accounts` non-OData query: the target tenant (the caller's own when +/// omitted). The `OData` `$filter` / `$orderby` / `limit` / `cursor` are parsed +/// separately by the `OData` extractor from the same query string; `tenant_id` +/// stays a plain param alongside them (per the RBAC list convention). +#[derive(Debug, serde::Deserialize)] +struct AccountsQuery { + tenant_id: Option, +} + +async fn list_accounts( + Extension(state): Extension>, + extension_ctx: Option>, + Query(query): Query, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + // Target tenant from the query, the caller's own tenant by default. The + // in-process client's PDP scope is the SQL-level BOLA filter (a tenant + // outside the caller's subtree yields an empty page), so there is no + // separate target-anchored gate here. The `$filter` ANDs that scope. + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + let page = state.client.list_accounts(&ctx, tenant_id, &odata).await?; + Ok(Json(Page { + items: page.items.into_iter().map(AccountInfoDto::from).collect(), + page_info: page.page_info, + })) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/recognition.rs b/gears/bss/ledger/ledger/src/api/rest/recognition.rs new file mode 100644 index 000000000..0577ff226 --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/recognition.rs @@ -0,0 +1,825 @@ +//! Axum handler + router for the ledger's recognition-run REST surface +//! (architecture §5, the ASC 606 S6 release). One write operation under +//! `/bss-ledger/v1`, tenant-scoped WITHOUT a tenant in the path (the vhp-core +//! convention, matching the payment / dispute surfaces): the `tenant_id` is in +//! the **body**. +//! +//! - `POST /recognition-runs` — trigger a recognition run for one fiscal period +//! (release the period's due `PENDING` segments). The `(recognition, write)` PEP gate +//! authorizes it against the body's `tenant_id` (a run posts `DR CL / CR +//! Revenue` journal entries). Idempotent on the run-trigger `run_id` (`None` ⇒ +//! the ledger mints one): a replay returns the prior run reference (200) +//! without re-running. When every due segment released in period order the run +//! renders `200` (the run reference + the release tally); when a due segment's +//! lower-period predecessor was not yet `DONE` the segment is parked `QUEUED` +//! and the run renders **202** `recognition-period-queued` (a success/queued +//! token, NOT a rejection — §4.6 ordering). +//! +//! The route registers through `OperationBuilder` so `/openapi.json` lists the +//! operation with its declared request / response schemas. Mirrors the +//! `record_dispute_phase` handler in [`crate::api::rest::disputes`]. +//! +// TODO(VHP-1855 I4): the benidorm recognition-lifecycle e2e (deferred post → +// run → schedule GET → change) is cluster-gated and lives in the separate e2e +// suite; not implemented here (it needs a live cluster, out of this slice's +// build-only scope). + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use bss_ledger_sdk::api::LedgerClientV1; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::odata::OData; +use toolkit::api::operation_builder::OperationBuilderODataExt; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_db::secure::AccessScope; +use toolkit_odata::Page; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::local_client::map_odata_page_err; +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{ + ChangeRecognitionScheduleRequest, RecognitionRunQueuedResponse, RecognitionRunResponse, + RecognitionRunView, RecognitionScheduleListResponse, RecognitionScheduleResponse, + RecognitionScheduleSummaryDto, RevenueDisaggregationResponse, ScheduleChangeResponse, + TriggerRecognitionRunRequest, +}; +use crate::api::rest::error::{ + authz_error_to_canonical, recognition_run_not_found, recognition_schedule_not_found, +}; +use crate::infra::storage::repo::RecognitionRepo; +use crate::odata::RecognitionRunFilterField; + +/// `OpenAPI` tag applied to the recognition operations. +const TAG: &str = "BSS Ledger Recognition"; + +/// The recognition handler's dependency on the dual-control gate. +/// Taking the gate behind a trait makes the over-threshold schedule-change +/// path unit-testable with a stub — the router tests previously ran with +/// `approval = None`, so the treatment-gate ordering and the ACTIVE-filter +/// shipped untested. Production binds +/// [`ApprovalService`](crate::infra::approval::service::ApprovalService). +#[async_trait::async_trait] +pub trait RecognitionApprovalGate: Send + Sync { + /// Resolve the tenant policy and decide whether `facts` crosses the D2 + /// threshold: `Some(approval_id)` (a `PENDING` approval was created — the handler + /// returns `409`) or `None` (below threshold — proceed inline). Mirrors + /// [`ApprovalService::gate`](crate::infra::approval::service::ApprovalService::gate). + /// + /// # Errors + /// [`DomainError`](crate::domain::error::DomainError) on a storage failure. + async fn gate( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + intent: crate::domain::approval::intent::ApprovalIntent, + facts: crate::domain::approval::policy::OperationFacts, + reason_code: String, + ) -> Result, crate::domain::error::DomainError>; +} + +#[async_trait::async_trait] +impl RecognitionApprovalGate for crate::infra::approval::service::ApprovalService { + async fn gate( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + intent: crate::domain::approval::intent::ApprovalIntent, + facts: crate::domain::approval::policy::OperationFacts, + reason_code: String, + ) -> Result, crate::domain::error::DomainError> { + // Delegate to the inherent method (disambiguated from this trait method). + crate::infra::approval::service::ApprovalService::gate( + self, + ctx, + scope, + intent, + facts, + reason_code, + ) + .await + } +} + +/// Shared per-request state for the recognition routes. Constructed once at +/// `init()` and shared via `Extension>`. Carries the in-process +/// data-access client (the run trigger goes through it — the client gates the +/// PEP and orchestrates the run). Mirrors [`crate::api::rest::payments::ApiState`]. +#[derive(Clone)] +pub struct ApiState { + /// In-process data-access client (the gear's own local impl). + pub client: Arc, + /// Dual-control gate (VHP-1852): a schedule change/cancel whose un-recognized + /// deferred remainder crosses the D2 threshold routes to the preparer→approver + /// queue (409); below-threshold changes apply inline. Taken behind + /// [`RecognitionApprovalGate`] so the gated path is + /// unit-testable with a stub; `None` skips the gate (router unit tests without a + /// governance DB). Production binds the real `ApprovalService`. + pub approval: Option>, + /// The recognition repo — the `GET /recognition-runs` list + + /// `GET /recognition-runs/{run_id}` by-id read source (the `recognition_run` + /// row). A plain scoped read, mirroring [`crate::api::rest::disputes::ApiState`]'s + /// `dispute_repo`. `Option` ONLY so the stub-based REST tests (which carry no DB) + /// can build `ApiState` with `None` (mirrors `approval` above); production ALWAYS + /// wires `Some` (see `module.rs`). + pub recognition_repo: Option, +} + +/// Build the Axum router for the recognition surface and register every +/// operation with the supplied `OpenAPI` registry. `state` is attached via an +/// `Extension` layer at the end so the registry sees the route definitions +/// before the per-request state is bound. +#[allow(clippy::too_many_lines)] // one builder chain per operation; flat is clearer than helpers +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/recognition-runs") + .operation_id("bss_ledger.trigger_recognition_run") + .summary("Trigger an ASC 606 recognition run for a fiscal period") + .description( + "Releases the period's due PENDING recognition segments for the \ + seller named by the body's `tenant_id`, posting one balanced \ + DR CONTRACT_LIABILITY / CR REVENUE entry per segment (the S6 \ + release). Idempotent on the run-trigger `run_id` (`None` ⇒ the \ + ledger mints one): a replay returns the prior run reference (200) \ + without starting a second run. When every due segment released in \ + period order the run returns 200 (the run reference + release \ + tally). When a due segment's lower-period predecessor is not yet \ + DONE that segment is parked QUEUED for a later drain (202 \ + `recognition-period-queued`, §4.6 ordering) instead of being \ + released out of order. A segment whose own target period has CLOSED \ + releases into the tenant's current open period instead (§4.3 E-2). \ + Rejected when a per-schedule over-recognition cap is exceeded \ + (OVER_RECOGNITION) or a required account class is not provisioned.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The fiscal period to run + optional idempotency key.", + ) + .handler(trigger_recognition_run) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The run reference + release tally (ran in period order / idempotent replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::ACCEPTED, + "One or more due segments parked QUEUED out-of-order (recognition-period-queued)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/revenue/disaggregation") + .operation_id("bss_ledger.revenue_disaggregation") + .summary("Disaggregate recognized ASC 606 revenue by stream") + .description( + "Returns the revenue RECOGNIZED for the `tenant_id` query (the \ + caller's own by default), disaggregated by (period_id, \ + revenue_stream) and ordered by (period_id, revenue_stream). The \ + source is recognized revenue = the DONE recognition segments (each a \ + posted DR CONTRACT_LIABILITY / CR REVENUE release), summed per \ + (period, stream). Omit `period_id` for all periods, or pass one \ + `YYYYMM` to narrow to it. The read is tenant-scoped (SQL-level BOLA): \ + a target outside the caller's authorized subtree yields no entries.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "Target seller tenant (defaults to the caller's own)", + ) + .query_param("period_id", false, "Narrow to one fiscal period (YYYYMM)") + .handler(revenue_disaggregation) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Recognized revenue disaggregated by (period, stream)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/recognition-schedules/{schedule_id}") + .operation_id("bss_ledger.get_recognition_schedule") + .summary("Read an ASC 606 recognition schedule's lifecycle view") + .description( + "Returns the recognition schedule named by the path `schedule_id` for \ + the `tenant_id` query (the schedule PK is `(tenant_id, schedule_id)`; \ + the tenant is in the query, like the disaggregation report). The body \ + is the schedule header (status, version, revenue_stream, currency, \ + total_deferred_minor, recognized_minor, the originating \ + source_invoice_id + source_invoice_item_ref invoice-link anchor, \ + po_allocation_group, subscription_ref, policy_ref) plus its segments \ + (segment_no, period_id, amount_minor, status), ordered by segment_no \ + (period order). The read is tenant-scoped (SQL-level BOLA): a schedule \ + outside the caller's authorized subtree — or simply absent — yields a \ + 404 (no existence leak).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("schedule_id", "The recognition schedule to read.") + .query_param( + "tenant_id", + true, + "The schedule's owning seller tenant (the PK's tenant half).", + ) + .handler(get_recognition_schedule) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The schedule header + its segments", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/recognition-schedules") + .operation_id("bss_ledger.list_recognition_schedules") + .summary("List ASC 606 recognition schedules (discovery)") + .description( + "Lists the recognition schedule HEADERS for the `tenant_id` query, \ + optionally narrowed to one originating `invoice_id` \ + (`source_invoice_id`) and/or one `revenue_stream`. This is the \ + discovery surface for the server-minted `schedule_id` (also echoed in \ + the invoice-post response): with it a REST client can find the id, \ + then read (`GET …/{schedule_id}`) or change \ + (`POST …/{schedule_id}/changes`) a specific schedule. Header-only — no \ + segments (the by-id read carries those). Tenant-scoped (SQL-level \ + BOLA): schedules outside the caller's authorized subtree are silently \ + excluded. An empty list is a normal 200 (not a 404).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + true, + "The schedules' owning seller tenant (the PK's tenant half).", + ) + .query_param( + "invoice_id", + false, + "Optional: narrow to one originating invoice (`source_invoice_id`).", + ) + .query_param( + "revenue_stream", + false, + "Optional: narrow to one revenue stream.", + ) + .handler(list_recognition_schedules) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The matching schedule headers (possibly empty)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/recognition-schedules/{schedule_id}/changes") + .operation_id("bss_ledger.change_recognition_schedule") + .summary("Change or cancel an ASC 606 recognition schedule") + .description( + "Changes or cancels the ACTIVE recognition schedule named by the path \ + `schedule_id` for the seller in the body's `tenant_id` (the `(recognition, \ + write)` PEP gate authorizes it — a change marks/mints schedule state). \ + The upstream modification `treatment` is gated FIRST: `prospective` / \ + `separate_contract` apply; `catch_up` or any unknown value is rejected \ + MODIFICATION_TREATMENT_REVIEW with no state change (the ledger never \ + silently treats a modification as prospective, §3.6). A `cancel` marks \ + the schedule CANCELLED (the unreleased deferred remainder stays as \ + CONTRACT_LIABILITY; no auto-reversal). A `replace` marks the old schedule \ + REPLACED and mints a NEW schedule version (a fresh `schedule_id`, \ + `version + 1`) that re-plans the REMAINING deferred over `new_segments` \ + (prospective — already-recognized revenue is not unwound, no compensating \ + entry). Idempotent on `change_id`. Emits billing.ledger.schedule.changed. \ + A path form (not a `:change` custom method).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("schedule_id", "The ACTIVE recognition schedule to change.") + .json_request::( + openapi, + "The change (cancel / replace), the idempotency `change_id`, the \ + modification `treatment`, and (for a replace) the replacement segments.", + ) + .handler(change_recognition_schedule) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The change outcome (the original + successor schedule ids and the \ + original's resulting status)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/recognition-runs/{run_id}") + .operation_id("bss_ledger.get_recognition_run") + .summary("Read a recorded ASC 606 recognition run") + .description( + "Returns the recorded recognition run named by the path `run_id` for \ + the `tenant_id` query — the orchestration wrapper that released a \ + period's due PENDING segments (each a posted DR CONTRACT_LIABILITY / \ + CR REVENUE entry). The body is the run's `period_id`, `status` \ + (RUNNING ⇒ in-flight / DONE ⇒ completed / FAILED ⇒ aborted), and \ + `started_at_utc`. The run PK folds `period_id` (a client may reuse one \ + `run_id` across two periods), so the owning seller `tenant_id` is \ + required in the query. Tenant-scoped (SQL-level BOLA): an unknown run — \ + or one outside the caller's authorized subtree — yields a 404 (no \ + existence leak). Mirrors `get_dispute`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("run_id", "The recognition run to read.") + .query_param( + "tenant_id", + true, + "The run's owning seller tenant (the run PK's tenant half).", + ) + .handler(get_recognition_run) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The recorded recognition run", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/recognition-runs") + .operation_id("bss_ledger.list_recognition_runs") + .summary("List recorded ASC 606 recognition runs (cursor-paginated)") + .description( + "Cursor-paginated list of the recorded recognition runs for the \ + `tenant_id` query (the caller's own by default). Supports OData \ + `$filter` over `run_id`, `period_id`, and `status`. The `$filter` ANDs \ + the caller's authorized subtree, so runs outside it are never returned \ + (SQL-level BOLA). Each item is the same `RecognitionRunView` the by-id \ + read returns. Mirrors `list_disputes`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "The runs' owning seller tenant (defaults to the caller's own).", + ) + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_recognition_runs) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of recorded recognition runs", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// A recognition-run outcome rendered with the right status: `200 OK` + the run +/// reference when the run released its due segments in period order (or replayed +/// a prior run), or `202 Accepted` + the `recognition-period-queued` body when +/// the run had to park one or more out-of-order segments for a later drain +/// (§4.6). Mirrors `payments::allocate_response`'s status-varying rendering. +fn run_response(outcome: bss_ledger_sdk::RecognitionRunOutcome) -> Response { + match outcome { + bss_ledger_sdk::RecognitionRunOutcome::Ran(run_ref) => { + (StatusCode::OK, Json(RecognitionRunResponse::from(run_ref))).into_response() + } + bss_ledger_sdk::RecognitionRunOutcome::Queued(queued) => ( + StatusCode::ACCEPTED, + Json(RecognitionRunQueuedResponse::from(queued)), + ) + .into_response(), + } +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) +// BEFORE the in-handler `require_authenticated` gate, so a malformed body yields +// 400 even for an unauthenticated caller (standard axum extractor ordering; no +// authenticated-only data is disclosed). +async fn trigger_recognition_run( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id` (tenant in body, not path). + let tenant_id = body.tenant_id; + // (recognition, write) PEP gate against the TARGET tenant: a run posts journal + // entries into the seller's ledger, so it authorizes the data-plane post + // action; a target outside the caller's scope is a cross-tenant write and is + // denied. The in-process client gates again (defence-in-depth, matching the + // payment / dispute surfaces). + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::WRITE, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let cmd = body.into_sdk()?; + let outcome = state.client.trigger_recognition_run(&ctx, cmd).await?; + Ok(run_response(outcome)) +} + +/// `GET /revenue/disaggregation` query parameters. The target tenant (the +/// caller's own when omitted) + an optional `period_id` narrowing. NOTE: +/// disaggregation is a **computed aggregate report** (a grouped SUM over the +/// recognized segments), not a paginated row collection — it keeps plain +/// `?tenant_id=&period_id=` query params (no `OData` `$filter` / `Page` envelope), +/// mirroring `journal_entries::ar_aging_handler`. +#[derive(Debug, serde::Deserialize)] +struct DisaggregationQuery { + tenant_id: Option, + period_id: Option, +} + +async fn revenue_disaggregation( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + // (recognition, read) PEP gate against the TARGET tenant — the SAME action a + // balance / journal-line LIST reads under (the recognized revenue is drawn + // down from the `entry` ledger). Defence-in-depth: the in-process client + // gates again + binds the compiled scope as the SQL-level BOLA filter (a + // target outside the caller's subtree yields no entries), matching + // `trigger_recognition_run`. + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let report = state + .client + .list_revenue_disaggregation( + &ctx, + bss_ledger_sdk::RevenueDisaggregationQuery { + tenant_id, + period_id: query.period_id, + }, + ) + .await?; + Ok(Json(RevenueDisaggregationResponse::from(report))) +} + +/// `GET /recognition-schedules/{schedule_id}` query parameters: the schedule's +/// owning seller `tenant_id` (the schedule PK is `(tenant_id, schedule_id)`, so +/// the tenant is REQUIRED in the query — unlike the disaggregation report, which +/// defaults it to the caller's own). `schedule_id` is the path param. +#[derive(Debug, serde::Deserialize)] +struct ScheduleQuery { + tenant_id: Uuid, +} + +async fn get_recognition_schedule( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(schedule_id): Path, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id; + // (recognition, read) PEP gate against the schedule's owning tenant — the SAME + // action the disaggregation read / balance LIST run under (the recognition + // tables are drawn down from the `entry` ledger). Defence-in-depth: the + // in-process client gates again + binds the compiled scope as the SQL-level + // BOLA filter (a schedule outside the caller's subtree resolves to None ⇒ 404). + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + // `None` ⇒ absent OR scoped-out — a canonical 404 either way (no existence + // leak), mirroring `journal_entries::get_entry`'s `entry_not_found`. NOT the + // fiscal-period `PeriodNotFound` domain variant — this is a fresh canonical + // not-found problem keyed on the schedule id. + let view = state + .client + .get_recognition_schedule(&ctx, tenant_id, schedule_id.clone()) + .await? + .ok_or_else(|| recognition_schedule_not_found(&schedule_id))?; + Ok(Json(RecognitionScheduleResponse::from(view))) +} + +/// `GET /recognition-schedules` query parameters: the owning seller `tenant_id` +/// (REQUIRED — the schedule PK's tenant half), optionally narrowed to one +/// originating `invoice_id` (`source_invoice_id`) and/or one `revenue_stream`. +#[derive(Debug, serde::Deserialize)] +struct ListSchedulesQuery { + tenant_id: Uuid, + #[serde(default)] + invoice_id: Option, + #[serde(default)] + revenue_stream: Option, +} + +async fn list_recognition_schedules( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id; + // (recognition, read) PEP gate against the target tenant — the SAME gate as the + // by-id read; the in-process client re-derives the compiled scope as the + // SQL-level BOLA filter, so foreign-tenant schedules are excluded (an empty + // list, never an existence leak). + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let list = state + .client + .list_recognition_schedules(&ctx, tenant_id, query.invoice_id, query.revenue_stream) + .await?; + Ok(Json(RecognitionScheduleListResponse { + schedules: list + .schedules + .into_iter() + .map(RecognitionScheduleSummaryDto::from) + .collect(), + truncated: list.truncated, + })) +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) BEFORE +// the in-handler `require_authenticated` gate (standard axum extractor ordering; +// no authenticated-only data is disclosed). Always renders `200` on success (the +// change applied, or an idempotent replay); the treatment-review / unknown-action +// / bad-segments rejections are RFC 9457 problems from the client. +async fn change_recognition_schedule( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(schedule_id): Path, + CanonicalJson(body): CanonicalJson, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id`; `schedule_id` is the path id. + let tenant_id = body.tenant_id; + // (recognition, write) PEP gate against the TARGET tenant: a schedule change marks / + // mints recognition-schedule state in the seller's ledger (same data-plane + // post action as the run trigger). A target outside the caller's scope is a + // cross-tenant write and is denied. The in-process client gates again + // (defence-in-depth, matching the run / dispute surfaces). + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::WRITE, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + let cmd = body.into_sdk(schedule_id)?; + + // Treatment gate FIRST (design §4.6; the `change_service` "Treatment gate + // FIRST" contract): a `catch_up` / unknown / unmarked treatment is a review + // with NO state change. It MUST refuse here — before the dual-control gate + // below can durably park a PENDING approval. The + // change-service re-gates the treatment in-txn (defence-in-depth); the gate is + // pure, so running it twice is free. + crate::domain::recognition::change::gate_treatment(&cmd.treatment) + .map_err(CanonicalError::from)?; + + // Dual-control gate (VHP-1852): a schedule change/cancel whose un-recognized + // deferred remainder (the revenue it re-plans / strands) crosses the D2 + // threshold routes to the preparer→approver queue (409 DUAL_CONTROL_REQUIRED); + // a below-threshold change applies inline. The remainder is read from the + // schedule; an absent/scoped-out OR non-ACTIVE schedule skips the gate. The + // non-ACTIVE skip is load-bearing for idempotency: an + // already-applied change_id leaves the original schedule REPLACED, so a replay + // falls through to the change-service and returns the idempotent 200 instead of + // recomputing the stale remainder and re-raising a spurious 409. Only an ACTIVE + // schedule is changeable, so only an ACTIVE schedule can gate. Mirrors the + // chargeback-loss gate in `disputes`. + if let Some(approval) = &state.approval + && let Some(view) = state + .client + .get_recognition_schedule(&ctx, tenant_id, cmd.schedule_id.clone()) + .await? + && view.status == crate::domain::status::SCHEDULE_STATUS_ACTIVE + { + let affected = view + .total_deferred_minor + .saturating_sub(view.recognized_minor); + let intent = crate::domain::approval::intent::ApprovalIntent::RecognitionScheduleChange( + crate::domain::approval::intent::RecognitionScheduleChangeIntent { + tenant_id: cmd.tenant_id, + schedule_id: cmd.schedule_id.clone(), + change_id: cmd.change_id.clone(), + action: cmd.action.clone(), + treatment: cmd.treatment.clone(), + new_segments: cmd.new_segments.as_ref().map(|segs| { + segs.iter() + .map( + |s| crate::domain::approval::intent::RecognitionChangeSegment { + period_id: s.period_id.clone(), + amount_minor: s.amount_minor, + }, + ) + .collect() + }), + }, + ); + let facts = crate::domain::approval::policy::OperationFacts { + kind: crate::domain::approval::ApprovalKind::RecognitionScheduleChange, + amount_usd_eq_minor: Some(affected), + effective_at: None, + has_outstanding_balance: false, + }; + if let Some(approval_id) = approval + .gate( + &ctx, + &scope, + intent, + facts, + "recognition-schedule-change".to_owned(), + ) + .await + .map_err(CanonicalError::from)? + { + return Err(CanonicalError::from( + crate::domain::error::DomainError::DualControlRequired(format!( + "recognition schedule change requires dual-control approval: {approval_id}" + )), + )); + } + } + + let result = state.client.change_recognition_schedule(&ctx, cmd).await?; + Ok(Json(ScheduleChangeResponse::from(result))) +} + +/// `GET /recognition-runs/{run_id}` query parameters: the run's owning seller +/// `tenant_id` (the run PK folds `period_id`, but the by-id read keys on the +/// surrogate `run_id` under the tenant, so the tenant is REQUIRED in the query — +/// like the by-id refund / schedule reads). `run_id` is the path param (a `Uuid`). +#[derive(Debug, serde::Deserialize)] +struct RunQuery { + tenant_id: Uuid, +} + +async fn get_recognition_run( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(run_id): Path, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id; + // (recognition, read) PEP gate against the run's owning tenant — the SAME action the + // schedule / disaggregation reads run under (the recognition tables are drawn + // from the `entry` ledger). The returned scope is the SQL-level BOLA filter the + // repo binds, so a foreign-tenant run resolves to None ⇒ 404 (no existence + // leak), mirroring `disputes::get_dispute`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let run = state + .recognition_repo + .as_ref() + .ok_or_else(|| CanonicalError::internal("recognition repository not configured").create())? + .read_run_out_of_txn(&scope, tenant_id, run_id) + .await + .map_err(|e| { + crate::domain::error::DomainError::Internal(format!("read recognition run: {e}")) + })? + .ok_or_else(|| recognition_run_not_found(run_id))?; + Ok(Json(RecognitionRunView::from(run))) +} + +/// `GET /recognition-runs` non-OData query: the runs' owning tenant (the caller's +/// own when omitted). The `OData` `$filter` / `$orderby` / `limit` / `cursor` are +/// parsed separately by the `OData` extractor from the same query string; +/// `tenant_id` stays a plain param alongside them (the list convention). +#[derive(Debug, serde::Deserialize)] +struct RunListQuery { + tenant_id: Option, +} + +async fn list_recognition_runs( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + // (recognition, read) PEP gate against the runs' owning tenant — the SAME action the + // by-id read / schedule list run under. The returned scope is the SQL-level + // BOLA filter the repo binds, so the page never contains a foreign-tenant run + // (no existence leak), mirroring `disputes::list_disputes`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECOGNITION, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let page = state + .recognition_repo + .as_ref() + .ok_or_else(|| CanonicalError::internal("recognition repository not configured").create())? + .list_runs(&scope, tenant_id, &odata) + .await + .map_err(map_odata_page_err)?; + Ok(Json(Page { + items: page + .items + .into_iter() + .map(RecognitionRunView::from) + .collect(), + page_info: page.page_info, + })) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/reconciliation.rs b/gears/bss/ledger/ledger/src/api/rest/reconciliation.rs new file mode 100644 index 000000000..006709b2e --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/reconciliation.rs @@ -0,0 +1,272 @@ +//! Axum handlers + router for the reconciliation surface (Slice 7 Phase 3, +//! design §4.3 / §4 / §5). Two operations under `/bss-ledger/v1/ledger`, +//! tenant-scoped WITHOUT a tenant in the path (the vhp-core convention — the +//! tenant is the caller's auth context). +//! +//! - `POST /ledger/reconciliation-runs` — trigger one named reconciliation check +//! (`AR_DERIVED` / `PAYMENTS_PSP` / `INVOICE_COMPLETENESS`) for the caller's own +//! tenant + a period; returns the new `run_id`. The check reads + computes a +//! variance and writes a durable `reconciliation_run` row (Slice 7 posts no +//! financial entries — it reads, reconciles, and gates close). Gates on +//! `RECONCILIATION:run`. The Payments↔PSP / invoice-completeness checks are +//! inert until their control feed lands (a not-configured feed ⇒ 400, design §0 +//! decision 3). +//! - `GET /ledger/reconciliation-runs/{run_id}` — read one run's variance result. +//! Gates on `RECONCILIATION:read`; the compiled read scope is the SQL-level BOLA +//! filter, so a foreign-tenant or absent run is the same 404 (no existence leak). +//! +//! Routes register through `OperationBuilder` so `/openapi.json` lists each +//! operation with its declared request / response schemas. Mirrors +//! `exceptions::router` / `fx::router`. + +use std::sync::Arc; + +use axum::extract::{Extension, Path}; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use chrono::{DateTime, Utc}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::error::{authz_error_to_canonical, reconciliation_run_not_found}; +use crate::domain::error::DomainError; +use crate::infra::reconciliation::{ + CHECK_AR_DERIVED, CHECK_INVOICE_COMPLETENESS, CHECK_PAYMENTS_PSP, ReconciliationFramework, +}; +use crate::infra::storage::entity::reconciliation_run; +use crate::infra::storage::repo::ReconciliationRunRepo; + +/// `OpenAPI` tag applied to the reconciliation operations. +const TAG: &str = "BSS Ledger Reconciliation"; + +/// Shared per-request state for the reconciliation routes. Constructed once at +/// `init()` and shared via `Extension>`. Carries the framework (the +/// `run` trigger) + the run repo (the by-id read). +#[derive(Clone)] +pub struct ApiState { + /// The reconciliation engine — `run_check` runs one named check + writes the + /// durable run row. `Arc` because the framework holds non-`Clone` posting / + /// feed handles. + pub framework: Arc, + /// The reconciliation-run repository (by-id read; SQL-level BOLA). + pub run_repo: ReconciliationRunRepo, +} + +/// `POST /ledger/reconciliation-runs` request body: the period + the named check +/// to run for the caller's own tenant. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct TriggerReconciliationRunRequest { + /// The fiscal `period_id` (`YYYYMM`) to reconcile. + pub period_id: String, + /// The named check: `AR_DERIVED`, `PAYMENTS_PSP`, or `INVOICE_COMPLETENESS`. + pub check_type: String, +} + +/// `POST /ledger/reconciliation-runs` response: the new run's id. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct TriggerReconciliationRunResponse { + /// The server-minted reconciliation-run id (the `GET …/{run_id}` key tail). + pub run_id: Uuid, +} + +/// One reconciliation-run row, projected for the read. PII-free (ids + variance). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct ReconciliationRunView { + pub run_id: Uuid, + pub period_id: String, + pub check_type: String, + pub variance_minor: i64, + pub within_tolerance: bool, + pub status: String, + pub at_utc: DateTime, +} + +impl From for ReconciliationRunView { + fn from(m: reconciliation_run::Model) -> Self { + Self { + run_id: m.run_id, + period_id: m.period_id, + check_type: m.check_type, + variance_minor: m.variance_minor, + within_tolerance: m.within_tolerance, + status: m.status, + at_utc: m.at_utc, + } + } +} + +/// Build the Axum router for the reconciliation surface and register both +/// operations with the supplied `OpenAPI` registry. `state` is attached via an +/// `Extension` layer at the end so the registry sees the route definitions before +/// the per-request state is bound. Mirrors [`crate::api::rest::exceptions::router`]. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/ledger/reconciliation-runs") + .operation_id("bss_ledger.trigger_reconciliation_run") + .summary("Trigger a reconciliation check for a period") + .description( + "Runs one named reconciliation check (`AR_DERIVED`, `PAYMENTS_PSP`, or \ + `INVOICE_COMPLETENESS`) for the caller's own tenant + `period_id`, \ + writing a durable `reconciliation_run` row with its variance result. \ + An out-of-tolerance run opens a close-blocking exception + raises an \ + alarm. Slice 7 posts no financial entries — it reads, reconciles, and \ + gates close. The Payments↔PSP / invoice-completeness checks are inert \ + until their control feed lands (a not-configured feed ⇒ 400). \ + `(reconciliation, run)` PEP gate against the caller's own tenant.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The period + the named check to run.", + ) + .handler(trigger_reconciliation_run) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "The new reconciliation run's id (its canonical URL is in `Location`).", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/ledger/reconciliation-runs/{run_id}") + .operation_id("bss_ledger.read_reconciliation_run") + .summary("Read a reconciliation run's variance result") + .description( + "Returns one `reconciliation_run` for `{run_id}` — the check type, the \ + reconciled `variance_minor`, whether it is within tolerance, the run \ + status, and when it ran. Tenant-scoped (SQL-level BOLA): a run that \ + does not exist for the caller's tenant, or that lies outside the \ + caller's authorized subtree, is the same 404 (no existence leak). \ + `(reconciliation, read)` PEP gate.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("run_id", "The reconciliation run's id (uuid).") + .handler(read_reconciliation_run) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The reconciliation run.", + ) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// Validate a `check_type` literal against the three known checks at the boundary +/// (a bad literal is a clean 400, not a deep `run_check` rejection). +fn validate_check_type(check_type: &str) -> Result<(), DomainError> { + if matches!( + check_type, + CHECK_AR_DERIVED | CHECK_PAYMENTS_PSP | CHECK_INVOICE_COMPLETENESS + ) { + Ok(()) + } else { + Err(DomainError::InvalidRequest(format!( + "unknown check_type {check_type:?} (expected \"{CHECK_AR_DERIVED}\", \ + \"{CHECK_PAYMENTS_PSP}\", or \"{CHECK_INVOICE_COMPLETENESS}\")" + ))) + } +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) BEFORE +// the in-handler `require_authenticated` gate, so a malformed body yields 400 even +// for an unauthenticated caller (standard axum extractor ordering; no +// authenticated-only data is disclosed). Mirrors `fx::ingest_fx_rate`. +async fn trigger_reconciliation_run( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The reconciliation targets the caller's own ledger (the run is a + // self-service Revenue-Assurance action on the caller's books). + let tenant = ctx.subject_tenant_id(); + // (reconciliation, run) gate against the caller's own tenant: triggering a + // check is a write (it persists a run row); the membership assertion fail-closes + // a target outside the caller's authorized scope. + crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECONCILIATION, + crate::authz::actions::RUN, + Some(tenant), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + // Reject an unknown check_type at the boundary (a clean 400). + validate_check_type(&body.check_type)?; + + let run_id = state + .framework + .run_check(&ctx, tenant, &body.period_id, &body.check_type) + .await + .map_err(CanonicalError::from)?; + + // 201 Created + a `Location` to the new run's by-id read (the server minted the + // `run_id`), mirroring how `refunds::post_refund` returns a created resource. + let location = format!("/bss-ledger/v1/ledger/reconciliation-runs/{run_id}"); + Ok(( + StatusCode::CREATED, + [(header::LOCATION, location)], + Json(TriggerReconciliationRunResponse { run_id }), + ) + .into_response()) +} + +/// `GET …/reconciliation-runs/{run_id}`: read one run (RECONCILIATION:read). +async fn read_reconciliation_run( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(run_id): Path, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + // (reconciliation, read) gate: reads pass `owner_tenant_id = None` (the PDP + // derives the scope from the subject + role; the returned scope is the SQL + // filter), pinning the single run row via `resource_id`. `require_constraints = + // true` so an unconstrained allow fail-closes instead of leaking every tenant. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::RECONCILIATION, + crate::authz::actions::READ, + None, + Some(run_id), + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let tenant = ctx.subject_tenant_id(); + let row = state + .run_repo + .read(&scope, tenant, run_id) + .await? + .ok_or_else(|| reconciliation_run_not_found(run_id))?; + + Ok(Json(ReconciliationRunView::from(row))) +} diff --git a/gears/bss/ledger/ledger/src/api/rest/refunds.rs b/gears/bss/ledger/ledger/src/api/rest/refunds.rs new file mode 100644 index 000000000..a7c6c878c --- /dev/null +++ b/gears/bss/ledger/ledger/src/api/rest/refunds.rs @@ -0,0 +1,484 @@ +//! Axum handlers + router for the ledger's refund (money-OUT) REST surface +//! (Slice 3, Phase 2, design §4.4 / §5 / §7, Group G). Three operations under +//! `/bss-ledger/v1`, tenant-scoped WITHOUT a tenant in the path (the vhp-core +//! convention, matching the adjustment / payment / dispute surfaces): the writes +//! carry `tenant_id` in the **body**, the by-id read takes the refund id in the +//! path + the owning seller `tenant_id` in the query. +//! +//! - `POST /refunds` — record one PSP refund phase (the money-out side that +//! unwinds a settled receipt). Idempotent on `(psp_refund_id, phase)`: a re-post +//! replays (`200`), a fresh post is `201`. A refund whose cash crosses the +//! tenant's D2 threshold routes to dual-control (`409 DUAL_CONTROL_REQUIRED`). A +//! refund whose origin payment has not landed is QUARANTINED +//! (`202` + `refund-quarantined` body token), NEVER posted (design §4.4 / PRD +//! L668) — distinct from queue-and-apply. +//! - `POST /refund-with-credit-note` — post a refund AND its paired S3 credit note +//! ATOMICALLY in ONE transaction as two linked entries (K-3): both commit or +//! neither, so AR is never overstated between them. `201` fresh / `200` replay / +//! `409` over D2. +//! - `GET /refunds/{refund_id}` — read a recorded refund + its clearing state. The +//! surrogate PK is `(tenant_id, refund_id)`, so the owning seller `tenant_id` is +//! required in the query. `404` when no refund with that id exists (or it is +//! outside the caller's subtree — no existence leak). +//! +//! All three authorize under `(entry, post)` / `(entry, read)` — the SAME +//! data-plane actions the credit/debit-note + invoice-post writes use (a refund +//! posts a balanced journal entry into the seller's ledger; the by-id read draws +//! from the `entry` ledger's `refund` record). This mirrors the Phase-1 notes +//! decision (`adjustments.rs`): a refund is not a separate authz resource, it +//! posts through the same `PostingService`. The `RefundHandler` is a concrete +//! orchestrator (not behind `LedgerClientV1`) that re-gates nothing internally, so +//! the handler-layer PEP gate is the authority and threads the compiled scope into +//! the post (the SQL-level BOLA filter), exactly as `adjustments::post_credit_note`. +//! +//! The routes register through `OperationBuilder` so `/openapi.json` lists each +//! operation with its declared request / response schemas. Mirrors +//! [`crate::api::rest::adjustments::router`]. + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, http::StatusCode}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit::api::odata::OData; +use toolkit::api::operation_builder::OperationBuilderODataExt; +use toolkit::api::{OpenApiRegistry, operation_builder::OperationBuilder}; +use toolkit_odata::Page; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::local_client::map_odata_page_err; +use crate::api::rest::auth_context::require_authenticated; +use crate::api::rest::canonical_json::CanonicalJson; +use crate::api::rest::dto::{ + REFUND_DISPUTE_HELD_STATUS, REFUND_QUARANTINED_STATUS, RefundDisputeHeldResponse, + RefundQuarantinedResponse, RefundRequest, RefundResponse, RefundView, + RefundWithCreditNoteRequest, RefundWithCreditNoteResponse, +}; +use crate::api::rest::error::{authz_error_to_canonical, refund_not_found}; +use crate::infra::adjustment::refund_service::{RefundHandler, RefundOutcome}; +use crate::infra::storage::repo::AdjustmentRepo; +use crate::odata::RefundFilterField; + +/// `OpenAPI` tag applied to the refund operations. +const TAG: &str = "BSS Ledger Refunds"; + +/// Shared per-request state for the refund routes. Constructed once at `init()` +/// and shared via `Extension>`. Carries the GATED refund +/// orchestrator (a refund posts through it — caps + record + event, over-D2 → +/// dual-control, refund-before-payment → quarantine, and the atomic +/// `refund-with-credit-note` composite) + the `AdjustmentRepo` for the by-id read. +/// Mirrors [`crate::api::rest::adjustments::ApiState`]. +#[derive(Clone)] +pub struct ApiState { + /// The gated refund orchestrator (post / composite / quarantine / de-quarantine). + pub refunds: Arc, + /// The adjustment repo — the `GET /refunds/{id}` read source (the `refund` row). + pub refund_repo: AdjustmentRepo, +} + +/// Build the Axum router for the refund surface and register every operation with +/// the supplied `OpenAPI` registry. `state` is attached via an `Extension` layer +/// at the end so the registry sees the route definitions before the per-request +/// state is bound. Mirrors [`crate::api::rest::adjustments::router`]. +pub fn router(state: Arc, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = Router::new(); + + router = OperationBuilder::post("/bss-ledger/v1/refunds") + .operation_id("bss_ledger.post_refund") + .summary("Record a PSP refund phase (money-out)") + .description( + "Records one phase of a PSP refund for the seller named by the body's \ + `tenant_id` — the money-out side that unwinds a settled receipt \ + (Pattern A on-account DR UNALLOCATED, or Pattern B restore-AR DR AR; \ + stage-1 CR REFUND_CLEARING, stage-2 DR REFUND_CLEARING / CR \ + CASH_CLEARING; NEVER DR CONTRACT_LIABILITY). Idempotent on \ + `(psp_refund_id, phase)`: a re-post returns the prior posting reference \ + (200) instead of a new one (201). A refund whose returned cash crosses \ + the tenant's D2 threshold routes to dual-control (409 \ + DUAL_CONTROL_REQUIRED). A refund whose origin payment has no resolvable \ + settlement is QUARANTINED (202 refund-quarantined) — never posted; it \ + only ever posts after an explicit de-quarantine that re-validates the \ + caps + the then-current threshold + the dispute state. An over-refund \ + past the settled/allocated cap is rejected (REFUND_EXCEEDS_SETTLED / \ + REFUND_EXCEEDS_ALLOCATED).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The refund phase (pattern, phase, amount, origin payment) + the \ + idempotency `psp_refund_id`.", + ) + .handler(post_refund) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Posting reference (201 fresh phase / 200 idempotent replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior refund phase", + ) + .json_response_with_schema::( + openapi, + StatusCode::ACCEPTED, + "Refund-before-payment quarantined until its origin lands (refund-quarantined)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/bss-ledger/v1/refund-with-credit-note") + .operation_id("bss_ledger.post_refund_with_credit_note") + .summary("Post a refund + its paired credit note atomically") + .description( + "Posts a S5 refund AND its paired S3 credit note in ONE transaction as \ + two linked entries (K-3) for the seller named by the body's refund \ + `tenant_id` — both commit or neither, so AR is never overstated between \ + them. The refund is gated over D2 like a plain stage-1 (the credit note \ + rides the same approval; a high-value composite routes to dual-control \ + as a unit, 409). A split-ambiguous / over-headroom credit note, an \ + over-refund cap, or a closed account rolls the WHOLE composite back. \ + The refund's origin settlement MUST resolve (a composite refunds a real \ + payment — an absent origin is a 404, NOT a quarantine).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::( + openapi, + "The refund + the paired credit note to post atomically.", + ) + .handler(post_refund_with_credit_note) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Both posted entry references (201 fresh / 200 idempotent replay)", + ) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Idempotent replay of a prior composite (both halves)", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/refunds/{refund_id}") + .operation_id("bss_ledger.get_refund") + .summary("Read a recorded refund + its clearing state") + .description( + "Returns the recorded refund for `(tenant_id, refund_id)` — its latest \ + lifecycle phase, pattern, amount, origin payment, and the two-stage \ + REFUND_CLEARING drain `clearing_state` (PENDING ⇒ stage-1 open / \ + SETTLED ⇒ drained or single-step / REVERSED ⇒ a PSP reject/void \ + line-negated the stage-1). The surrogate PK is `(tenant_id, \ + refund_id)`, so the owning seller `tenant_id` is required in the query. \ + Tenant-scoped (SQL-level BOLA): an unknown refund — or one outside the \ + caller's authorized subtree — yields a 404 (no existence leak).", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param( + "refund_id", + "The refund whose record + clearing state to read.", + ) + .query_param( + "tenant_id", + true, + "The refund's owning seller tenant (the refund PK's tenant half).", + ) + .handler(get_refund) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The recorded refund + its clearing state", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/bss-ledger/v1/refunds") + .operation_id("bss_ledger.list_refunds") + .summary("List recorded refunds (cursor-paginated)") + .description( + "Cursor-paginated list of the recorded refunds for the `tenant_id` \ + query (the caller's own by default). Supports OData `$filter` over \ + `payment_id`, `psp_refund_id`, `phase`, `pattern`, `clearing_state`, \ + and `invoice_id`. The `$filter` ANDs the caller's authorized subtree, \ + so refunds outside it are never returned (SQL-level BOLA). Each item is \ + the same `RefundView` the by-id read returns.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .query_param( + "tenant_id", + false, + "The refunds' owning seller tenant (defaults to the caller's own).", + ) + .query_param_typed( + "limit", + false, + "Maximum items per page (default 25, max 200)", + "integer", + ) + .query_param("cursor", false, "Opaque base64url pagination cursor") + .handler(list_refunds) + .with_odata_filter::() + .json_response_with_schema::>( + openapi, + StatusCode::OK, + "One page of recorded refunds", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + router.layer(Extension(state)) +} + +/// A refund-record outcome rendered with the right status: `201 Created` for a +/// fresh post, `200 OK` for an idempotent replay, or `202 Accepted` + the +/// `refund-quarantined` body for a refund-before-payment quarantined until its +/// origin lands (§4.4). Mirrors `disputes::record_response`'s status-varying +/// rendering (the dual-control 409 flows through the `From` ladder). +fn record_response(outcome: RefundOutcome) -> Response { + match outcome { + RefundOutcome::Posted(reference) => { + let status = if reference.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + (status, Json(RefundResponse::from(reference))).into_response() + } + RefundOutcome::Quarantined(handle) => ( + StatusCode::ACCEPTED, + Json(RefundQuarantinedResponse { + status: REFUND_QUARANTINED_STATUS.to_owned(), + flow: handle.flow, + business_id: handle.business_id, + quarantined_at: handle.quarantined_at, + }), + ) + .into_response(), + // Refund-dispute-held (Z5-2, design §5): the origin payment has an OPEN + // dispute, so the refund's cash leg was durably HELD (202), never posted. + RefundOutcome::DisputeHeld(handle) => ( + StatusCode::ACCEPTED, + Json(RefundDisputeHeldResponse { + status: REFUND_DISPUTE_HELD_STATUS.to_owned(), + flow: handle.flow, + business_id: handle.business_id, + held_at: handle.held_at, + }), + ) + .into_response(), + } +} + +// The `CanonicalJson` extractor runs (and may reject with a canonical 400) BEFORE +// the in-handler `require_authenticated` gate, so a malformed body yields 400 even +// for an unauthenticated caller (standard axum extractor ordering; no +// authenticated-only data is disclosed). Mirrors `adjustments::post_credit_note`. +async fn post_refund( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the body's `tenant_id` (tenant in body, not path). + let tenant_id = body.tenant_id; + // (entry, post) PEP gate against the TARGET tenant — the SAME data-plane post + // action the credit/debit-note + invoice-post writes use (a refund posts a + // balanced journal entry into the seller's ledger). A target outside the + // caller's scope is a cross-tenant write and is denied; the returned scope + // threads into the scoped post (the SQL-level BOLA filter). + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::POST, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let req = body.into_domain().map_err(CanonicalError::from)?; + // `record_refund` resolves the origin settlement: present ⇒ post (over-D2 → + // DualControlRequired → 409 via the ladder; over-refund cap → 400); absent ⇒ + // quarantine (202). All domain rejections flow through the single + // `From for CanonicalError` ladder via `?`. + let outcome = state + .refunds + .record_refund(&ctx, &scope, req) + .await + .map_err(CanonicalError::from)?; + Ok(record_response(outcome)) +} + +async fn post_refund_with_credit_note( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + CanonicalJson(body): CanonicalJson, +) -> Result { + let ctx = require_authenticated(extension_ctx)?; + // The target seller is the refund's `tenant_id`. The credit note targets the + // same seller (the composite is one tenant's books); the (entry, post) gate is + // on the refund's tenant. + let tenant_id = body.refund.tenant_id; + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::POST, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let refund = body.refund.into_domain().map_err(CanonicalError::from)?; + let credit_note = body + .credit_note + .into_domain() + .map_err(CanonicalError::from)?; + // The composite refund's tenant + the credit note's tenant must be the same + // seller (one tenant's books, one PEP gate). A cross-tenant pairing is rejected + // as a malformed request (400) rather than silently posting two tenants' books. + if credit_note.tenant_id != tenant_id { + return Err(CanonicalError::from( + crate::domain::error::DomainError::InvalidRequest( + "refund-with-credit-note: the refund and credit note must target the same \ + tenant" + .to_owned(), + ), + )); + } + let outcome = state + .refunds + .post_refund_with_credit_note(&ctx, &scope, refund, credit_note) + .await + .map_err(CanonicalError::from)?; + let status = if outcome.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + Ok(( + status, + Json(RefundWithCreditNoteResponse { + refund_entry_id: outcome.refund_entry_id, + credit_note_entry_id: outcome.credit_note_entry_id, + replayed: outcome.replayed, + }), + ) + .into_response()) +} + +/// `GET /refunds/{refund_id}` query parameters: the refund's owning seller +/// `tenant_id` (the refund PK is `(tenant_id, refund_id)`, so the tenant is +/// REQUIRED in the query — like the by-id exposure / recognition-schedule reads). +#[derive(Debug, serde::Deserialize)] +struct RefundQuery { + tenant_id: Uuid, +} + +async fn get_refund( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Path(refund_id): Path, + Query(query): Query, +) -> Result, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id; + // (entry, read) PEP gate against the refund's owning tenant — the SAME action + // the balance / exposure reads run under. The returned scope is the SQL-level + // BOLA filter the repo binds, so a foreign-tenant refund resolves to None ⇒ 404 + // (no existence leak), mirroring `adjustments::get_invoice_exposure`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let refund = state + .refund_repo + .read_refund_out_of_txn(&scope, tenant_id, &refund_id) + .await + .map_err(|e| crate::domain::error::DomainError::Internal(format!("read refund: {e}")))? + .ok_or_else(|| refund_not_found(&refund_id))?; + Ok(Json(RefundView::from(refund))) +} + +/// `GET /refunds` non-OData query: the refunds' owning tenant (the caller's own +/// when omitted). The `OData` `$filter` / `$orderby` / `limit` / `cursor` are +/// parsed separately by the `OData` extractor from the same query string; +/// `tenant_id` stays a plain param alongside them (the list convention). +#[derive(Debug, serde::Deserialize)] +struct RefundListQuery { + tenant_id: Option, +} + +async fn list_refunds( + Extension(state): Extension>, + Extension(enforcer): Extension, + extension_ctx: Option>, + Query(query): Query, + OData(odata): OData, +) -> Result>, CanonicalError> { + let ctx = require_authenticated(extension_ctx)?; + let tenant_id = query.tenant_id.unwrap_or_else(|| ctx.subject_tenant_id()); + // (entry, read) PEP gate against the refunds' owning tenant — the SAME action + // the by-id read / balances run under. The returned scope is the SQL-level BOLA + // filter the repo binds, so the page never contains a foreign-tenant refund (no + // existence leak), mirroring `get_refund` / `journal_entries::list_lines`. + let scope = crate::authz::access_scope( + &enforcer, + &ctx, + &crate::authz::resource_types::ENTRY, + crate::authz::actions::READ, + Some(tenant_id), + None, + /* require_constraints */ true, + ) + .await + .map_err(authz_error_to_canonical)?; + + let page = state + .refund_repo + .list_refunds(&scope, tenant_id, &odata) + .await + .map_err(map_odata_page_err)?; + Ok(Json(Page { + items: page.items.into_iter().map(RefundView::from).collect(), + page_info: page.page_info, + })) +} diff --git a/gears/bss/ledger/ledger/src/authz.rs b/gears/bss/ledger/ledger/src/authz.rs new file mode 100644 index 000000000..a9e834aed --- /dev/null +++ b/gears/bss/ledger/ledger/src/authz.rs @@ -0,0 +1,338 @@ +//! BSS Ledger authorization: PEP resource-type descriptors, action names, the +//! shared [`access_scope`] gate every ctx-bearing service path calls before +//! touching the repository, and the authz-label stub type-schemas registered at +//! gear init so RBAC role-definitions can target the ledger's labels. +//! +//! Nine object-named labels, all OUTSIDE the `gts.cf.resources.*` family — the +//! built-in Reader / Contributor / Owner roles do NOT auto-cover them; access to +//! this finance data requires explicit billing roles: +//! - `gts.cf.bss.ledger.entry.v1~` — journal entries / balances (`post`, +//! `reverse`, `read`). +//! - `gts.cf.bss.ledger.ledger.v1~` — the seller's ledger (`provision` — seed its +//! chart of accounts, scales, calendar, first period; `read` — list its chart +//! of accounts). +//! - `gts.cf.bss.ledger.fiscal_period.v1~` — a fiscal period (`close`). +//! - `gts.cf.bss.ledger.payment.v1~` — a payment (`write` — settle a receipt / +//! allocate it to receivables; `read` — list a payment's allocations / read the +//! payer's unallocated pool). +//! - `gts.cf.bss.ledger.credit_application.v1~` — a reusable-credit wallet +//! operation (`write` — grant pool cash into the wallet / apply the wallet to +//! receivables). +//! - `gts.cf.bss.ledger.dispute.v1~` — a chargeback dispute (`write` — record a +//! dispute phase: open / win / lose). +//! - `gts.cf.bss.ledger.dual_control_policy.v1~` — the tenant dual-control +//! threshold policy (`write` — append an effective-dated version; `read` — the +//! effective policy). +//! - `gts.cf.bss.ledger.recognition.v1~` — ASC 606 revenue recognition (`write` — +//! trigger a run / change a schedule; `read` — runs / schedules / +//! disaggregation). +//! - `gts.cf.bss.ledger.reconciliation.v1~` — reconciliation & revenue assurance +//! (`run` — trigger a check; `resolve` — resolve a close-blocking exception; +//! `read` — the exception queue / recon runs). +//! +//! Each action sits on its real object (a noun), never an authz tier: the +//! "billing-setup" role grants `provision` + `read` on `ledger` + `close` on +//! `fiscal_period`. +//! +//! The PEP advertises NO tenant-subtree capability (`PolicyEnforcer::new`), so +//! the PDP pre-expands the caller's seller subtree to a flat +//! `AccessScope::In([...])` that SecureORM binds to the `tenant_id` column. + +use authz_resolver_sdk::PolicyEnforcer; +use authz_resolver_sdk::pep::{AccessRequest, ResourceType}; +use toolkit_security::{AccessScope, SecurityContext, pep_properties}; +use uuid::Uuid; + +/// Authz `resource_type` label strings (the PDP-visible glob targets). +/// +/// Kept as plain `&'static str` consts so both the [`resource_types`] +/// descriptors used at enforcement time and the GTS permission catalog declared +/// in the `bss-ledger` crate (`crate::gts::permissions`) share one source of +/// truth. +pub mod labels { + /// Journal entries / balances — data plane (`post`, `reverse`, `read`). + /// OUTSIDE `gts.cf.resources.*` so only an explicit billing role covers it. + pub const ENTRY: &str = "gts.cf.bss.ledger.entry.v1~"; + /// The seller's ledger — `provision` seeds its chart of accounts, currency + /// scales, fiscal calendar, and first period; `read` lists its chart of + /// accounts. OUTSIDE `gts.cf.resources.*`. + pub const LEDGER: &str = "gts.cf.bss.ledger.ledger.v1~"; + /// A fiscal period — `close` transitions it `OPEN`→`CLOSED`. OUTSIDE + /// `gts.cf.resources.*`. + pub const FISCAL_PERIOD: &str = "gts.cf.bss.ledger.fiscal_period.v1~"; + /// A payment — `write` settles a receipt / allocates it to receivables; + /// `read` lists a payment's allocations / the payer's unallocated pool. + /// OUTSIDE `gts.cf.resources.*`. + pub const PAYMENT: &str = "gts.cf.bss.ledger.payment.v1~"; + /// A reusable-credit wallet operation — `write` grants the payer's + /// unallocated pool cash into the wallet sub-grain, or applies the wallet to + /// open receivables (architecture §5.2). OUTSIDE `gts.cf.resources.*`. + pub const CREDIT_APPLICATION: &str = "gts.cf.bss.ledger.credit_application.v1~"; + /// A chargeback dispute — `write` records a dispute phase (open / win / + /// lose) on a payment; `read` lists disputes / reads one by id (architecture + /// §4.5). OUTSIDE `gts.cf.resources.*`. + pub const DISPUTE: &str = "gts.cf.bss.ledger.dispute.v1~"; + /// The tenant dual-control threshold policy — `write` appends an + /// effective-dated D2/A6/TTL version (DC8); `read` returns the effective + /// policy. Its OWN resource (not a `ledger` action) so a governance-officer + /// role grants threshold read/write independently of ledger provisioning or + /// the `entry` data plane. OUTSIDE `gts.cf.resources.*`. + pub const DUAL_CONTROL_POLICY: &str = "gts.cf.bss.ledger.dual_control_policy.v1~"; + /// ASC 606 revenue recognition — `write` triggers a recognition run or + /// changes a schedule; `read` lists runs / schedules / revenue disaggregation. + /// Its OWN resource (a job-driven revenue-accounting domain, not the `entry` + /// data plane) so a revenue-accountant role grants recognition read/write + /// independently of posting refunds / notes. OUTSIDE `gts.cf.resources.*`. + pub const RECOGNITION: &str = "gts.cf.bss.ledger.recognition.v1~"; + /// Reconciliation & Revenue Assurance — `read` lists the exception queue / + /// reconciliation runs; `run` triggers a reconciliation check. Its OWN resource + /// (a distinct Revenue-Assurance surface, mirroring how `dispute` / `recognition` + /// got their own) so a revenue-assurance role grants recon read/run independently + /// of the `entry` data plane or `fiscal_period` close. OUTSIDE `gts.cf.resources.*`. + pub const RECONCILIATION: &str = "gts.cf.bss.ledger.reconciliation.v1~"; + /// Tenant ledger config plane (VHP-1853 invoice-posting policy + VHP-1986 FX + /// revaluation mode) — `write` appends an effective-dated version of a tenant + /// setting; `read` the effective value. ONE shared config resource so a + /// billing-config role grants these tenant settings together, independently of + /// the `entry` data plane. NOTE: `dual_control_policy` is deliberately a + /// SEPARATE resource (segregation of duties — a config admin must not be able + /// to weaken its own approval thresholds). OUTSIDE `gts.cf.resources.*`. + pub const LEDGER_CONFIG: &str = "gts.cf.bss.ledger.config.v1~"; + + /// Every authz label, stable order. The single canonical list driving the + /// per-label stub type-schema registration (see + /// [`super::authz_label_type_schemas`]) that lets RBAC role-definitions + /// target any ledger label. MUST match the permission catalog's distinct + /// `resource_type`s (`crate::gts::permissions`); a drift test enforces it. + pub const ALL: &[&str] = &[ + ENTRY, + LEDGER, + FISCAL_PERIOD, + PAYMENT, + CREDIT_APPLICATION, + DISPUTE, + DUAL_CONTROL_POLICY, + RECOGNITION, + RECONCILIATION, + LEDGER_CONFIG, + ]; +} + +/// PEP action names for the ledger surfaces. +pub mod actions { + /// Post a balanced journal entry (data plane, write). + pub const POST: &str = "post"; + /// Reverse a posted journal entry (strict line-negation) or post a + /// `MAPPING_CORRECTION` (data plane, write). Distinct from [`POST`] so a + /// role can grant original posting without granting reversal authority. + pub const REVERSE: &str = "reverse"; + /// Read action — used by `entry` (balances / journal / records), `ledger` + /// (chart of accounts), `payment` (allocations / unallocated / settlement), + /// `dispute` (list / by-id), `dual_control_policy` (effective policy), and + /// `recognition` (runs / schedules / disaggregation). The resource scopes what + /// the read authorizes. + pub const READ: &str = "read"; + /// Annotate an entry / line with a controlled non-financial note (Group 2B; + /// the `PATCH …/annotation` surface — the typed `description` overlay). + /// Distinct from [`POST`] / [`REVERSE`] so a role can grant annotation edits + /// without granting the authority to post or reverse financial entries, and + /// distinct from any authoritative workflow state (e.g. dispute, which is + /// owned by `dispute × write`). + pub const ANNOTATE: &str = "annotate"; + /// Read the secured audit surface for an entry / document / tamper-status + /// (Group 2C; the `GET …/audit/*` surface). Distinct from [`READ`] so a + /// forensic/audit role can be granted the audit-retrieval surface (incl. the + /// cross-tenant elevation gate) without granting balance/chart reads or any + /// write authority. + pub const AUDIT_READ: &str = "audit_read"; + /// Erase a payer's PII map (GDPR right-to-erasure; Group 3A, the + /// `POST …/audit/erasure` surface). DPO-scoped — distinct from every other + /// action so a Data Protection Officer role grants erasure without granting + /// posting, reversal, or audit-read authority. + pub const ERASE: &str = "erase"; + /// Re-identify a (possibly erased) payer's PII reference (forensic; Group 3A, + /// the `POST …/audit/reidentify` surface). Distinct from [`ERASE`] / + /// [`AUDIT_READ`] so the elevated forensic re-identify can be granted on its + /// own. + pub const REIDENTIFY: &str = "reidentify"; + /// Provision a seller's ledger (control plane). + pub const PROVISION: &str = "provision"; + /// Close a fiscal period (control plane). + pub const CLOSE: &str = "close"; + /// Write action — used by `payment` (settle / allocate, decision K), + /// `dual_control_policy` (append an effective-dated threshold version, DC8), + /// and `recognition` (trigger a run / change a schedule). One uniform `write` + /// verb; the resource scopes what it authorizes (so a governance-officer or + /// revenue-accountant grant is independent of payment writes). + pub const WRITE: &str = "write"; + /// Approve (or reject / return) a dual-control governed mutation (data plane, + /// write). Distinct from [`POST`]/[`REVERSE`]/[`WRITE`] so a role can grant + /// posting authority without granting approval authority — and the + /// `preparer ≠ approver` rule is enforced server-side (VHP-1852). + pub const APPROVE: &str = "approve"; + /// Trigger a reconciliation check (the `reconciliation` control plane, Slice 7 + /// Phase 3). Distinct from [`READ`] so a revenue-assurance role can read the + /// exception queue / recon runs without triggering runs. + pub const RUN: &str = "run"; + /// Resolve / acknowledge / approve a close-blocking exception (the + /// `reconciliation` plane, Slice 7 Phase 2 — `OPEN→ACK→RESOLVED` / + /// `APPROVED_EXCEPTION`). Distinct from [`READ`] (a dashboard viewer cannot + /// mutate) and [`RUN`] (resolving an exception is not triggering a check). + pub const RESOLVE: &str = "resolve"; +} + +/// Properties the PEP may compile from PDP constraints for ledger rows. Every +/// ledger row is tenant-owned: `owner_tenant_id` is the tenant column the +/// secure-ORM filter binds to, `id` the row PK (entry-level gates). NO +/// subtree/group property — the PDP pre-expands the subtree to a flat `In` +/// (decision A). +pub const SUPPORTED_PROPERTIES: &[&str] = + &[pep_properties::OWNER_TENANT_ID, pep_properties::RESOURCE_ID]; + +/// PEP resource-type descriptors (one `const` per ledger authz label). +pub mod resource_types { + use super::{ResourceType, SUPPORTED_PROPERTIES, labels}; + + /// Journal entries / balances — data plane (`post`, `reverse`, `read`). + pub const ENTRY: ResourceType = ResourceType::from_static(labels::ENTRY, SUPPORTED_PROPERTIES); + /// The seller's ledger — `provision`, `read`. + pub const LEDGER: ResourceType = + ResourceType::from_static(labels::LEDGER, SUPPORTED_PROPERTIES); + /// A fiscal period — `close`. + pub const FISCAL_PERIOD: ResourceType = + ResourceType::from_static(labels::FISCAL_PERIOD, SUPPORTED_PROPERTIES); + /// A payment — `write` (settle / allocate), `read` (list allocations / + /// unallocated). + pub const PAYMENT: ResourceType = + ResourceType::from_static(labels::PAYMENT, SUPPORTED_PROPERTIES); + /// A reusable-credit wallet operation — `write` (grant / apply). + pub const CREDIT_APPLICATION: ResourceType = + ResourceType::from_static(labels::CREDIT_APPLICATION, SUPPORTED_PROPERTIES); + /// A chargeback dispute — `write` (record a phase), `read` (list / by-id). + pub const DISPUTE: ResourceType = + ResourceType::from_static(labels::DISPUTE, SUPPORTED_PROPERTIES); + /// The tenant dual-control threshold policy — `write` (append a version), + /// `read` (effective policy). + pub const DUAL_CONTROL_POLICY: ResourceType = + ResourceType::from_static(labels::DUAL_CONTROL_POLICY, SUPPORTED_PROPERTIES); + /// ASC 606 revenue recognition — `write` (trigger run / change schedule), + /// `read` (runs / schedules / disaggregation). + pub const RECOGNITION: ResourceType = + ResourceType::from_static(labels::RECOGNITION, SUPPORTED_PROPERTIES); + /// Reconciliation & Revenue Assurance — `read` (exception queue / recon runs), + /// `run` (trigger a reconciliation check). + pub const RECONCILIATION: ResourceType = + ResourceType::from_static(labels::RECONCILIATION, SUPPORTED_PROPERTIES); + /// The tenant ledger config plane (invoice-posting policy + FX revaluation + /// mode) — `write` (append a version of a setting), `read` (effective values). + pub const LEDGER_CONFIG: ResourceType = + ResourceType::from_static(labels::LEDGER_CONFIG, SUPPORTED_PROPERTIES); +} + +/// Error from the ledger authz gate. +#[derive(Debug, thiserror::Error)] +pub enum AuthzError { + /// The PDP explicitly denied access (or returned uncompilable constraints). + #[error("permission denied: {0}")] + Denied(String), + /// The PDP was unreachable or its response could not be compiled. + #[error("authz unavailable: {0}")] + Unavailable(String), +} + +/// Minimal, deterministic type-schema body for an authz label. Key order is +/// fixed by construction, so a re-registration is byte-identical — the registry +/// accepts identical duplicates and does not validate body richness. +fn authz_type_schema_json(gts_id: &str, title: &str) -> serde_json::Value { + serde_json::json!({ + "$id": format!("gts://{gts_id}"), + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": title, + "type": "object", + }) +} + +/// Stub type-schemas for every authz label ([`labels::ALL`]). The platform RBAC +/// role-definition validator resolves a rule's `target_type` through the +/// types-registry (`get_type_schema`), so registering these at gear init lets a +/// custom billing role target any ledger authz label. +#[must_use] +pub fn authz_label_type_schemas() -> Vec { + labels::ALL + .iter() + .map(|label| authz_type_schema_json(label, &format!("BSS Ledger authz label {label}"))) + .collect() +} + +/// Shared PEP gate: asks the PDP whether `(resource_type, action)` is permitted +/// for `ctx`, returning the caller's compiled [`AccessScope`]. `resource_id` +/// pins a single-row op (`None` for collections). +/// +/// `owner_tenant_id` is an optional `OWNER_TENANT_ID` resource-property hint +/// describing the *resource's* owning tenant: +/// - **Reads** pass `None` — the PDP derives the scope from the subject + role, +/// never from a caller-supplied tenant; the returned scope is the SQL filter. +/// - **Writes** pass `Some(target_tenant)` — the tenant the row is written to. +/// This is NOT self-validating at the PDP: the degraded flat-`In` decision +/// does not re-check `owner_tenant_id`, so this fn asserts `target_tenant` is +/// a member of the compiled scope and denies a cross-tenant target. +/// +/// `require_constraints` must be `true` on every authorizing path here — reads +/// (so the scope is a real SQL filter and an unconstrained *allow* fail-closes +/// instead of leaking every tenant) and writes (so the target-membership +/// assertion above has a constraint to test). Pass `false` only for a pure +/// allow/deny gate with no tenant anchor; no ledger path currently does. +/// +/// # Errors +/// +/// [`AuthzError::Denied`] when the PDP denies or returns uncompilable +/// constraints; [`AuthzError::Unavailable`] when the PDP is unreachable. +pub async fn access_scope( + enforcer: &PolicyEnforcer, + ctx: &SecurityContext, + rt: &ResourceType, + action: &str, + owner_tenant_id: Option, + resource_id: Option, + require_constraints: bool, +) -> Result { + let mut request = AccessRequest::new().require_constraints(require_constraints); + if let Some(tenant) = owner_tenant_id { + request = request.resource_property(pep_properties::OWNER_TENANT_ID, tenant); + } + if let Some(rid) = resource_id { + request = request.resource_property(pep_properties::RESOURCE_ID, rid); + } + let scope = enforcer + .access_scope_with(ctx, rt, action, resource_id, &request) + .await + .map_err(|e| match e { + authz_resolver_sdk::EnforcerError::Denied { .. } + | authz_resolver_sdk::EnforcerError::CompileFailed(_) => { + AuthzError::Denied(e.to_string()) + } + authz_resolver_sdk::EnforcerError::EvaluationFailed(_) => { + AuthzError::Unavailable(e.to_string()) + } + })?; + + // Write paths anchor to a target tenant and pass `require_constraints = + // true`: the degraded flat-`In` PDP decision does NOT re-validate + // `owner_tenant_id`, so assert the target is a member of the compiled scope + // here — a target outside the caller's authorized tenants is a cross-tenant + // write and is denied. Reads pass `owner_tenant_id = None` and use the + // scope as the SQL filter, so this membership check is write-only. + if let Some(target) = owner_tenant_id + && require_constraints + && !scope.contains_uuid(pep_properties::OWNER_TENANT_ID, target) + { + return Err(AuthzError::Denied(format!( + "subject not authorized to write resources owned by tenant {target}" + ))); + } + Ok(scope) +} + +#[cfg(test)] +#[path = "authz_tests.rs"] +mod authz_tests; diff --git a/gears/bss/ledger/ledger/src/authz_tests.rs b/gears/bss/ledger/ledger/src/authz_tests.rs new file mode 100644 index 000000000..17f65cf5a --- /dev/null +++ b/gears/bss/ledger/ledger/src/authz_tests.rs @@ -0,0 +1,373 @@ +//! Tests for the ledger authz descriptors and label stub schemas. + +#![allow(clippy::expect_used)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, AuthZResolverError, PolicyEnforcer}; +use toolkit_security::{SecurityContext, pep_properties}; +use uuid::Uuid; + +use super::{AuthzError, access_scope, actions, authz_label_type_schemas, labels, resource_types}; + +#[test] +fn resource_types_carry_their_labels() { + assert_eq!(resource_types::ENTRY.name(), labels::ENTRY); + assert_eq!(resource_types::LEDGER.name(), labels::LEDGER); + assert_eq!(resource_types::FISCAL_PERIOD.name(), labels::FISCAL_PERIOD); + assert_eq!(resource_types::PAYMENT.name(), labels::PAYMENT); +} + +#[test] +fn entry_actions_are_stable() { + // Anti-drift: the `entry` resource's action consts are wire-stable strings + // the PEP gate + the permission catalog (`crate::gts::permissions`) share. + // `reverse` must exist as its own action — the reversal / + // mapping-correction handlers gate on `(entry, reverse)`, NOT `(entry, + // post)`, so original-posting authority and reversal authority are + // separately grantable. Drop `REVERSE` and this fails to compile / match. + assert_eq!(actions::POST, "post"); + assert_eq!(actions::REVERSE, "reverse"); + assert_eq!(actions::READ, "read"); +} + +#[test] +fn labels_are_concrete_gts_types() { + // Stronger than a suffix match: every authz label must parse as a + // structurally valid GTS id AND be a concrete TYPE id (type ids end `~`). + for label in labels::ALL { + assert!( + ::gts::GtsID::new(label).is_ok(), + "label {label} is not a structurally valid GTS id" + ); + assert!( + label.ends_with('~'), + "label {label} must be a concrete type id" + ); + } +} + +#[test] +fn label_schemas_cover_every_label() { + let schemas = authz_label_type_schemas(); + assert_eq!(schemas.len(), labels::ALL.len()); + for schema in &schemas { + let id = schema["$id"].as_str().expect("$id string"); + let label = id.strip_prefix("gts://").expect("$id is gts:// prefixed"); + assert!( + labels::ALL.contains(&label), + "schema $id {id} does not map to a known label" + ); + assert_eq!(schema["type"], "object"); + } +} + +/// Degraded flat-`In` PDP fake: permits and emits a single flat +/// `In([allowed])` constraint over `OWNER_TENANT_ID` — the shape the +/// production PDP returns for a PEP that advertises no tenant-subtree +/// capability (this gear, [`PolicyEnforcer::new`] with no `with_capabilities`). +/// The request is ignored: the fake models a subject authorized only for the +/// single `allowed` tenant. +struct FlatInResolver { + allowed: Uuid, +} + +#[async_trait] +impl AuthZResolverClient for FlatInResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::In(InPredicate::new( + pep_properties::OWNER_TENANT_ID, + vec![self.allowed], + ))], + }], + deny_reason: None, + }, + }) + } +} + +/// A degraded-mode enforcer (no `with_capabilities`) over a subject authorized +/// for `allowed` only — mirrors the gear's production PEP wiring. +fn flat_in_enforcer(allowed: Uuid) -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(FlatInResolver { allowed })) +} + +fn ctx_for(tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::now_v7()) + .subject_tenant_id(tenant) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// A write gate (`require_constraints = true` + a target `owner_tenant_id`) +/// must DENY when the target tenant is outside the PDP's compiled scope, and +/// ALLOW when it is inside. This pins the cross-tenant-write hole: the degraded +/// flat-`In` decision does not re-validate `owner_tenant_id` at the PDP, so the +/// gate itself must assert target membership. +#[tokio::test] +async fn write_gate_denies_target_outside_authorized_scope() { + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + let enforcer = flat_in_enforcer(tenant_a); // authorized for tenant_a only + let ctx = ctx_for(tenant_a); + + // Cross-tenant write: target B is outside the authorized In([A]) -> Denied. + let denied = access_scope( + &enforcer, + &ctx, + &resource_types::ENTRY, + actions::POST, + Some(tenant_b), + None, + true, + ) + .await; + assert!( + matches!(denied, Err(AuthzError::Denied(_))), + "posting into tenant B with scope In([A]) must be denied, got {denied:?}" + ); + + // In-scope write: target A is inside the authorized scope -> allowed, and + // the returned scope carries the In([A]) filter for SQL-level binding. + let allowed = access_scope( + &enforcer, + &ctx, + &resource_types::ENTRY, + actions::POST, + Some(tenant_a), + None, + true, + ) + .await + .expect("posting into own tenant A must be allowed"); + assert!( + allowed.contains_uuid(pep_properties::OWNER_TENANT_ID, tenant_a), + "the granted scope must carry the tenant-A filter" + ); +} + +/// The `reverse` action gates exactly like `post` (a write on `entry`): a +/// cross-tenant target is denied and an in-scope target is allowed with the +/// `In([A])` filter. Pins that reversal authority routes through the same +/// degraded flat-`In` write gate as original posting. +#[tokio::test] +async fn reverse_gate_matches_post_write_semantics() { + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + let enforcer = flat_in_enforcer(tenant_a); // authorized for tenant_a only + let ctx = ctx_for(tenant_a); + + let denied = access_scope( + &enforcer, + &ctx, + &resource_types::ENTRY, + actions::REVERSE, + Some(tenant_b), + None, + true, + ) + .await; + assert!( + matches!(denied, Err(AuthzError::Denied(_))), + "reversing into tenant B with scope In([A]) must be denied, got {denied:?}" + ); + + let allowed = access_scope( + &enforcer, + &ctx, + &resource_types::ENTRY, + actions::REVERSE, + Some(tenant_a), + None, + true, + ) + .await + .expect("reversing within own tenant A must be allowed"); + assert!( + allowed.contains_uuid(pep_properties::OWNER_TENANT_ID, tenant_a), + "the granted scope must carry the tenant-A filter" + ); +} + +/// PDP fake that always fails to evaluate (models an unreachable PDP). +struct FailingResolver; + +#[async_trait] +impl AuthZResolverClient for FailingResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Err(AuthZResolverError::Internal("pdp unreachable".to_owned())) + } +} + +/// PDP fake that explicitly denies (`decision = false`). +struct DenyingResolver; + +#[async_trait] +impl AuthZResolverClient for DenyingResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext { + constraints: vec![], + deny_reason: None, + }, + }) + } +} + +/// An unreachable PDP must fail closed as `Unavailable` (→ 503), NOT `Denied` +/// (→ 403): the two carry different operator semantics and retry behaviour. +#[tokio::test] +async fn pdp_evaluation_failure_maps_to_unavailable() { + let enforcer = PolicyEnforcer::new(Arc::new(FailingResolver)); + let ctx = ctx_for(Uuid::now_v7()); + let res = access_scope( + &enforcer, + &ctx, + &resource_types::LEDGER, + actions::PROVISION, + Some(Uuid::now_v7()), + None, + true, + ) + .await; + assert!( + matches!(res, Err(AuthzError::Unavailable(_))), + "an unreachable PDP must fail closed as Unavailable, got {res:?}" + ); +} + +/// An explicit PDP deny maps to `Denied` (→ 403). +#[tokio::test] +async fn pdp_decision_false_maps_to_denied() { + let enforcer = PolicyEnforcer::new(Arc::new(DenyingResolver)); + let ctx = ctx_for(Uuid::now_v7()); + let res = access_scope( + &enforcer, + &ctx, + &resource_types::LEDGER, + actions::PROVISION, + Some(Uuid::now_v7()), + None, + true, + ) + .await; + assert!( + matches!(res, Err(AuthzError::Denied(_))), + "an explicit PDP deny must map to Denied, got {res:?}" + ); +} + +/// A read (`owner_tenant_id = None`) skips the write-membership assertion and +/// returns the PDP's compiled `In([tenant])` scope verbatim for SQL binding. +#[tokio::test] +async fn read_path_returns_pdp_scope_without_membership_check() { + let tenant = Uuid::now_v7(); + let enforcer = flat_in_enforcer(tenant); + let ctx = ctx_for(tenant); + let scope = access_scope( + &enforcer, + &ctx, + &resource_types::LEDGER, + actions::READ, + None, + None, + true, + ) + .await + .expect("read must be allowed"); + assert!( + scope.contains_uuid(pep_properties::OWNER_TENANT_ID, tenant), + "the read scope must carry the tenant filter" + ); +} + +/// The `(payment, write)` gate enforces the same cross-tenant write semantics as +/// `(entry, post)`: a target tenant outside the caller's compiled scope is +/// denied, an in-scope target is allowed with the `In([A])` filter for SQL +/// binding. Pins that settle / allocate cannot write into a foreign tenant's +/// ledger. +#[tokio::test] +async fn payment_write_gate_denies_target_outside_authorized_scope() { + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + let enforcer = flat_in_enforcer(tenant_a); // authorized for tenant_a only + let ctx = ctx_for(tenant_a); + + let denied = access_scope( + &enforcer, + &ctx, + &resource_types::PAYMENT, + actions::WRITE, + Some(tenant_b), + None, + true, + ) + .await; + assert!( + matches!(denied, Err(AuthzError::Denied(_))), + "settling/allocating into tenant B with scope In([A]) must be denied, got {denied:?}" + ); + + let allowed = access_scope( + &enforcer, + &ctx, + &resource_types::PAYMENT, + actions::WRITE, + Some(tenant_a), + None, + true, + ) + .await + .expect("payment write within own tenant A must be allowed"); + assert!( + allowed.contains_uuid(pep_properties::OWNER_TENANT_ID, tenant_a), + "the granted scope must carry the tenant-A filter" + ); +} + +/// A `(payment, read)` gate (`owner_tenant_id = None`) returns the PDP's compiled +/// `In([tenant])` scope verbatim — the SQL-level BOLA filter the allocation / +/// unallocated reads bind, so a foreign payment/payer resolves to empty. +#[tokio::test] +async fn payment_read_returns_pdp_scope_for_sql_filter() { + let tenant = Uuid::now_v7(); + let enforcer = flat_in_enforcer(tenant); + let ctx = ctx_for(tenant); + let scope = access_scope( + &enforcer, + &ctx, + &resource_types::PAYMENT, + actions::READ, + None, + None, + true, + ) + .await + .expect("payment read must be allowed"); + assert!( + scope.contains_uuid(pep_properties::OWNER_TENANT_ID, tenant), + "the payment read scope must carry the tenant filter" + ); +} diff --git a/gears/bss/ledger/ledger/src/config.rs b/gears/bss/ledger/ledger/src/config.rs new file mode 100644 index 000000000..5af254c10 --- /dev/null +++ b/gears/bss/ledger/ledger/src/config.rs @@ -0,0 +1,511 @@ +//! Gear configuration. The `database.server` reference selects the +//! Postgres connection; `search_path` is set on that connection's +//! `params` in `config/server.yaml` (see Task 3 notes). + +use std::time::Duration; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct BssLedgerConfig { + /// Background-job cadences (tie-out, period-open). Defaults to daily. + #[serde(default)] + pub jobs: JobsConfig, + /// ASC 606 revenue-recognition tunables (Slice 4): the per-schedule segment + /// ceiling the `ScheduleBuilder` enforces and the recognition-run cadence. + #[serde(default)] + pub recognition: RecognitionConfig, + /// FX & multi-currency tunables (Slice 5): rate-sync cadence, staleness + /// thresholds, the deterministic provider order, and the Mode-B unrealized + /// revaluation gate + cadence. + #[serde(default)] + pub fx: FxConfig, + /// Reconciliation & period-close tunables (Slice 7 Phase 3): the + /// `ReconciliationJob` cadence, the AR↔derived rounding tolerance, the + /// manifest / bill-run close-enforcement gates (default OFF), and the + /// close-drain lock timeout. + #[serde(default)] + pub recon: ReconConfig, + /// Payments & allocation tunables (Slice 3): the per-allocation + /// touched-invoice ceiling. + #[serde(default)] + pub payments: PaymentsConfig, + /// Chained GTS tenant-type ids whose tenants own a billing ledger + /// ("sellers"). Provisioning rejects a target whose type is not in this set + /// (the §4.12 seller predicate, owned by the ledger — NOT an AM tenant-type + /// trait, since GTS mandates closed trait schemas). Defaults to + /// partner + platform; organization (buyer/leaf) is excluded. + #[serde(default = "default_seller_tenant_types")] + pub seller_tenant_types: Vec, + /// Register event-type schemas + build producers. Default OFF: bss-ledger is + /// the platform's first event producer and the GTS event-type model is + /// incomplete (`event.v1~<...>` schemas fail types-registry ready-commit — + /// the schema doc validates as an INSTANCE of the event base, not a derived + /// type). Re-enable once event-broker models event types correctly. + #[serde(default)] + pub events_enabled: bool, +} + +impl Default for BssLedgerConfig { + fn default() -> Self { + Self { + jobs: JobsConfig::default(), + recognition: RecognitionConfig::default(), + fx: FxConfig::default(), + recon: ReconConfig::default(), + payments: PaymentsConfig::default(), + seller_tenant_types: default_seller_tenant_types(), + events_enabled: false, + } + } +} + +/// Default seller (ledger-owner) tenant types: partner + platform. +fn default_seller_tenant_types() -> Vec { + vec![ + "gts.cf.core.am.tenant_type.v1~vz.ams.tenants.partner.v1~".to_owned(), + "gts.cf.core.am.tenant_type.v1~vz.ams.tenants.platform.v1~".to_owned(), + ] +} + +/// Tick cadences for the gear's `RunnableCapability` background jobs. +/// +/// Both default to once per day. The cadence only approximates the +/// fiscal-period boundary (no `chrono-tz`); the jobs themselves are +/// idempotent, so a coarse tick is harmless. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +#[allow( + clippy::struct_field_names, + reason = "the *_tick_secs suffix is the config-key naming convention" +)] +pub struct JobsConfig { + /// Seconds between tie-out (self-reconciliation) ticks. + pub tie_out_tick_secs: u64, + /// Seconds between fiscal-period-open ticks. + pub period_open_tick_secs: u64, + /// Seconds between queued-allocation sweep ticks (the deferred-apply + /// backstop, §4.7). Defaults to every 5 minutes — far tighter than the daily + /// fiscal jobs, because a queued allocation should apply promptly once its + /// settlement lands (drain-on-settle covers the common case; this sweep is the + /// backstop, so a few minutes is the worst-case apply latency). + pub queue_applier_tick_secs: u64, + /// Seconds between aged-alarm ticks (the §6 `Warn`-severity scan for queued + /// work / parked unallocated cash that has aged past a threshold). Defaults to + /// hourly — tighter than the daily fiscal jobs so genuinely stuck work surfaces + /// within an hour of crossing the (currently 24h) age threshold, but not as hot + /// as the queue sweep (an aged item is, by definition, not time-critical). + pub aged_alarm_tick_secs: u64, + /// Seconds between chain-verifier ticks (re-walk every tenant's + /// tamper-evidence hash chain). Defaults to once per day. + pub verify_tick_secs: u64, + /// How often the daily tie-out runs the FULL all-time fold as a drift + /// backstop instead of the incremental (baseline + open-period) path + /// (VHP-1843): every `N`th tick folds full, the rest go incremental. `0` (or + /// `1`) means every tick folds full — the pre-VHP-1843 behaviour. The first + /// tick after startup always folds full. Defaults to `7` (≈ weekly at the + /// daily cadence) — closed periods are immutable, so the incremental path is + /// authoritative and the full fold is paranoia against baseline drift. + pub tieout_full_every_n: u64, +} + +impl Default for JobsConfig { + fn default() -> Self { + Self { + tie_out_tick_secs: 86_400, + period_open_tick_secs: 86_400, + queue_applier_tick_secs: 300, + aged_alarm_tick_secs: 3_600, + verify_tick_secs: 86_400, + tieout_full_every_n: 7, + } + } +} + +/// A bss-ledger configuration validation failure (boot-time): a field violated +/// its constraint (a zero tick cadence, an out-of-bound staleness window, …). +/// Carries the field + the constraint so `init()` fails loud with a precise, +/// matchable error rather than an opaque string. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ConfigError { + /// A field that must be strictly positive was `0`. + #[error("config: {field} must be > 0")] + MustBePositive { field: &'static str }, + /// A field exceeded its allowed maximum. + #[error("config: {field} must be <= {max} ({reason})")] + AboveMax { + field: &'static str, + max: u64, + reason: &'static str, + }, +} + +impl JobsConfig { + /// Validate the tick cadences. + /// + /// # Errors + /// Returns `Err` if either cadence is `0`: `tokio::time::interval` + /// panics on `Duration::ZERO`, so a zero tick would abort the serve + /// task at runtime instead of failing loudly at `init()`. + pub fn validate(&self) -> Result<(), ConfigError> { + if self.tie_out_tick_secs == 0 { + return Err(ConfigError::MustBePositive { + field: "jobs.tie_out_tick_secs", + }); + } + if self.period_open_tick_secs == 0 { + return Err(ConfigError::MustBePositive { + field: "jobs.period_open_tick_secs", + }); + } + if self.queue_applier_tick_secs == 0 { + return Err(ConfigError::MustBePositive { + field: "jobs.queue_applier_tick_secs", + }); + } + if self.aged_alarm_tick_secs == 0 { + return Err(ConfigError::MustBePositive { + field: "jobs.aged_alarm_tick_secs", + }); + } + if self.verify_tick_secs == 0 { + return Err(ConfigError::MustBePositive { + field: "jobs.verify_tick_secs", + }); + } + Ok(()) + } + + /// Tie-out tick cadence as a [`Duration`]. + #[must_use] + pub fn tie_out_interval(&self) -> Duration { + Duration::from_secs(self.tie_out_tick_secs) + } + + /// Period-open tick cadence as a [`Duration`]. + #[must_use] + pub fn period_open_interval(&self) -> Duration { + Duration::from_secs(self.period_open_tick_secs) + } + + /// Queued-allocation sweep tick cadence as a [`Duration`]. + #[must_use] + pub fn queue_applier_interval(&self) -> Duration { + Duration::from_secs(self.queue_applier_tick_secs) + } + + /// Aged-alarm tick cadence as a [`Duration`]. + #[must_use] + pub fn aged_alarm_interval(&self) -> Duration { + Duration::from_secs(self.aged_alarm_tick_secs) + } + + /// Chain-verifier tick cadence as a [`Duration`]. + #[must_use] + pub fn verify_interval(&self) -> Duration { + Duration::from_secs(self.verify_tick_secs) + } +} + +/// ASC 606 revenue-recognition tunables (Slice 4, design §3.7 / §4.2). +/// +/// `max_segments_per_schedule` is the **deployment** ceiling the pure +/// `ScheduleBuilder` enforces before materialization (decision 3 / E-8): a +/// straight-line schedule whose derived segment count exceeds it is blocked +/// (`DomainError::ScheduleTooLong`) rather than degraded — degrade (coarser / +/// chunked auto-segmentation) and a per-tenant ceiling are deferred (VHP-1853). +/// `recognition_run_tick_secs` is the cadence of the Phase 2 `RecognitionRunJob` +/// ticker (mirrors the `JobsConfig` `*_tick_secs` knobs). +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct RecognitionConfig { + /// Maximum `recognition_segment` rows one schedule may carry. The + /// `ScheduleBuilder` blocks (no degrade in v1) above this. Default `120` + /// (design E-8 guardrail); a deployment may raise/lower it, but it must + /// stay `> 0` (an empty schedule is meaningless). + pub max_segments_per_schedule: usize, + /// Seconds between recognition-run ticks (the Phase 2 release job). Defaults + /// to every 5 minutes — far tighter than the daily fiscal jobs so a due + /// period's segments release promptly once the period opens. + pub recognition_run_tick_secs: u64, +} + +impl Default for RecognitionConfig { + fn default() -> Self { + Self { + max_segments_per_schedule: 120, + recognition_run_tick_secs: 300, + } + } +} + +impl RecognitionConfig { + /// Validate the recognition tunables. + /// + /// # Errors + /// Returns `Err` if `max_segments_per_schedule` is `0` (a schedule with no + /// segments cannot exist, so the guard would reject every schedule) or if + /// `recognition_run_tick_secs` is `0` (`tokio::time::interval` panics on + /// `Duration::ZERO`, aborting the serve task at runtime rather than failing + /// loudly at `init()`). + pub fn validate(&self) -> Result<(), ConfigError> { + if self.max_segments_per_schedule == 0 { + return Err(ConfigError::MustBePositive { + field: "recognition.max_segments_per_schedule", + }); + } + if self.recognition_run_tick_secs == 0 { + return Err(ConfigError::MustBePositive { + field: "recognition.recognition_run_tick_secs", + }); + } + Ok(()) + } + + /// Recognition-run tick cadence as a [`Duration`]. + #[must_use] + pub fn recognition_run_interval(&self) -> Duration { + Duration::from_secs(self.recognition_run_tick_secs) + } +} + +/// FX & multi-currency tunables (Slice 5, design §4.5 / §4.6 / §13 F2-F4). +/// +/// `revaluation_enabled` is the FLEET DEFAULT for the Mode-B unrealized +/// revaluation run, applied to a tenant WITHOUT an explicit `fx_revaluation_mode` +/// row (off by default — fail-safe: a Mode-A tenant whose ERP revalues must NOT +/// double-count). Per-tenant Mode A/B resolution is the `fx_revaluation_mode` +/// config (VHP-1986); an explicit row OVERRIDES this flag. Staleness +/// follows F3: G10 currencies stale past `stale_g10_hours` (24h), others past a +/// tenant policy bounded by `stale_default_max_days` (≤ 7 days — a config above 7 +/// is REJECTED at `validate`, no silent clamp). `provider_order` is the +/// deterministic fallback order `RateSource` resolves over the local store; empty +/// until an adapter is configured (cross-currency posts then block fail-safe). +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct FxConfig { + /// Mode-B unrealized revaluation gate. Default `false` (fail-safe off). + pub revaluation_enabled: bool, + /// Hours past a snapshot's `as_of` after which a G10-currency rate is stale. + /// Bounded `1..=168` (7 days) at `validate` — a larger window overflows + /// `chrono::Duration::hours` in `rate_source::is_stale` and panics. + pub stale_g10_hours: u64, + /// Upper bound (days) on the per-tenant staleness threshold for non-G10 + /// currencies. A configured threshold above this is rejected at `validate`. + pub stale_default_max_days: u64, + /// Seconds between `RateSyncJob` ticks (pull `fetch_latest` into the store). + pub rate_sync_tick_secs: u64, + /// Seconds between `RevaluationRunJob` ticks (Mode-B period-end remeasure). + pub revaluation_run_tick_secs: u64, + /// Deterministic provider fallback order (by `provider_id`); empty until an + /// adapter is configured. + pub provider_order: Vec, +} + +impl Default for FxConfig { + fn default() -> Self { + Self { + revaluation_enabled: false, + stale_g10_hours: 24, + stale_default_max_days: 7, + rate_sync_tick_secs: 3_600, + revaluation_run_tick_secs: 86_400, + provider_order: Vec::new(), + } + } +} + +/// Upper bound on `stale_g10_hours`: 7 days in hours, mirroring the 7-day cap on +/// `stale_default_max_days`. A larger value would flow to +/// `chrono::Duration::hours` in `rate_source::is_stale` and panic (the staleness +/// window overflows chrono's millisecond representation) — bound it at `validate`. +const MAX_STALE_G10_HOURS: u64 = 168; + +impl FxConfig { + /// Validate the FX tunables. + /// + /// # Errors + /// Returns `Err` if `stale_default_max_days > 7` (F3: a threshold above the + /// 7-day bound must be rejected, never silently clamped), if `stale_g10_hours` + /// exceeds [`MAX_STALE_G10_HOURS`] (a larger window overflows + /// `chrono::Duration::hours` and panics in `rate_source::is_stale`), or if any + /// of `stale_g10_hours` / `rate_sync_tick_secs` / `revaluation_run_tick_secs` + /// is `0` (a zero staleness window admits any rate; a zero tick panics + /// `tokio::time::interval`). + pub fn validate(&self) -> Result<(), ConfigError> { + if self.stale_default_max_days > 7 { + return Err(ConfigError::AboveMax { + field: "fx.stale_default_max_days", + max: 7, + reason: "F3 bound", + }); + } + if self.stale_g10_hours == 0 { + return Err(ConfigError::MustBePositive { + field: "fx.stale_g10_hours", + }); + } + if self.stale_g10_hours > MAX_STALE_G10_HOURS { + return Err(ConfigError::AboveMax { + field: "fx.stale_g10_hours", + max: MAX_STALE_G10_HOURS, + reason: "7-day bound", + }); + } + if self.rate_sync_tick_secs == 0 { + return Err(ConfigError::MustBePositive { + field: "fx.rate_sync_tick_secs", + }); + } + if self.revaluation_run_tick_secs == 0 { + return Err(ConfigError::MustBePositive { + field: "fx.revaluation_run_tick_secs", + }); + } + Ok(()) + } + + /// Rate-sync tick cadence as a [`Duration`]. + #[must_use] + pub fn rate_sync_interval(&self) -> Duration { + Duration::from_secs(self.rate_sync_tick_secs) + } + + /// Revaluation-run tick cadence as a [`Duration`]. + #[must_use] + pub fn revaluation_run_interval(&self) -> Duration { + Duration::from_secs(self.revaluation_run_tick_secs) + } +} + +/// Reconciliation & period-close tunables (Slice 7 Phase 3, design §4.3 / §4.5). +/// +/// `recon_tick_secs` is the cadence of the `ReconciliationJob` ticker (near-real-time +/// invoice-completeness watermark + the periodic AR/PSP checks). `ar_tolerance_minor_per_k_lines` +/// is the AR↔derived rounding tolerance X4 (≤ N minor units per 1,000 posted lines; statutory +/// floors override — not modelled here). `manifest_enforcement` / `bill_run_enforcement` gate +/// whether the issued-invoice-manifest completeness check and the bill-run-finished assertion +/// BLOCK period close — default OFF (fail-safe) until the launch-blocking cross-team feeds are +/// live (design §0 decision 3 / §4.5 residual risk). `close_lock_timeout_ms` bounds the close +/// drain window under a sustained bill run (design §4.5). +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ReconConfig { + /// Seconds between `ReconciliationJob` ticks. Defaults to every 5 minutes (300) — + /// tighter than the daily fiscal jobs so a missed posting / variance surfaces well + /// before the close window. + pub recon_tick_secs: u64, + /// AR↔derived tie-out tolerance: max minor units of rounding-only variance per 1,000 + /// posted lines (X4). Default `1`. + pub ar_tolerance_minor_per_k_lines: u32, + /// Block period close on an unresolved invoice-completeness gap (`MISSED_POSTING`). + /// Default `false` (the manifest feed is launch-blocking cross-team; inert until live). + pub manifest_enforcement: bool, + /// Block period close until the bill-run-finished control signal is asserted. + /// Default `false` (the signal is launch-blocking cross-team; inert until live). + pub bill_run_enforcement: bool, + /// Upper bound (ms) on the close drain window / lock wait. Default `5000`. + pub close_lock_timeout_ms: u64, +} + +impl Default for ReconConfig { + fn default() -> Self { + Self { + recon_tick_secs: 300, + ar_tolerance_minor_per_k_lines: 1, + manifest_enforcement: false, + bill_run_enforcement: false, + close_lock_timeout_ms: 5_000, + } + } +} + +impl ReconConfig { + /// Validate the recon tunables. + /// + /// # Errors + /// Returns `Err` if `recon_tick_secs` is `0` (`tokio::time::interval` panics on + /// `Duration::ZERO`, aborting the serve task) or if `close_lock_timeout_ms` is `0` + /// (a zero drain window can never let a sustained bill run drain). + pub fn validate(&self) -> Result<(), ConfigError> { + if self.recon_tick_secs == 0 { + return Err(ConfigError::MustBePositive { + field: "recon.recon_tick_secs", + }); + } + if self.close_lock_timeout_ms == 0 { + return Err(ConfigError::MustBePositive { + field: "recon.close_lock_timeout_ms", + }); + } + Ok(()) + } + + /// Reconciliation-tick cadence as a [`Duration`]. + #[must_use] + pub fn recon_tick_interval(&self) -> Duration { + Duration::from_secs(self.recon_tick_secs) + } +} + +/// Hard ceiling on `payments.max_invoices_per_allocation`. An allocation posts +/// one CR `AR` line per touched invoice plus the DR `UNALLOCATED` leg and (on a +/// cross-currency close) a net `FX_GAIN_LOSS` line; the engine caps an entry at +/// 1,000 lines (`LEDGER_ENTRY_TOO_LARGE`), so the touched-invoice count must +/// leave room for those two extra lines. A config above this is rejected (never +/// silently clamped) so a misconfiguration fails loud at `init()` rather than +/// deep in a post. +pub const MAX_INVOICES_PER_ALLOCATION_CEILING: usize = 998; + +/// Payments & allocation tunables (Slice 3, design §Bounds). +/// +/// `max_invoices_per_allocation` bounds the number of invoices ONE allocation +/// may **touch** (the AR legs it posts) — NOT the payer's open-invoice backlog, +/// which is read-only and uncapped. A split touching more than this rejects with +/// `ALLOCATION_TOO_LARGE`. Defaults to `500` (the PM-confirmed working default); +/// a deployment may lower it (tighter transactions) or raise it toward the +/// [`MAX_INVOICES_PER_ALLOCATION_CEILING`], but never past it. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct PaymentsConfig { + /// Max invoices one allocation may touch. `1..=MAX_INVOICES_PER_ALLOCATION_CEILING`. + pub max_invoices_per_allocation: usize, +} + +impl Default for PaymentsConfig { + fn default() -> Self { + Self { + max_invoices_per_allocation: + crate::infra::payment::allocate::MAX_INVOICES_PER_ALLOCATION, + } + } +} + +impl PaymentsConfig { + /// Validate the payments tunables. + /// + /// # Errors + /// Returns `Err` if `max_invoices_per_allocation` is `0` (a zero cap rejects + /// every allocation) or exceeds [`MAX_INVOICES_PER_ALLOCATION_CEILING`] (an + /// allocation entry would then risk the engine's 1,000-line ceiling). + pub fn validate(&self) -> Result<(), ConfigError> { + if self.max_invoices_per_allocation == 0 { + return Err(ConfigError::MustBePositive { + field: "payments.max_invoices_per_allocation", + }); + } + if self.max_invoices_per_allocation > MAX_INVOICES_PER_ALLOCATION_CEILING { + return Err(ConfigError::AboveMax { + field: "payments.max_invoices_per_allocation", + max: MAX_INVOICES_PER_ALLOCATION_CEILING as u64, + reason: "engine 1000-line entry ceiling", + }); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "config_tests.rs"] +mod config_tests; diff --git a/gears/bss/ledger/ledger/src/config_tests.rs b/gears/bss/ledger/ledger/src/config_tests.rs new file mode 100644 index 000000000..156c088a7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/config_tests.rs @@ -0,0 +1,260 @@ +//! Tests for the gear's job-cadence configuration. + +use super::*; + +#[test] +fn default_jobs_config_validates() { + assert!(JobsConfig::default().validate().is_ok()); +} + +#[test] +fn zero_tie_out_tick_rejected() { + let cfg = JobsConfig { + tie_out_tick_secs: 0, + ..JobsConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn zero_period_open_tick_rejected() { + let cfg = JobsConfig { + period_open_tick_secs: 0, + ..JobsConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn zero_aged_alarm_tick_rejected() { + let cfg = JobsConfig { + aged_alarm_tick_secs: 0, + ..JobsConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn zero_verify_tick_rejected() { + let cfg = JobsConfig { + verify_tick_secs: 0, + ..JobsConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn default_tie_out_interval_is_one_day() { + assert_eq!(JobsConfig::default().tie_out_interval().as_secs(), 86_400); +} + +#[test] +fn default_aged_alarm_interval_is_one_hour() { + assert_eq!(JobsConfig::default().aged_alarm_interval().as_secs(), 3_600); +} + +#[test] +fn default_recognition_config_validates() { + assert!(RecognitionConfig::default().validate().is_ok()); +} + +#[test] +fn default_max_segments_is_120() { + assert_eq!(RecognitionConfig::default().max_segments_per_schedule, 120); +} + +#[test] +fn zero_max_segments_rejected() { + let cfg = RecognitionConfig { + max_segments_per_schedule: 0, + ..RecognitionConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn zero_recognition_run_tick_rejected() { + let cfg = RecognitionConfig { + recognition_run_tick_secs: 0, + ..RecognitionConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn default_recognition_run_interval_is_five_minutes() { + assert_eq!( + RecognitionConfig::default() + .recognition_run_interval() + .as_secs(), + 300 + ); +} + +#[test] +fn zero_queue_applier_tick_rejected() { + let cfg = JobsConfig { + queue_applier_tick_secs: 0, + ..JobsConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn default_period_open_interval_is_one_day() { + assert_eq!( + JobsConfig::default().period_open_interval().as_secs(), + 86_400 + ); +} + +#[test] +fn default_queue_applier_interval_is_five_minutes() { + assert_eq!( + JobsConfig::default().queue_applier_interval().as_secs(), + 300 + ); +} + +#[test] +fn default_fx_config_validates() { + assert!(FxConfig::default().validate().is_ok()); +} + +#[test] +fn zero_stale_g10_hours_rejected() { + let cfg = FxConfig { + stale_g10_hours: 0, + ..FxConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +/// Finding 19: `stale_g10_hours` above the 7-day bound (`MAX_STALE_G10_HOURS`) +/// must be rejected at `validate` — `rate_source::is_stale` feeds it to +/// `chrono::Duration::hours`, which panics when the window overflows chrono's +/// millisecond representation. Reject loudly at `init` instead. +#[test] +fn over_max_stale_g10_hours_rejected() { + let cfg = FxConfig { + stale_g10_hours: MAX_STALE_G10_HOURS + 1, + ..FxConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn over_seven_days_stale_default_rejected() { + let cfg = FxConfig { + stale_default_max_days: 8, + ..FxConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn default_recon_config_validates() { + assert!(ReconConfig::default().validate().is_ok()); +} + +#[test] +fn default_recon_config_values() { + let cfg = ReconConfig::default(); + assert_eq!(cfg.recon_tick_secs, 300); + assert_eq!(cfg.ar_tolerance_minor_per_k_lines, 1); + assert!( + !cfg.manifest_enforcement, + "manifest enforcement default OFF" + ); + assert!( + !cfg.bill_run_enforcement, + "bill-run enforcement default OFF" + ); + assert_eq!(cfg.close_lock_timeout_ms, 5_000); +} + +#[test] +fn zero_recon_tick_rejected() { + let cfg = ReconConfig { + recon_tick_secs: 0, + ..ReconConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn zero_close_lock_timeout_rejected() { + let cfg = ReconConfig { + close_lock_timeout_ms: 0, + ..ReconConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn default_recon_tick_interval_is_five_minutes() { + assert_eq!(ReconConfig::default().recon_tick_interval().as_secs(), 300); +} + +#[test] +fn default_config_carries_seller_types_and_events_off() { + let cfg = BssLedgerConfig::default(); + assert!(!cfg.events_enabled, "events default OFF"); + assert_eq!(cfg.seller_tenant_types.len(), 2, "partner + platform"); + assert!(cfg.jobs.validate().is_ok()); + assert!(cfg.recognition.validate().is_ok()); + assert!(cfg.recon.validate().is_ok()); +} + +#[test] +fn default_verify_interval_is_one_day() { + assert_eq!(JobsConfig::default().verify_interval().as_secs(), 86_400); +} + +/// design F-8: the §6 event payloads ship DORMANT — `events_enabled` +/// MUST default to `false` so no broker producer is wired until the platform GTS +/// event-type model lands. A flip to `true` by default would silently activate a +/// surface with no vendored schema and no producer; this locks the deferral. +#[test] +fn events_disabled_by_default_keeps_slice6_events_dormant() { + assert!( + !BssLedgerConfig::default().events_enabled, + "§6 events must stay dormant (events_enabled=false) until a producer is wired (design F-8)" + ); +} + +#[test] +fn default_payments_cap_is_the_working_default() { + assert_eq!( + PaymentsConfig::default().max_invoices_per_allocation, + crate::infra::payment::allocate::MAX_INVOICES_PER_ALLOCATION, + ); + assert!(PaymentsConfig::default().validate().is_ok()); +} + +#[test] +fn zero_allocation_cap_rejected() { + let cfg = PaymentsConfig { + max_invoices_per_allocation: 0, + }; + assert!(matches!( + cfg.validate(), + Err(ConfigError::MustBePositive { .. }) + )); +} + +#[test] +fn allocation_cap_above_ceiling_rejected() { + let cfg = PaymentsConfig { + max_invoices_per_allocation: MAX_INVOICES_PER_ALLOCATION_CEILING + 1, + }; + assert!(matches!(cfg.validate(), Err(ConfigError::AboveMax { .. }))); +} + +#[test] +fn allocation_cap_at_ceiling_accepted() { + let cfg = PaymentsConfig { + max_invoices_per_allocation: MAX_INVOICES_PER_ALLOCATION_CEILING, + }; + assert!(cfg.validate().is_ok()); +} diff --git a/gears/bss/ledger/ledger/src/domain.rs b/gears/bss/ledger/ledger/src/domain.rs new file mode 100644 index 000000000..853b238e2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain.rs @@ -0,0 +1,23 @@ +//! Domain layer: repo-facing value types and errors. + +pub mod adjustment; +pub mod allocate; +pub mod approval; +pub mod audit_chain; +pub(crate) mod canonical; +pub mod chain; +pub mod error; +pub mod exception; +pub mod fx; +pub mod invoice; +pub mod model; +pub mod money; +pub mod money_math; +pub mod payment; +pub mod period; +pub mod ports; +pub mod posting; +pub mod provisioning; +pub mod recognition; +pub mod scale; +pub mod status; diff --git a/gears/bss/ledger/ledger/src/domain/adjustment.rs b/gears/bss/ledger/ledger/src/domain/adjustment.rs new file mode 100644 index 000000000..476a23385 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment.rs @@ -0,0 +1,73 @@ +//! Adjustments domain (Slice 3, design §4.1 / §4.2) — the shared pure domain +//! for credit/debit notes & refunds. Backend-agnostic: no DB / txn / async I/O +//! (the in-txn handlers — `CreditNoteHandler` / `DebitNoteHandler` / `RefundHandler`, +//! Groups C–G — own the durable posting + counter/schedule deltas; this layer +//! only derives the pure decisions they apply). +//! +//! v1 (Phase 1 / Groups B–D) ships: +//! +//! - [`debit_note`] — the pure debit-note request shape +//! ([`debit_note::DebitNoteRequest`]) + the deterministic **direct-split** leg +//! plan ([`debit_note::build_debit_note_legs`], design §4.3): a mirror of the +//! Slice-1 invoice-post split (DR `AR` incl-tax / CR `REVENUE` recognized-now / +//! CR `CONTRACT_LIABILITY` deferred-per-PO / CR `TAX_PAYABLE`). Unlike the credit +//! note it does NOT use the [`splitter`] (a debit note is a fresh charge, not a +//! reduction of an existing obligation); when it defers, the Group D infra +//! [`DebitNoteHandler`](crate::infra::adjustment::debit_note_service) runs the +//! SAME recognition [`ScheduleBuilder`](crate::domain::recognition::builder) +//! path Slice 1 uses to build the releasing schedule in the same atomic txn (D4), +//! and raises the invoice's headroom (`invoice_exposure.debit_note_total_minor +//! += amount`). +//! - [`credit_note`] — the pure credit-note request shape +//! ([`credit_note::CreditNoteRequest`]) + the deterministic compensating-leg +//! plan ([`credit_note::build_credit_note_legs`], design §4.2): the +//! `CONTRA_REVENUE`/`GOODWILL` + per-stream `CONTRACT_LIABILITY` + `TAX_PAYABLE` +//! debits against the open-AR-capped `AR` credit + the `REUSABLE_CREDIT` +//! remainder (K-2). Backed by the Group C infra +//! [`CreditNoteHandler`](crate::infra::adjustment::credit_note_service) which +//! reads the schedule state + open AR, drives the [`splitter`], builds the legs, +//! and posts them atomically with the schedule/headroom/wallet writes. +//! - [`splitter`] — [`splitter::RecognizedDeferredSplitter`], the pure +//! recognized-vs-deferred split of a credit/debit-note ex-tax amount across the +//! targeted obligation's recognition-schedule state (one schedule per revenue +//! stream, Slice 4 §4.5). The deferred part of each stream is bounded by that +//! schedule's remaining releasable amount (`total_deferred_minor − +//! recognized_minor`); the recognized part is the remainder. An indeterminable +//! basis (no item→schedule/stream mapping, an unresolved per-stream split, or a +//! deferred request over the summed releasable) is a **block** +//! ([`crate::domain::error::DomainError::CreditNoteSplitAmbiguous`]) — never a +//! silent pro-rata (design §4.2, PRD L273). The Group C `CreditNoteHandler` +//! reads the [`splitter::SplitResult`] public fields to build the `CONTRA_REVENUE` +//! / `CONTRACT_LIABILITY` leg amounts + the per-stream schedule reductions, and +//! records the deterministic [`splitter::SplitResult::split_basis_ref`] on the +//! `credit_note` row. +//! +//! The splitter is **sync + pure** (mirroring [`crate::domain::recognition`]): it +//! is a function of the already-read schedule state handed in as +//! [`splitter::ScheduleStreamState`] inputs, so there is no async I/O to model and +//! a sync API keeps it callable from the in-txn Group C handler without an +//! executor. The schedule state is READ by the handler (infra) and passed in — the +//! splitter never imports the repo (DE0301 — no infra in domain), exactly as the +//! recognition `ScheduleBuilder` takes its context rather than reading the DB. +//! +//! Phase 2 / Group B adds: +//! +//! - [`refund`] — the pure refund request shape ([`refund::RefundRequest`]) + the +//! deterministic two-leg plan ([`refund::build_refund_legs`], design §4.4): the +//! money-OUT unwind of a settled receipt through the two-stage `REFUND_CLEARING` +//! liability. Pattern A (`A_UNALLOCATED`) draws down the on-account `UNALLOCATED` +//! pool; Pattern B (`B_RESTORE_AR`) re-opens the receivable (`AR`). Stage-1 +//! (`initiated`) CREDITS `REFUND_CLEARING`; stage-2 (`confirmed`) DEBITS it back +//! to `CASH_CLEARING` as the cash leaves (a single-step D1 switch collapses both +//! into one `… · CR CASH_CLEARING` move). A refund NEVER restates revenue and +//! NEVER debits `CONTRACT_LIABILITY` (the unreleased-deferred restatement rides a +//! paired credit note, not the refund). Backed by the Group B infra +//! [`RefundHandler`](crate::infra::adjustment::refund_service) which resolves the +//! origin `payment_settlement` (by `payment_id` + `currency`), routes by `phase`, +//! builds the legs, and posts them atomically with the `refund` row. + +pub mod credit_note; +pub mod debit_note; +pub mod manual; +pub mod refund; +pub mod splitter; diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/credit_note.rs b/gears/bss/ledger/ledger/src/domain/adjustment/credit_note.rs new file mode 100644 index 000000000..90f077645 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/credit_note.rs @@ -0,0 +1,490 @@ +//! Credit-note domain (Slice 3, Phase 1 / Group C1) — the **pure** request shape +//! and the deterministic compensating-leg plan a credit note posts (design §4.2). +//! Backend-agnostic: no DB / txn / async I/O. The infra +//! [`CreditNoteHandler`](crate::infra::adjustment::credit_note_service) reads the +//! schedule state + the invoice's current open AR under the §4.7 lock order, +//! drives the [`RecognizedDeferredSplitter`](super::splitter) for the ex-tax +//! split, then calls [`build_credit_note_legs`] to derive the balanced leg plan +//! (which the handler maps onto posting lines + the per-stream schedule +//! reductions + the headroom/wallet writes). +//! +//! **The leg plan (design §4.2 legs table).** A credit note reduces recognized +//! revenue, the unreleased deferred balance, and the reversed tax against the +//! invoice's open AR (and, for a paid invoice, a reusable-credit remainder): +//! +//! | Line | Side | Account class | +//! |------|------|---------------| +//! | Reduce recognized revenue (ex-tax) | DR | `CONTRA_REVENUE` (or `GOODWILL`, AR-only) | +//! | Reduce unreleased deferred (ex-tax, per stream) | DR | `CONTRACT_LIABILITY` | +//! | Reverse tax | DR | `TAX_PAYABLE` | +//! | Reduce AR (incl. tax) — up to current open AR | CR | `AR` | +//! | Remainder beyond open AR (paid invoice, Rev2 / K-2) | CR | `REUSABLE_CREDIT` | +//! +//! The plan is **balanced by construction** (Σ DR == Σ CR): the debit side is +//! `recognized_ex_tax + deferred_ex_tax + tax` (= the note's incl-tax amount), and +//! the credit side splits that SAME total into `AR` (capped at open AR) + +//! `REUSABLE_CREDIT` (the remainder). [`build_credit_note_legs`] asserts the +//! balance as a domain invariant before returning. +//! +//! **Goodwill / AR-only (D3, §4.2).** A `goodwill` credit reduces no recognized +//! revenue and touches no schedule: its single debit is `GOODWILL` (NOT +//! `CONTRA_REVENUE`) for the whole ex-tax amount. The split must carry no deferred +//! part (a goodwill credit has no obligation to reduce); the handler passes a +//! zero-deferred split for it. The authoritative AR floor for goodwill is the +//! Slice 1 `ar_invoice_balance` NO-negative CHECK (NOT the `invoice_exposure` +//! headroom), so a goodwill credit that would over-reduce AR is rejected by that +//! CHECK in the post — not here. + +use bss_ledger_sdk::{AccountClass, Side}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use super::splitter::SplitResult; +use crate::domain::error::DomainError; +use crate::domain::invoice::builder::TaxBreakdown; + +/// The `credit_grant_event_type` a paid-invoice credit-note remainder accrues to +/// in the reusable-credit wallet (design §4.2 / B-5). Stamped on the +/// `CR REUSABLE_CREDIT` leg so the projector seeds the wallet sub-grain under this +/// bucket; mirrors the `credit_grant_event_type` literal Slice 2 uses for grants. +pub const CREDIT_GRANT_EVENT_TYPE_CREDIT_NOTE: &str = "CREDIT_NOTE"; + +/// One credit-note request — the pure inputs the handler resolves from the REST +/// DTO (Group E) before reading any ledger state. Amounts are `i64` minor units; +/// `amount_minor` is **incl-tax** (the design's note amount), `tax_minor` is the +/// reversed tax slice of it, and `requested_deferred_minor` is how much of the +/// ex-tax revenue portion targets the unreleased deferred balance (the rest +/// reduces recognized revenue). The ex-tax revenue amount the splitter divides is +/// `amount_minor − tax_minor`. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreditNoteRequest { + /// The seller tenant whose ledger this posts into. + pub tenant_id: Uuid, + /// The tenant the original invoice billed (the AR / wallet owner). + pub payer_tenant_id: Uuid, + /// The business id of this credit note — the `(tenant, CREDIT_NOTE, + /// credit_note_id)` idempotency key + the `credit_note` row PK. + pub credit_note_id: String, + /// The originating posted invoice (`NOTE_INVOICE_NOT_FOUND` if absent, + /// enforced by the handler). The credit note never mutates its rows. + pub origin_invoice_id: String, + /// The targeted posted invoice-item ref (the line being credited) — anchors + /// the recognized/deferred split + the `split_basis_ref`. `None` for an + /// invoice-level (no specific item) credit. + pub origin_invoice_item_ref: Option, + /// The PO / allocation group the targeted line books under (the split-basis + /// dimension, §4.2). `None` for a line with no allocation group. + pub po_allocation_group: Option, + /// The revenue stream the credit books against (the `CONTRA_REVENUE` / + /// `CONTRACT_LIABILITY` legs carry it; per-stream classes need it). + pub revenue_stream: String, + /// ISO-4217 currency of the note (all legs share it). + pub currency: String, + /// The note amount **incl-tax**, in minor units (`>= 0`). + pub amount_minor: i64, + /// The tax slice of `amount_minor` to reverse onto `TAX_PAYABLE` (`>= 0`, + /// `<= amount_minor`). The ex-tax revenue amount is `amount_minor − tax_minor`. + pub tax_minor: i64, + /// The **authoritative** tax breakdown (computed by the tax engine for the + /// *original* invoice's tax-date — the caller's concern; the gear only routes + /// the dims, never recomputes, §4.5). Each component reverses onto its OWN + /// `TAX_PAYABLE` leg carrying its `(jurisdiction, filing-period, rate)` dims so + /// the projector disaggregates `tax_subbalance` per `(jurisdiction, filing)`. + /// REQUIRED when `tax_minor > 0` — `validate_shape` rejects a bare `tax_minor` + /// (a dimensionless `TAX_PAYABLE` leg has no (jurisdiction, filing) and the + /// schema rejects it, `chk_journal_line_tax_dims`). `tax_minor` remains the + /// authoritative split scalar (`amount_minor_ex_tax = amount − tax_minor`); the + /// breakdown MUST sum to it (`validate_shape`). + pub tax: Vec, + /// How much of the ex-tax revenue amount targets the **unreleased deferred** + /// balance (`0 <= requested_deferred_minor <= amount_minor − tax_minor`). The + /// remainder reduces recognized revenue. MUST be 0 when `goodwill` is set. + pub requested_deferred_minor: i64, + /// The mandatory business reason code (AC #14) recorded on the `credit_note` + /// row. + pub reason_code: String, + /// `true` ⇒ an AR-only **goodwill** credit (D3): the ex-tax debit is + /// `GOODWILL`, no recognized-revenue reduction and no schedule reduction. + pub goodwill: bool, +} + +impl CreditNoteRequest { + /// The ex-tax revenue amount the splitter divides into recognized vs deferred + /// parts — `amount_minor − tax_minor`, the note amount net of the reversed + /// tax. Saturating at 0 (a malformed `tax > amount` is rejected up-front by + /// [`validate_shape`]; this never underflows once validated). + #[must_use] + pub fn amount_minor_ex_tax(&self) -> i64 { + self.amount_minor.saturating_sub(self.tax_minor).max(0) + } +} + +/// One planned compensating leg of a credit-note entry — a pure description the +/// handler maps onto a posting line (binding the chart `account_id` + scale). +/// `revenue_stream` is `Some` for the per-stream classes (`CONTRA_REVENUE`, +/// `CONTRACT_LIABILITY`) and `None` for the stream-less classes (`AR`, +/// `TAX_PAYABLE`, `GOODWILL`, `REUSABLE_CREDIT`); `credit_grant_event_type` is +/// `Some` only on the `CR REUSABLE_CREDIT` wallet remainder leg. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PlannedLeg { + /// The account class this leg posts to. + pub account_class: AccountClass, + /// DR / CR. + pub side: Side, + /// The leg amount in minor units (`> 0`; zero-amount legs are never emitted — + /// inherited S1 / AC #4 rejects a zero placeholder line). + pub amount_minor: i64, + /// The revenue stream (per-stream classes only); `None` for stream-less. + pub revenue_stream: Option, + /// The owning `recognition_schedule` id this leg's deferred reduction targets + /// — `Some` only on a per-stream `DR CONTRACT_LIABILITY` leg, so the handler + /// reduces the right schedule. `None` on every other leg. + pub schedule_id: Option, + /// The wallet bucket — `Some(CREDIT_NOTE)` only on the `CR REUSABLE_CREDIT` + /// remainder leg (the projector seeds the wallet sub-grain under it); `None` + /// elsewhere. + pub credit_grant_event_type: Option, + /// Tax dims (per-(jurisdiction, filing-period, rate) disaggregation, design §4.5). + /// `Some` only on a `TAX_PAYABLE` leg built from a `TaxBreakdown`; `None` on every + /// other leg and on the legacy single dimensionless tax leg. + pub tax_jurisdiction: Option, + pub tax_filing_period: Option, + pub tax_rate_ref: Option, +} + +/// The full balanced leg plan for one credit note: the legs to post plus the +/// `split_basis_ref` to stamp on the `credit_note` row and the wallet remainder +/// amount (the `CR REUSABLE_CREDIT` slice, `0` for a fully-open-AR invoice). Pure +/// data — the handler posts the legs, persists the row, and seeds the headroom / +/// schedule / wallet writes from these fields. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreditNoteLegPlan { + /// The balanced legs (Σ DR == Σ CR), in a deterministic order: the debit legs + /// (`CONTRA_REVENUE`/`GOODWILL`, then per-stream `CONTRACT_LIABILITY`, then + /// `TAX_PAYABLE`) followed by the credit legs (`AR`, then `REUSABLE_CREDIT`). + pub legs: Vec, + /// The total ex-tax amount reducing recognized revenue (the + /// `CONTRA_REVENUE`/`GOODWILL` debit) — mirrors + /// [`SplitResult::recognized_part_minor`] (or the whole ex-tax amount for a + /// goodwill credit). Recorded on the `credit_note` row. + pub recognized_part_minor: i64, + /// The total ex-tax amount reducing the unreleased deferred balance (Σ + /// per-stream `CONTRACT_LIABILITY` debit) — mirrors + /// [`SplitResult::deferred_part_minor`] (`0` for goodwill). Recorded on the + /// `credit_note` row. + pub deferred_part_minor: i64, + /// The amount credited to `AR` (incl. tax), capped at the invoice's current + /// open AR. + pub ar_credit_minor: i64, + /// The remainder credited to `REUSABLE_CREDIT` (the paid-invoice wallet seed, + /// K-2) — `note amount − ar_credit_minor`, `0` when open AR fully absorbs the + /// note. + pub wallet_remainder_minor: i64, + /// The deterministic split-basis description to stamp on the `credit_note` row + /// (from the splitter; a synthetic one for a goodwill credit that runs no + /// split). + pub split_basis_ref: String, +} + +/// Validate a credit-note request's amounts + the goodwill shape (design §4.2). +/// Pure shape checks the splitter does not own (it sees only the ex-tax revenue +/// amount): a negative amount/tax, a tax over the note amount, a deferred request +/// over the ex-tax revenue amount, an empty reason code, or a goodwill credit that +/// carries a deferred part (a goodwill credit reduces no obligation, C4). +/// +/// # Errors +/// [`DomainError::AmountOutOfRange`] for a malformed amount/tax/deferred; +/// [`DomainError::InvalidRequest`] for an empty reason code or a goodwill credit +/// with a non-zero deferred part. +pub fn validate_shape(req: &CreditNoteRequest) -> Result<(), DomainError> { + if req.amount_minor < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "credit-note amount_minor must be >= 0, got {}", + req.amount_minor + ))); + } + if req.tax_minor < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "credit-note tax_minor must be >= 0, got {}", + req.tax_minor + ))); + } + if req.tax_minor > req.amount_minor { + return Err(DomainError::AmountOutOfRange(format!( + "credit-note tax_minor {} exceeds amount_minor {}", + req.tax_minor, req.amount_minor + ))); + } + // Tax must carry a dimensioned breakdown: a TAX_PAYABLE journal line requires + // (jurisdiction, filing_period) (chk_journal_line_tax_dims), so a bare + // `tax_minor` with no breakdown can only build a dimensionless leg the schema + // rejects at insert — reject it up front as a clean 400, not a late DB fault. + if req.tax_minor > 0 && req.tax.is_empty() { + return Err(DomainError::InvalidRequest(format!( + "credit-note tax_minor {} requires a tax breakdown carrying \ + (jurisdiction, filing_period); a bare tax amount is not bookable", + req.tax_minor + ))); + } + // A non-empty tax breakdown carries the per-component dims; `tax_minor` stays the + // authoritative split scalar, so the components MUST sum to it (Σ tax-legs == + // tax_minor keeps the plan balanced) and each MUST share the note currency + // (every leg posts in `req.currency`). + if !req.tax.is_empty() { + let breakdown_sum: i64 = req.tax.iter().map(|t| t.amount_minor).sum(); + if breakdown_sum != req.tax_minor { + return Err(DomainError::AmountOutOfRange(format!( + "credit-note tax breakdown sum {breakdown_sum} != tax_minor {}", + req.tax_minor + ))); + } + if let Some(bad) = req.tax.iter().find(|t| t.currency != req.currency) { + return Err(DomainError::AmountOutOfRange(format!( + "credit-note tax breakdown currency {} != note currency {}", + bad.currency, req.currency + ))); + } + } + if req.requested_deferred_minor < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "credit-note requested_deferred_minor must be >= 0, got {}", + req.requested_deferred_minor + ))); + } + if req.requested_deferred_minor > req.amount_minor_ex_tax() { + return Err(DomainError::AmountOutOfRange(format!( + "credit-note requested_deferred_minor {} exceeds the ex-tax amount {}", + req.requested_deferred_minor, + req.amount_minor_ex_tax() + ))); + } + if req.reason_code.trim().is_empty() { + return Err(DomainError::InvalidRequest( + "credit note requires a non-empty reason_code (AC #14)".to_owned(), + )); + } + // C4: a goodwill (AR-only) credit reduces no recognized revenue and touches no + // schedule, so it must carry no deferred part. (The handler also passes an + // empty schedule-state set for a goodwill credit, so the splitter would block + // a deferred request anyway — this is the clean up-front 400.) + if req.goodwill && req.requested_deferred_minor != 0 { + return Err(DomainError::InvalidRequest(format!( + "goodwill credit note must not target a deferred part (got {})", + req.requested_deferred_minor + ))); + } + Ok(()) +} + +/// Build the balanced compensating-leg plan for a credit note (design §4.2). Pure +/// — no DB / txn. The caller supplies the [`SplitResult`] (from the splitter, the +/// recognized-vs-deferred ex-tax division across the obligation's per-stream +/// schedule state) and `open_ar_minor` (the invoice's current open AR incl. tax, +/// read by the handler under the lock order). Produces: +/// +/// - DR `CONTRA_REVENUE` = `split.recognized_part_minor` (ex-tax) — OR DR +/// `GOODWILL` for the whole ex-tax amount when `req.goodwill` (C4: no revenue +/// reduction, no schedule touch); +/// - one DR `CONTRACT_LIABILITY` per stream with a deferred part (`> 0`), carrying +/// that stream's `schedule_id` so the handler reduces the right schedule; +/// - DR `TAX_PAYABLE` = `req.tax_minor` (when `> 0`); +/// - CR `AR` = `min(note amount, open_ar_minor)` (when `> 0`); +/// - CR `REUSABLE_CREDIT` = the remainder beyond open AR (when `> 0`, K-2), +/// stamped `credit_grant_event_type = CREDIT_NOTE`. +/// +/// The plan is balanced by construction (Σ DR == note amount == Σ CR), asserted +/// before returning. Zero-amount legs are omitted (inherited S1 / AC #4). +/// +/// # Errors +/// [`DomainError::Internal`] if the supplied split does not net to the request's +/// ex-tax amount, or if `open_ar_minor` is negative — both invariant breaches the +/// handler's reads should never produce (the assertions guard against a silent +/// unbalanced post). +pub fn build_credit_note_legs( + req: &CreditNoteRequest, + split: &SplitResult, + open_ar_minor: i64, +) -> Result { + if open_ar_minor < 0 { + return Err(DomainError::Internal(format!( + "credit-note open AR must be >= 0, got {open_ar_minor}" + ))); + } + let ex_tax = req.amount_minor_ex_tax(); + // The split is over the ex-tax revenue amount: recognized + deferred == ex_tax. + // A mismatch means the handler fed the splitter a different amount than the + // request's ex-tax — an invariant breach we refuse rather than post unbalanced. + let split_total = split + .recognized_part_minor + .saturating_add(split.deferred_part_minor); + if split_total != ex_tax { + return Err(DomainError::Internal(format!( + "credit-note split parts ({} recognized + {} deferred) do not net to the ex-tax \ + amount {ex_tax}", + split.recognized_part_minor, split.deferred_part_minor + ))); + } + + let stream = req.revenue_stream.clone(); + let mut legs: Vec = Vec::new(); + + // --- Debit side --- + let recognized_part = split.recognized_part_minor; + if req.goodwill { + // C4 — AR-only goodwill: the whole ex-tax amount debits GOODWILL (never + // CONTRA_REVENUE), no per-stream deferred reduction. `validate_shape` + // already guaranteed `deferred == 0`, so `ex_tax == recognized_part`. + if ex_tax > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::Goodwill, + side: Side::Debit, + amount_minor: ex_tax, + revenue_stream: None, + schedule_id: None, + credit_grant_event_type: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + }); + } + } else { + // Reduce recognized revenue via CONTRA_REVENUE (debit-normal; NOT REVENUE + // directly, design §4.2). Per-stream class ⇒ carries the stream. + if recognized_part > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::ContraRevenue, + side: Side::Debit, + amount_minor: recognized_part, + revenue_stream: Some(stream.clone()), + schedule_id: None, + credit_grant_event_type: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + }); + } + // Reduce the unreleased deferred balance per stream — one DR + // CONTRACT_LIABILITY per stream that took a deferred part, carrying that + // stream's schedule_id so the handler reduces the right schedule (§4.5). + for ps in &split.per_stream { + if ps.deferred_part_minor > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::ContractLiability, + side: Side::Debit, + amount_minor: ps.deferred_part_minor, + revenue_stream: Some(ps.revenue_stream.clone()), + schedule_id: Some(ps.schedule_id.clone()), + credit_grant_event_type: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + }); + } + } + } + + // Reverse tax onto TAX_PAYABLE (stream-less). Carries posted tax evidence + // upstream (never recomputed here, §4.5). Emit ONE DR TAX_PAYABLE per breakdown + // component carrying its (jurisdiction, filing-period, rate) dims so the + // projector disaggregates `tax_subbalance` per (jurisdiction, filing). A taxed + // note MUST carry a breakdown (`validate_shape` rejects a bare `tax_minor`): a + // dimensionless TAX_PAYABLE line carries no (jurisdiction, filing) and the + // schema rejects it (chk_journal_line_tax_dims). Σ of the per-component legs == + // tax_minor (validated), so the plan still balances. + if !req.tax.is_empty() { + for t in &req.tax { + if t.amount_minor > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::TaxPayable, + side: Side::Debit, + amount_minor: t.amount_minor, + revenue_stream: None, + schedule_id: None, + credit_grant_event_type: None, + tax_jurisdiction: Some(t.tax_jurisdiction.clone()), + tax_filing_period: Some(t.tax_filing_period.clone()), + tax_rate_ref: t.tax_rate_ref.clone(), + }); + } + } + } + + // --- Credit side: open-AR cap then wallet remainder (K-2) --- + let ar_credit = req.amount_minor.min(open_ar_minor).max(0); + let wallet_remainder = req.amount_minor.saturating_sub(ar_credit).max(0); + // Goodwill is AR-only relief (design D3): it may only reduce the open receivable, + // never mint spendable wallet credit. An amount beyond open AR — or ANY amount on a + // fully-paid invoice (open_ar == 0) — has no receivable to relieve, so reject rather + // than convert a goodwill gesture into a cash-equivalent REUSABLE_CREDIT grant. + if req.goodwill && wallet_remainder > 0 { + return Err(DomainError::InvalidRequest(format!( + "goodwill credit note {} ({} minor) exceeds the invoice's open AR ({open_ar_minor} minor): \ + goodwill is AR-only and cannot mint reusable credit", + req.credit_note_id, req.amount_minor + ))); + } + if ar_credit > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::Ar, + side: Side::Credit, + amount_minor: ar_credit, + revenue_stream: None, + schedule_id: None, + credit_grant_event_type: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + }); + } + if wallet_remainder > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::ReusableCredit, + side: Side::Credit, + amount_minor: wallet_remainder, + revenue_stream: None, + schedule_id: None, + credit_grant_event_type: Some(CREDIT_GRANT_EVENT_TYPE_CREDIT_NOTE.to_owned()), + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + }); + } + + // Balance invariant (Σ DR == Σ CR). The debit side is recognized_ex_tax + + // deferred_ex_tax + tax == ex_tax + tax == amount_minor; the credit side is + // ar_credit + wallet_remainder == amount_minor. A zero-amount note emits no + // legs (both sides 0) — a benign no-op the handler still records, but the post + // engine rejects an empty entry, so the handler guards zero up-front. + let dr: i64 = legs + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount_minor) + .sum(); + let cr: i64 = legs + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| l.amount_minor) + .sum(); + debug_assert_eq!(dr, cr, "credit-note leg plan must balance"); + if dr != cr { + return Err(DomainError::Internal(format!( + "credit-note leg plan does not balance (DR {dr} != CR {cr})" + ))); + } + + Ok(CreditNoteLegPlan { + legs, + recognized_part_minor: recognized_part, + deferred_part_minor: split.deferred_part_minor, + ar_credit_minor: ar_credit, + wallet_remainder_minor: wallet_remainder, + split_basis_ref: split.split_basis_ref.clone(), + }) +} + +#[cfg(test)] +#[path = "credit_note_tests.rs"] +mod credit_note_tests; diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/credit_note_tests.rs b/gears/bss/ledger/ledger/src/domain/adjustment/credit_note_tests.rs new file mode 100644 index 000000000..09879dc2a --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/credit_note_tests.rs @@ -0,0 +1,352 @@ +//! Tests for the pure credit-note leg plan (`build_credit_note_legs`) + the +//! request shape gate (`validate_shape`): contra-vs-goodwill debit selection, +//! with/without a deferred part (per-stream `CONTRACT_LIABILITY` + `schedule_id`), +//! tax reversal, the open-AR cap, the paid-invoice wallet remainder (K-2), and the +//! balance invariant. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use bss_ledger_sdk::{AccountClass, Side}; +use uuid::Uuid; + +use super::*; +use crate::domain::adjustment::splitter::{SplitResult, StreamSplit}; +use crate::domain::error::DomainError; +use crate::domain::invoice::builder::TaxBreakdown; + +/// A baseline non-goodwill request: incl-tax `amount_minor`, `tax_minor`, +/// `requested_deferred_minor`, one revenue stream. +fn req(amount_minor: i64, tax_minor: i64, requested_deferred_minor: i64) -> CreditNoteRequest { + CreditNoteRequest { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + credit_note_id: "cn-1".to_owned(), + origin_invoice_id: "inv-1".to_owned(), + origin_invoice_item_ref: Some("item-1".to_owned()), + po_allocation_group: Some("po-1".to_owned()), + revenue_stream: "SAAS".to_owned(), + currency: "USD".to_owned(), + amount_minor, + tax_minor, + // A taxed note must carry a breakdown (validate_shape rejects a bare + // tax_minor); synthesize a single-component breakdown when tax_minor > 0. + tax: if tax_minor > 0 { + vec![tax_component(tax_minor, "US-CA", "2026Q2")] + } else { + Vec::new() + }, + requested_deferred_minor, + reason_code: "CUSTOMER_GOODWILL".to_owned(), + goodwill: false, + } +} + +/// A split result over `ex_tax`, with `recognized`/`deferred` parts placed on a +/// single stream (the common single-obligation case). +fn split_single(stream: &str, schedule_id: &str, recognized: i64, deferred: i64) -> SplitResult { + SplitResult { + recognized_part_minor: recognized, + deferred_part_minor: deferred, + per_stream: vec![StreamSplit { + revenue_stream: stream.to_owned(), + schedule_id: schedule_id.to_owned(), + recognized_part_minor: recognized, + deferred_part_minor: deferred, + }], + split_basis_ref: "item=item-1;po=po-1;streams=[SAAS:sch-1@v1:def=...]".to_owned(), + } +} + +/// A wholly-recognized split with no streams (a fully point-in-time line). +fn split_recognized_no_streams(recognized: i64) -> SplitResult { + SplitResult { + recognized_part_minor: recognized, + deferred_part_minor: 0, + per_stream: vec![], + split_basis_ref: "item=item-1;po=po-1;streams=none".to_owned(), + } +} + +fn leg(plan: &CreditNoteLegPlan, class: AccountClass, side: Side) -> Option<&PlannedLeg> { + plan.legs + .iter() + .find(|l| l.account_class == class && l.side == side) +} + +fn assert_balanced(plan: &CreditNoteLegPlan) { + let dr: i64 = plan + .legs + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount_minor) + .sum(); + let cr: i64 = plan + .legs + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| l.amount_minor) + .sum(); + assert_eq!(dr, cr, "plan must balance: {plan:?}"); + // No zero-amount legs (inherited S1 / AC #4). + assert!(plan.legs.iter().all(|l| l.amount_minor > 0), "no zero legs"); +} + +#[test] +fn contra_only_fully_recognized_open_ar_covers() { + // 1000 incl 100 tax ⇒ 900 ex-tax, all recognized. Open AR covers the full 1000. + let r = req(1000, 100, 0); + let split = split_recognized_no_streams(900); + let plan = build_credit_note_legs(&r, &split, 1000).unwrap(); + assert_balanced(&plan); + + let contra = leg(&plan, AccountClass::ContraRevenue, Side::Debit).expect("contra leg"); + assert_eq!(contra.amount_minor, 900); + assert_eq!(contra.revenue_stream.as_deref(), Some("SAAS")); + assert!(leg(&plan, AccountClass::ContractLiability, Side::Debit).is_none()); + + let tax = leg(&plan, AccountClass::TaxPayable, Side::Debit).expect("tax leg"); + assert_eq!(tax.amount_minor, 100); + + let ar = leg(&plan, AccountClass::Ar, Side::Credit).expect("ar leg"); + assert_eq!(ar.amount_minor, 1000); + assert!(leg(&plan, AccountClass::ReusableCredit, Side::Credit).is_none()); + assert_eq!(plan.wallet_remainder_minor, 0); + assert_eq!(plan.recognized_part_minor, 900); + assert_eq!(plan.deferred_part_minor, 0); +} + +#[test] +fn contra_plus_deferred_carries_schedule_id_per_stream() { + // 900 ex-tax: 400 recognized + 500 deferred on stream SAAS / schedule sch-9. + let r = req(900, 0, 500); + let split = split_single("SAAS", "sch-9", 400, 500); + let plan = build_credit_note_legs(&r, &split, 900).unwrap(); + assert_balanced(&plan); + + let contra = leg(&plan, AccountClass::ContraRevenue, Side::Debit).expect("contra"); + assert_eq!(contra.amount_minor, 400); + + let cl = leg(&plan, AccountClass::ContractLiability, Side::Debit).expect("cl"); + assert_eq!(cl.amount_minor, 500); + assert_eq!(cl.revenue_stream.as_deref(), Some("SAAS")); + // The CL leg carries the owning schedule_id so the handler reduces the right + // schedule (§4.5) — this is the load-bearing wiring for the schedule reduction. + assert_eq!(cl.schedule_id.as_deref(), Some("sch-9")); + + assert_eq!(plan.deferred_part_minor, 500); +} + +#[test] +fn fully_deferred_emits_no_contra_leg() { + let r = req(700, 0, 700); + let split = split_single("SAAS", "sch-2", 0, 700); + let plan = build_credit_note_legs(&r, &split, 700).unwrap(); + assert_balanced(&plan); + assert!(leg(&plan, AccountClass::ContraRevenue, Side::Debit).is_none()); + let cl = leg(&plan, AccountClass::ContractLiability, Side::Debit).expect("cl"); + assert_eq!(cl.amount_minor, 700); +} + +#[test] +fn paid_invoice_remainder_goes_to_reusable_credit_wallet() { + // Note 1000 incl 100 tax; the invoice has only 300 open AR (mostly paid). The + // 700 remainder seeds the reusable-credit wallet (K-2). + let r = req(1000, 100, 0); + let split = split_recognized_no_streams(900); + let plan = build_credit_note_legs(&r, &split, 300).unwrap(); + assert_balanced(&plan); + + let ar = leg(&plan, AccountClass::Ar, Side::Credit).expect("ar"); + assert_eq!(ar.amount_minor, 300); + let wallet = leg(&plan, AccountClass::ReusableCredit, Side::Credit).expect("wallet"); + assert_eq!(wallet.amount_minor, 700); + assert_eq!( + wallet.credit_grant_event_type.as_deref(), + Some(CREDIT_GRANT_EVENT_TYPE_CREDIT_NOTE) + ); + assert_eq!(plan.wallet_remainder_minor, 700); + assert_eq!(plan.ar_credit_minor, 300); +} + +#[test] +fn fully_paid_invoice_whole_note_goes_to_wallet() { + // Zero open AR ⇒ no AR leg; the whole note seeds the wallet. + let r = req(500, 0, 0); + let split = split_recognized_no_streams(500); + let plan = build_credit_note_legs(&r, &split, 0).unwrap(); + assert_balanced(&plan); + assert!(leg(&plan, AccountClass::Ar, Side::Credit).is_none()); + let wallet = leg(&plan, AccountClass::ReusableCredit, Side::Credit).expect("wallet"); + assert_eq!(wallet.amount_minor, 500); +} + +#[test] +fn goodwill_uses_goodwill_class_not_contra_and_no_schedule() { + // C4: goodwill ⇒ DR GOODWILL for the full ex-tax, no CONTRA_REVENUE, no CL. + let mut r = req(400, 0, 0); + r.goodwill = true; + let split = split_recognized_no_streams(400); + let plan = build_credit_note_legs(&r, &split, 400).unwrap(); + assert_balanced(&plan); + + let goodwill = leg(&plan, AccountClass::Goodwill, Side::Debit).expect("goodwill"); + assert_eq!(goodwill.amount_minor, 400); + assert!(goodwill.revenue_stream.is_none()); + assert!(goodwill.schedule_id.is_none()); + assert!(leg(&plan, AccountClass::ContraRevenue, Side::Debit).is_none()); + assert!(leg(&plan, AccountClass::ContractLiability, Side::Debit).is_none()); +} + +#[test] +fn validate_shape_rejects_tax_over_amount() { + let r = req(100, 200, 0); + assert!(matches!( + validate_shape(&r), + Err(DomainError::AmountOutOfRange(_)) + )); +} + +#[test] +fn validate_shape_rejects_deferred_over_ex_tax() { + // 100 incl 50 tax ⇒ 50 ex-tax; a 60 deferred request is out of range. + let r = req(100, 50, 60); + assert!(matches!( + validate_shape(&r), + Err(DomainError::AmountOutOfRange(_)) + )); +} + +#[test] +fn validate_shape_rejects_goodwill_with_deferred() { + let mut r = req(400, 0, 100); + r.goodwill = true; + assert!(matches!( + validate_shape(&r), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn validate_shape_rejects_empty_reason_code() { + let mut r = req(100, 0, 0); + r.reason_code = " ".to_owned(); + assert!(matches!( + validate_shape(&r), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn build_rejects_split_not_matching_ex_tax() { + // Defensive invariant: a split that does not net to ex_tax is an Internal + // breach (the handler must feed the splitter the request's ex-tax amount). + let r = req(1000, 100, 0); // ex_tax = 900 + let split = split_recognized_no_streams(800); // wrong total + assert!(matches!( + build_credit_note_legs(&r, &split, 1000), + Err(DomainError::Internal(_)) + )); +} + +#[test] +fn build_rejects_negative_open_ar() { + let r = req(100, 0, 0); + let split = split_recognized_no_streams(100); + assert!(matches!( + build_credit_note_legs(&r, &split, -1), + Err(DomainError::Internal(_)) + )); +} + +/// One tax component (the authoritative breakdown the caller carries, §4.5). +fn tax_component(amount_minor: i64, jurisdiction: &str, filing: &str) -> TaxBreakdown { + TaxBreakdown { + amount_minor, + currency: "USD".to_owned(), + tax_jurisdiction: jurisdiction.to_owned(), + tax_filing_period: filing.to_owned(), + tax_rate_ref: Some(format!("rate:{jurisdiction}")), + } +} + +#[test] +fn tax_breakdown_emits_per_component_tax_legs() { + // §4.5: a credit note carrying an authoritative breakdown of two components + // (different jurisdiction + filing) reverses ONE DR TAX_PAYABLE per component, + // each stamped with its own dims so the projector disaggregates `tax_subbalance` + // per (jurisdiction, filing). `tax_minor` (the split scalar) == Σ components. + let mut r = req(1000, 150, 0); // ex_tax = 850, tax = 150 = 100 + 50 + r.tax = vec![ + tax_component(100, "US-CA", "2026Q2"), + tax_component(50, "US-NY", "2026Q2"), + ]; + let split = split_recognized_no_streams(850); + let plan = build_credit_note_legs(&r, &split, 1000).unwrap(); + assert_balanced(&plan); + + let tax_legs: Vec<&PlannedLeg> = plan + .legs + .iter() + .filter(|l| l.account_class == AccountClass::TaxPayable && l.side == Side::Debit) + .collect(); + assert_eq!(tax_legs.len(), 2, "one TAX_PAYABLE leg per component"); + // Σ of the per-component tax legs == tax_minor (the plan stays balanced). + let tax_sum: i64 = tax_legs.iter().map(|l| l.amount_minor).sum(); + assert_eq!(tax_sum, 150, "Σ tax legs == tax_minor"); + + let ca = tax_legs + .iter() + .find(|l| l.tax_jurisdiction.as_deref() == Some("US-CA")) + .expect("US-CA tax leg"); + assert_eq!(ca.amount_minor, 100); + assert_eq!(ca.tax_filing_period.as_deref(), Some("2026Q2")); + assert_eq!(ca.tax_rate_ref.as_deref(), Some("rate:US-CA")); + + let ny = tax_legs + .iter() + .find(|l| l.tax_jurisdiction.as_deref() == Some("US-NY")) + .expect("US-NY tax leg"); + assert_eq!(ny.amount_minor, 50); + assert_eq!(ny.tax_filing_period.as_deref(), Some("2026Q2")); +} + +#[test] +fn bare_tax_minor_without_breakdown_is_rejected() { + // A taxed note MUST carry a dimensioned breakdown: a bare `tax_minor` with no + // breakdown could only build a dimensionless TAX_PAYABLE leg the schema rejects + // (chk_journal_line_tax_dims), so validate_shape blocks it up front (400). + // The req helper auto-synthesizes a breakdown when tax_minor > 0; clear it to + // force the bare-tax_minor case under test. + let mut r = req(1000, 100, 0); + r.tax = Vec::new(); + assert!(r.tax.is_empty()); + assert!( + matches!(validate_shape(&r), Err(DomainError::InvalidRequest(_))), + "a bare tax_minor with no breakdown must be rejected" + ); +} + +#[test] +fn validate_shape_rejects_tax_breakdown_not_summing_to_tax_minor() { + // A non-empty breakdown is bound to `tax_minor` (the split scalar): a sum that + // disagrees is a 400 (AMOUNT_OUT_OF_RANGE), never a silent unbalanced post. + let mut r = req(1000, 150, 0); + r.tax = vec![tax_component(100, "US-CA", "2026Q2")]; // sums to 100, not 150 + assert!(matches!( + validate_shape(&r), + Err(DomainError::AmountOutOfRange(_)) + )); +} + +#[test] +fn validate_shape_rejects_tax_breakdown_currency_mismatch() { + // Every leg posts in the note currency; a component in another currency is a 400. + let mut r = req(1000, 100, 0); + let mut wrong = tax_component(100, "US-CA", "2026Q2"); + wrong.currency = "EUR".to_owned(); + r.tax = vec![wrong]; + assert!(matches!( + validate_shape(&r), + Err(DomainError::AmountOutOfRange(_)) + )); +} diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/debit_note.rs b/gears/bss/ledger/ledger/src/domain/adjustment/debit_note.rs new file mode 100644 index 000000000..f886866eb --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/debit_note.rs @@ -0,0 +1,395 @@ +//! Debit-note domain (Slice 3, Phase 1 / Group D1) — the **pure** request shape +//! and the deterministic **direct-split** leg plan a debit note posts (design +//! §4.3). A debit note is an *additional charge* against an already-posted +//! invoice; unlike the credit note (a compensating reduction driven by the +//! [`RecognizedDeferredSplitter`](super::splitter)), it **mirrors the Slice-1 +//! invoice-post direct split** — it books fresh AR / Revenue / Contract-liability +//! / Tax exactly as a new invoice line would, and (when it defers) triggers the +//! Slice 4 `ScheduleBuilder` in the same atomic unit (D4). Backend-agnostic: no +//! DB / txn / async I/O. The infra +//! [`DebitNoteHandler`](crate::infra::adjustment::debit_note_service) derives the +//! deferred split + the schedule plan, calls [`build_debit_note_legs`], then posts +//! the legs atomically with the schedule-build + headroom writes. +//! +//! **The leg plan (design §4.3 legs table — a mirror of S1 invoice-post).** +//! +//! | Line | Side | Account class | +//! |------|------|---------------| +//! | Additional AR (incl. tax) | DR | `AR` | +//! | Revenue recognized at post (ex-tax) | CR | `REVENUE` | +//! | Contract liability deferred per PO (ex-tax, if any) | CR | `CONTRACT_LIABILITY` | +//! | Tax | CR | `TAX_PAYABLE` | +//! +//! The plan is **balanced by construction** (`DR AR == CR REVENUE + CR +//! CONTRACT_LIABILITY + CR TAX_PAYABLE`): the single AR debit is the incl-tax +//! amount, and the credit side splits it into recognized revenue +//! (`ex_tax − deferred`), the deferred Contract-liability (`deferred`, the part the +//! schedule will release), and the reversed-evidence tax (`tax`). The ex-tax total +//! is `amount_minor − tax_minor`; `deferred_minor` is how much of THAT ex-tax goes +//! to `CONTRACT_LIABILITY` (the rest recognizes now). [`build_debit_note_legs`] +//! asserts the balance as a domain invariant before returning. +//! +//! **No zero-placeholder lines (inherited S1 / AC #4).** A fully-recognized debit +//! note (`deferred_minor == 0`) emits NO `CONTRACT_LIABILITY` line (byte-identical +//! to the S1 direct split for a non-deferred item); a zero recognized-now part +//! (`deferred_minor == ex_tax`) emits NO `REVENUE` line; a zero tax emits no +//! `TAX_PAYABLE` line. + +use bss_ledger_sdk::{AccountClass, Side}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::invoice::builder::TaxBreakdown; +use crate::domain::recognition::input::RecognitionInput; + +/// One debit-note request — the pure inputs the handler resolves from the REST +/// DTO (Group E) before posting. Amounts are `i64` minor units; `amount_minor` is +/// **incl-tax** (the design's note amount), `tax_minor` is the tax slice of it +/// (carried posted tax evidence, never recomputed, §4.3), and `deferred_minor` is +/// how much of the ex-tax revenue portion (`amount_minor − tax_minor`) is deferred +/// to `CONTRACT_LIABILITY` per the line's PO (the rest recognizes now). When +/// `deferred_minor > 0` the [`Self::recognition`] spec drives the schedule build +/// (D4) — the SAME `ScheduleBuilder` path Slice 1's invoice-post uses. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +// The `*_ref` / `*_id` fields mirror the storage / SDK column names verbatim; +// renaming to satisfy `struct_field_names` would diverge from `NewDebitNote` / +// the journal-line contract. +#[allow(clippy::struct_field_names)] +pub struct DebitNoteRequest { + /// The seller tenant whose ledger this posts into. + pub tenant_id: Uuid, + /// The tenant the original invoice billed (the AR owner the charge lands on). + pub payer_tenant_id: Uuid, + /// The business id of this debit note — the `(tenant, DEBIT_NOTE, + /// debit_note_id)` idempotency key + the `debit_note` row PK. + pub debit_note_id: String, + /// The originating posted invoice (`NOTE_INVOICE_NOT_FOUND` if absent, + /// enforced by the handler in Group E). The debit note never mutates its rows; + /// it raises that invoice's headroom (`debit_note_total_minor += amount`). + pub origin_invoice_id: String, + /// The targeted posted invoice-item ref — anchors the freshly-built + /// `recognition_schedule`'s NOT-NULL `source_invoice_item_ref` when the note + /// defers (§4.7). Required (non-empty) by the handler for a deferred note; a + /// fully-recognized note may carry it for lineage but does not require it. + pub origin_invoice_item_ref: Option, + /// The revenue stream the charge books against (the `REVENUE` / + /// `CONTRACT_LIABILITY` legs carry it; per-stream classes need it). + pub revenue_stream: String, + /// ISO-4217 currency of the note (all legs share it). + pub currency: String, + /// The note amount **incl-tax**, in minor units (`>= 0`) — the single DR AR. + pub amount_minor: i64, + /// The tax slice of `amount_minor` posted onto `TAX_PAYABLE` (`>= 0`, + /// `<= amount_minor`). Posted tax evidence — never recomputed here (§4.3). The + /// ex-tax revenue amount is `amount_minor − tax_minor`. + pub tax_minor: i64, + /// The **authoritative** tax breakdown (computed by the tax engine for the + /// *original* invoice's tax-date — the caller's concern; the gear only routes + /// the dims, never recomputes, §4.5). Each component posts onto its OWN + /// `TAX_PAYABLE` leg carrying its `(jurisdiction, filing-period, rate)` dims so + /// the projector disaggregates `tax_subbalance` per `(jurisdiction, filing)`. + /// REQUIRED when `tax_minor > 0` — `validate_shape` rejects a bare `tax_minor` + /// (a dimensionless `TAX_PAYABLE` leg has no (jurisdiction, filing) and the + /// schema rejects it, `chk_journal_line_tax_dims`). `tax_minor` remains the + /// authoritative split scalar (`amount_minor_ex_tax = amount − tax_minor`); the + /// breakdown MUST sum to it (`validate_shape`). + pub tax: Vec, + /// How much of the ex-tax revenue amount is **deferred** to + /// `CONTRACT_LIABILITY` per the line's PO (`0 <= deferred_minor <= amount_minor + /// − tax_minor`). The remainder (`ex_tax − deferred_minor`) recognizes now to + /// `REVENUE`. `0` ⇒ fully recognized, NO `CONTRACT_LIABILITY` line + no schedule + /// build (byte-identical to the S1 direct split for a non-deferred line). + pub deferred_minor: i64, + /// The mandatory business reason / context code (AC #14 — "MUST link business + /// context", §4.3) recorded for audit. Non-empty. + pub reason_code: String, + /// The optional per-item ASC 606 recognition spec (Slice 4) — the SAME shape + /// Slice 1's invoice-post item carries. REQUIRED to be `Some` when + /// `deferred_minor > 0` (the handler runs it through the recognition + /// [`ScheduleBuilder`](crate::domain::recognition::builder::ScheduleBuilder) to + /// build the schedule that releases the deferred Contract-liability, D4). `None` + /// for a fully-recognized note. + pub recognition: Option, +} + +impl DebitNoteRequest { + /// The ex-tax revenue amount — `amount_minor − tax_minor`, the note amount net + /// of the posted tax. Saturating at 0 (a malformed `tax > amount` is rejected + /// up-front by [`validate_shape`]; this never underflows once validated). + #[must_use] + pub fn amount_minor_ex_tax(&self) -> i64 { + self.amount_minor.saturating_sub(self.tax_minor).max(0) + } + + /// The recognized-now ex-tax amount — `ex_tax − deferred_minor` (the part the + /// `CR REVENUE` leg books). Saturating at 0 (validated so `deferred <= ex_tax`). + #[must_use] + pub fn recognized_minor(&self) -> i64 { + self.amount_minor_ex_tax() + .saturating_sub(self.deferred_minor) + .max(0) + } +} + +/// One planned leg of a debit-note direct-split entry — a pure description the +/// handler maps onto a posting line (binding the chart `account_id` + scale). +/// `revenue_stream` is `Some` for the per-stream classes (`REVENUE`, +/// `CONTRACT_LIABILITY`) and `None` for the stream-less classes (`AR`, +/// `TAX_PAYABLE`). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PlannedLeg { + /// The account class this leg posts to. + pub account_class: AccountClass, + /// DR / CR. + pub side: Side, + /// The leg amount in minor units (`> 0`; zero-amount legs are never emitted — + /// inherited S1 / AC #4 rejects a zero placeholder line). + pub amount_minor: i64, + /// The revenue stream (per-stream classes only); `None` for stream-less. + pub revenue_stream: Option, + /// Tax dims (per-(jurisdiction, filing-period, rate) disaggregation, design §4.5). + /// `Some` only on a `TAX_PAYABLE` leg built from a `TaxBreakdown`; `None` on + /// every other leg. + pub tax_jurisdiction: Option, + pub tax_filing_period: Option, + pub tax_rate_ref: Option, +} + +/// The full balanced direct-split leg plan for one debit note: the legs to post +/// plus the recognized / deferred ex-tax parts to record on the `debit_note` row. +/// Pure data — the handler posts the legs and persists the row from these fields; +/// the schedule build + headroom bump ride the handler's sidecar (not described +/// here — they key off `deferred_part_minor` / `amount_minor`). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DebitNoteLegPlan { + /// The balanced legs (Σ DR == Σ CR), in a deterministic order: DR `AR`, then + /// CR `REVENUE`, then CR `CONTRACT_LIABILITY`, then CR `TAX_PAYABLE`. + pub legs: Vec, + /// The ex-tax amount recognized now to `REVENUE` (`= ex_tax − deferred`). + /// Recorded on the `debit_note` row. + pub recognized_part_minor: i64, + /// The ex-tax amount deferred to `CONTRACT_LIABILITY` (`= deferred_minor`). + /// Recorded on the `debit_note` row; the handler builds the schedule for it. + pub deferred_part_minor: i64, +} + +/// Validate a debit-note request's amounts + the deferral/recognition shape +/// (design §4.3). Pure shape checks: a negative amount/tax/deferred, a tax over the +/// note amount, a deferred part over the ex-tax revenue amount, an empty reason +/// code, or a deferred note that carries no recognition spec (a deferred line MUST +/// carry the spec the schedule build derives from — the S1 invoice-item-link rule, +/// §4.7 / D4). +/// +/// # Errors +/// [`DomainError::AmountOutOfRange`] for a malformed amount/tax/deferred; +/// [`DomainError::InvalidRequest`] for an empty reason code or a deferred note that +/// is missing its recognition spec. +pub fn validate_shape(req: &DebitNoteRequest) -> Result<(), DomainError> { + if req.amount_minor < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "debit-note amount_minor must be >= 0, got {}", + req.amount_minor + ))); + } + if req.tax_minor < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "debit-note tax_minor must be >= 0, got {}", + req.tax_minor + ))); + } + if req.tax_minor > req.amount_minor { + return Err(DomainError::AmountOutOfRange(format!( + "debit-note tax_minor {} exceeds amount_minor {}", + req.tax_minor, req.amount_minor + ))); + } + // Tax must carry a dimensioned breakdown: a TAX_PAYABLE journal line requires + // (jurisdiction, filing_period) (chk_journal_line_tax_dims), so a bare + // `tax_minor` with no breakdown can only build a dimensionless leg the schema + // rejects at insert — reject it up front as a clean 400, not a late DB fault. + if req.tax_minor > 0 && req.tax.is_empty() { + return Err(DomainError::InvalidRequest(format!( + "debit-note tax_minor {} requires a tax breakdown carrying \ + (jurisdiction, filing_period); a bare tax amount is not bookable", + req.tax_minor + ))); + } + // A non-empty tax breakdown carries the per-component dims; `tax_minor` stays the + // authoritative split scalar, so the components MUST sum to it (Σ tax-legs == + // tax_minor keeps the plan balanced) and each MUST share the note currency + // (every leg posts in `req.currency`). + if !req.tax.is_empty() { + let breakdown_sum: i64 = req.tax.iter().map(|t| t.amount_minor).sum(); + if breakdown_sum != req.tax_minor { + return Err(DomainError::AmountOutOfRange(format!( + "debit-note tax breakdown sum {breakdown_sum} != tax_minor {}", + req.tax_minor + ))); + } + if let Some(bad) = req.tax.iter().find(|t| t.currency != req.currency) { + return Err(DomainError::AmountOutOfRange(format!( + "debit-note tax breakdown currency {} != note currency {}", + bad.currency, req.currency + ))); + } + } + if req.deferred_minor < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "debit-note deferred_minor must be >= 0, got {}", + req.deferred_minor + ))); + } + if req.deferred_minor > req.amount_minor_ex_tax() { + return Err(DomainError::AmountOutOfRange(format!( + "debit-note deferred_minor {} exceeds the ex-tax amount {}", + req.deferred_minor, + req.amount_minor_ex_tax() + ))); + } + if req.reason_code.trim().is_empty() { + return Err(DomainError::InvalidRequest( + "debit note requires a non-empty reason_code / business context (AC #14)".to_owned(), + )); + } + // D4 / §4.7: a deferring note MUST carry the recognition spec the schedule + // build derives from (no deferred Contract-liability balance without a schedule + // — the S1 rule). A fully-recognized note (`deferred == 0`) needs none. + if req.deferred_minor > 0 && req.recognition.is_none() { + return Err(DomainError::InvalidRequest( + "deferred debit note must carry a recognition spec to build its schedule (D4)" + .to_owned(), + )); + } + Ok(()) +} + +/// Build the balanced direct-split leg plan for a debit note (design §4.3) — a +/// mirror of [`build_invoice_entry`](crate::domain::invoice::builder::build_invoice_entry)'s +/// per-item split, for a single charge line. Pure — no DB / txn. Produces: +/// +/// - DR `AR` = `req.amount_minor` (incl. tax) — the single additional receivable; +/// - CR `REVENUE` = `ex_tax − deferred` (the recognized-now part), carrying the +/// stream — emitted only when `> 0`; +/// - CR `CONTRACT_LIABILITY` = `deferred` (the per-PO deferred part), carrying the +/// stream — emitted only when `> 0` (NO zero-placeholder line); +/// - CR `TAX_PAYABLE` = `req.tax_minor` (the posted tax evidence) — emitted only +/// when `> 0`. +/// +/// The plan is balanced by construction (`DR AR == CR REVENUE + CR +/// CONTRACT_LIABILITY + CR TAX_PAYABLE == amount_minor`), asserted before +/// returning. Zero-amount legs are omitted (inherited S1 / AC #4). +/// +/// # Errors +/// [`DomainError::Internal`] if the plan does not balance — an invariant breach +/// that should be impossible once [`validate_shape`] has passed (the assertion +/// guards against a silent unbalanced post). +pub fn build_debit_note_legs(req: &DebitNoteRequest) -> Result { + let ex_tax = req.amount_minor_ex_tax(); + let deferred = req.deferred_minor.clamp(0, ex_tax); + let recognized = ex_tax - deferred; + let stream = req.revenue_stream.clone(); + + let mut legs: Vec = Vec::with_capacity(4); + + // --- Debit side: the single additional AR (incl. tax) --- + if req.amount_minor > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::Ar, + side: Side::Debit, + amount_minor: req.amount_minor, + revenue_stream: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + }); + } + + // --- Credit side: recognized REVENUE + deferred CONTRACT_LIABILITY + TAX --- + // CR REVENUE — the recognized-now ex-tax part (per-stream class ⇒ carries the + // stream). Omitted when the whole ex-tax amount defers (a lone CL credit then + // balances the AR/tax debit, the S1 fully-deferred shape). + if recognized > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::Revenue, + side: Side::Credit, + amount_minor: recognized, + revenue_stream: Some(stream.clone()), + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + }); + } + // CR CONTRACT_LIABILITY — the deferred per-PO part (per-stream class). NO + // zero-placeholder line: a fully-recognized note (`deferred == 0`) emits none, + // byte-identical to the S1 direct split for a non-deferred item. + if deferred > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::ContractLiability, + side: Side::Credit, + amount_minor: deferred, + revenue_stream: Some(stream), + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + }); + } + // CR TAX_PAYABLE — the posted tax evidence (stream-less, never recomputed, + // §4.3). Emit ONE CR TAX_PAYABLE per breakdown component carrying its + // (jurisdiction, filing-period, rate) dims so the projector disaggregates + // `tax_subbalance` per (jurisdiction, filing). A taxed note MUST carry a + // breakdown (`validate_shape` rejects a bare `tax_minor`): a dimensionless + // TAX_PAYABLE line carries no (jurisdiction, filing) and the schema rejects it + // (chk_journal_line_tax_dims). Σ of the per-component legs == tax_minor + // (validated), so the plan still balances. + if !req.tax.is_empty() { + for t in &req.tax { + if t.amount_minor > 0 { + legs.push(PlannedLeg { + account_class: AccountClass::TaxPayable, + side: Side::Credit, + amount_minor: t.amount_minor, + revenue_stream: None, + tax_jurisdiction: Some(t.tax_jurisdiction.clone()), + tax_filing_period: Some(t.tax_filing_period.clone()), + tax_rate_ref: t.tax_rate_ref.clone(), + }); + } + } + } + + // Balance invariant (Σ DR == Σ CR). DR side is `amount_minor` (the AR); + // CR side is `recognized + deferred + tax == ex_tax + tax == amount_minor`. A + // zero-amount note emits no legs (both sides 0) — a benign no-op the handler + // still records, but the post engine rejects an empty entry, so the handler + // guards zero up-front. + let dr: i64 = legs + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount_minor) + .sum(); + let cr: i64 = legs + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| l.amount_minor) + .sum(); + debug_assert_eq!(dr, cr, "debit-note leg plan must balance"); + if dr != cr { + return Err(DomainError::Internal(format!( + "debit-note leg plan does not balance (DR {dr} != CR {cr})" + ))); + } + + Ok(DebitNoteLegPlan { + legs, + recognized_part_minor: recognized, + deferred_part_minor: deferred, + }) +} + +#[cfg(test)] +#[path = "debit_note_tests.rs"] +mod debit_note_tests; diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/debit_note_tests.rs b/gears/bss/ledger/ledger/src/domain/adjustment/debit_note_tests.rs new file mode 100644 index 000000000..7f910d793 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/debit_note_tests.rs @@ -0,0 +1,358 @@ +//! Tests for the pure debit-note direct-split leg plan (`build_debit_note_legs`) +//! and the request-shape gate (`validate_shape`): the S1-mirror DR AR / CR REVENUE / +//! CR `CONTRACT_LIABILITY` / CR `TAX_PAYABLE` split, fully-recognized (no CL line), +//! with-deferred (CL line), fully-deferred (no REVENUE line), the no-zero- +//! placeholder rule, tax routing, and the balance invariant. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use bss_ledger_sdk::{AccountClass, Side}; +use uuid::Uuid; + +use super::*; +use crate::domain::error::DomainError; +use crate::domain::invoice::builder::TaxBreakdown; +use crate::domain::recognition::input::{RecognitionInput, RecognitionTiming}; + +/// A straight-line recognition spec (the deferred-note D4 shape). +fn straight_line() -> RecognitionInput { + RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods: 12, + first_period_id: None, + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + } +} + +/// A baseline debit-note request: incl-tax `amount_minor`, `tax_minor`, +/// `deferred_minor`, one revenue stream. Carries a recognition spec iff it defers. +fn req(amount_minor: i64, tax_minor: i64, deferred_minor: i64) -> DebitNoteRequest { + DebitNoteRequest { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + debit_note_id: "dn-1".to_owned(), + origin_invoice_id: "inv-1".to_owned(), + origin_invoice_item_ref: Some("item-1".to_owned()), + revenue_stream: "SAAS".to_owned(), + currency: "USD".to_owned(), + amount_minor, + tax_minor, + // A taxed note must carry a breakdown (validate_shape rejects a bare + // tax_minor); synthesize a single-component breakdown when tax_minor > 0. + tax: if tax_minor > 0 { + vec![tax_component(tax_minor, "US-CA", "2026Q2")] + } else { + Vec::new() + }, + deferred_minor, + reason_code: "ADDITIONAL_USAGE".to_owned(), + recognition: if deferred_minor > 0 { + Some(straight_line()) + } else { + None + }, + } +} + +fn leg(plan: &DebitNoteLegPlan, class: AccountClass, side: Side) -> Option<&PlannedLeg> { + plan.legs + .iter() + .find(|l| l.account_class == class && l.side == side) +} + +fn assert_balanced(plan: &DebitNoteLegPlan) { + let dr: i64 = plan + .legs + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount_minor) + .sum(); + let cr: i64 = plan + .legs + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| l.amount_minor) + .sum(); + assert_eq!(dr, cr, "plan must balance: {plan:?}"); + // No zero-amount legs (inherited S1 / AC #4). + assert!(plan.legs.iter().all(|l| l.amount_minor > 0), "no zero legs"); +} + +#[test] +fn fully_recognized_emits_no_contract_liability_line() { + // 1000 incl 100 tax ⇒ 900 ex-tax, all recognized (deferred 0). DR AR 1000 / CR + // REVENUE 900 / CR TAX 100; NO CONTRACT_LIABILITY line. + let r = req(1000, 100, 0); + let plan = build_debit_note_legs(&r).unwrap(); + assert_balanced(&plan); + + let ar = leg(&plan, AccountClass::Ar, Side::Debit).expect("ar leg"); + assert_eq!(ar.amount_minor, 1000); + assert!(ar.revenue_stream.is_none()); + + let rev = leg(&plan, AccountClass::Revenue, Side::Credit).expect("revenue leg"); + assert_eq!(rev.amount_minor, 900); + assert_eq!(rev.revenue_stream.as_deref(), Some("SAAS")); + + let tax = leg(&plan, AccountClass::TaxPayable, Side::Credit).expect("tax leg"); + assert_eq!(tax.amount_minor, 100); + + assert!( + leg(&plan, AccountClass::ContractLiability, Side::Credit).is_none(), + "fully-recognized ⇒ no CL line (no zero placeholder)" + ); + assert_eq!(plan.recognized_part_minor, 900); + assert_eq!(plan.deferred_part_minor, 0); +} + +#[test] +fn with_deferred_splits_revenue_and_contract_liability() { + // 1200 ex-tax (no tax), 500 deferred ⇒ DR AR 1200 / CR REVENUE 700 / CR CL 500. + let r = req(1200, 0, 500); + let plan = build_debit_note_legs(&r).unwrap(); + assert_balanced(&plan); + + let ar = leg(&plan, AccountClass::Ar, Side::Debit).expect("ar"); + assert_eq!(ar.amount_minor, 1200); + + let rev = leg(&plan, AccountClass::Revenue, Side::Credit).expect("revenue"); + assert_eq!(rev.amount_minor, 700, "recognized-now = ex_tax - deferred"); + assert_eq!(rev.revenue_stream.as_deref(), Some("SAAS")); + + let cl = leg(&plan, AccountClass::ContractLiability, Side::Credit).expect("cl"); + assert_eq!(cl.amount_minor, 500, "deferred per PO"); + assert_eq!(cl.revenue_stream.as_deref(), Some("SAAS")); + + assert_eq!(plan.recognized_part_minor, 700); + assert_eq!(plan.deferred_part_minor, 500); +} + +#[test] +fn with_deferred_and_tax_all_four_legs_balance() { + // 1100 incl 100 tax ⇒ 1000 ex-tax; 400 deferred ⇒ 600 recognized. Four legs: + // DR AR 1100 / CR REVENUE 600 / CR CL 400 / CR TAX 100. + let r = req(1100, 100, 400); + let plan = build_debit_note_legs(&r).unwrap(); + assert_balanced(&plan); + assert_eq!(plan.legs.len(), 4, "DR AR + CR REVENUE + CR CL + CR TAX"); + + assert_eq!( + leg(&plan, AccountClass::Ar, Side::Debit) + .unwrap() + .amount_minor, + 1100 + ); + assert_eq!( + leg(&plan, AccountClass::Revenue, Side::Credit) + .unwrap() + .amount_minor, + 600 + ); + assert_eq!( + leg(&plan, AccountClass::ContractLiability, Side::Credit) + .unwrap() + .amount_minor, + 400 + ); + assert_eq!( + leg(&plan, AccountClass::TaxPayable, Side::Credit) + .unwrap() + .amount_minor, + 100 + ); +} + +#[test] +fn fully_deferred_emits_no_revenue_line() { + // 700 ex-tax, all 700 deferred ⇒ DR AR 700 / CR CL 700; NO REVENUE line. + let r = req(700, 0, 700); + let plan = build_debit_note_legs(&r).unwrap(); + assert_balanced(&plan); + assert!( + leg(&plan, AccountClass::Revenue, Side::Credit).is_none(), + "no recognized-now part ⇒ no REVENUE line" + ); + let cl = leg(&plan, AccountClass::ContractLiability, Side::Credit).expect("cl"); + assert_eq!(cl.amount_minor, 700); + assert_eq!(plan.recognized_part_minor, 0); + assert_eq!(plan.deferred_part_minor, 700); +} + +#[test] +fn zero_deferred_never_emits_a_placeholder_cl_line() { + // A run of fully-recognized notes (with + without tax) never carries a CL leg. + for (amount, tax) in [(500_i64, 0_i64), (500, 50), (1, 0)] { + let plan = build_debit_note_legs(&req(amount, tax, 0)).unwrap(); + assert!( + leg(&plan, AccountClass::ContractLiability, Side::Credit).is_none(), + "deferred 0 must never emit a CONTRACT_LIABILITY placeholder ({amount}/{tax})" + ); + assert_eq!(plan.deferred_part_minor, 0); + } +} + +#[test] +fn no_tax_emits_no_tax_payable_line() { + let r = req(900, 0, 0); + let plan = build_debit_note_legs(&r).unwrap(); + assert_balanced(&plan); + assert!( + leg(&plan, AccountClass::TaxPayable, Side::Credit).is_none(), + "zero tax ⇒ no TAX_PAYABLE line" + ); +} + +#[test] +fn validate_shape_rejects_tax_over_amount() { + let r = req(100, 200, 0); + assert!(matches!( + validate_shape(&r), + Err(DomainError::AmountOutOfRange(_)) + )); +} + +#[test] +fn validate_shape_rejects_deferred_over_ex_tax() { + // 100 incl 50 tax ⇒ 50 ex-tax; a 60 deferred request is out of range. + let mut r = req(100, 50, 0); + r.deferred_minor = 60; + r.recognition = Some(straight_line()); + assert!(matches!( + validate_shape(&r), + Err(DomainError::AmountOutOfRange(_)) + )); +} + +#[test] +fn validate_shape_rejects_empty_reason_code() { + let mut r = req(100, 0, 0); + r.reason_code = " ".to_owned(); + assert!(matches!( + validate_shape(&r), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn validate_shape_rejects_deferred_without_recognition_spec() { + // A deferring note MUST carry the recognition spec the schedule build needs (D4). + let mut r = req(1000, 0, 400); + r.recognition = None; + assert!(matches!( + validate_shape(&r), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn validate_shape_accepts_fully_recognized_without_recognition_spec() { + // A fully-recognized note needs no spec. + let r = req(1000, 100, 0); + assert!(validate_shape(&r).is_ok()); +} + +#[test] +fn amount_helpers_compute_ex_tax_and_recognized() { + let r = req(1100, 100, 400); + assert_eq!(r.amount_minor_ex_tax(), 1000); + assert_eq!(r.recognized_minor(), 600); +} + +/// One tax component (the authoritative breakdown the caller carries, §4.5). +fn tax_component(amount_minor: i64, jurisdiction: &str, filing: &str) -> TaxBreakdown { + TaxBreakdown { + amount_minor, + currency: "USD".to_owned(), + tax_jurisdiction: jurisdiction.to_owned(), + tax_filing_period: filing.to_owned(), + tax_rate_ref: Some(format!("rate:{jurisdiction}")), + } +} + +#[test] +fn tax_breakdown_emits_per_component_tax_legs() { + // §4.5: a debit note carrying an authoritative breakdown of two components + // (different jurisdiction + filing) posts ONE CR TAX_PAYABLE per component, each + // stamped with its own dims so the projector disaggregates `tax_subbalance` per + // (jurisdiction, filing). `tax_minor` (the split scalar) == Σ components. + let mut r = req(1000, 150, 0); // ex_tax = 850, tax = 150 = 100 + 50 + r.tax = vec![ + tax_component(100, "US-CA", "2026Q2"), + tax_component(50, "US-NY", "2026Q3"), + ]; + let plan = build_debit_note_legs(&r).unwrap(); + assert_balanced(&plan); + + let tax_legs: Vec<&PlannedLeg> = plan + .legs + .iter() + .filter(|l| l.account_class == AccountClass::TaxPayable && l.side == Side::Credit) + .collect(); + assert_eq!(tax_legs.len(), 2, "one TAX_PAYABLE leg per component"); + let tax_sum: i64 = tax_legs.iter().map(|l| l.amount_minor).sum(); + assert_eq!(tax_sum, 150, "Σ tax legs == tax_minor"); + + let ca = tax_legs + .iter() + .find(|l| l.tax_jurisdiction.as_deref() == Some("US-CA")) + .expect("US-CA tax leg"); + assert_eq!(ca.amount_minor, 100); + assert_eq!(ca.tax_filing_period.as_deref(), Some("2026Q2")); + assert_eq!(ca.tax_rate_ref.as_deref(), Some("rate:US-CA")); + + let ny = tax_legs + .iter() + .find(|l| l.tax_jurisdiction.as_deref() == Some("US-NY")) + .expect("US-NY tax leg"); + assert_eq!(ny.amount_minor, 50); + assert_eq!(ny.tax_filing_period.as_deref(), Some("2026Q3")); +} + +#[test] +fn bare_tax_minor_without_breakdown_is_rejected() { + // A taxed note MUST carry a dimensioned breakdown: a bare `tax_minor` with no + // breakdown could only build a dimensionless TAX_PAYABLE leg the schema rejects + // (chk_journal_line_tax_dims), so validate_shape blocks it up front (400). + // The req helper auto-synthesizes a breakdown when tax_minor > 0; clear it to + // force the bare-tax_minor case under test. + let mut r = req(1000, 100, 0); + r.tax = Vec::new(); + assert!(r.tax.is_empty()); + assert!( + matches!(validate_shape(&r), Err(DomainError::InvalidRequest(_))), + "a bare tax_minor with no breakdown must be rejected" + ); +} + +#[test] +fn validate_shape_rejects_tax_breakdown_not_summing_to_tax_minor() { + // A non-empty breakdown is bound to `tax_minor` (the split scalar): a sum that + // disagrees is a 400 (AMOUNT_OUT_OF_RANGE), never a silent unbalanced post. + let mut r = req(1000, 150, 0); + r.tax = vec![tax_component(100, "US-CA", "2026Q2")]; // sums to 100, not 150 + assert!(matches!( + validate_shape(&r), + Err(DomainError::AmountOutOfRange(_)) + )); +} + +#[test] +fn validate_shape_rejects_tax_breakdown_currency_mismatch() { + // Every leg posts in the note currency; a component in another currency is a 400. + let mut r = req(1000, 100, 0); + let mut wrong = tax_component(100, "US-CA", "2026Q2"); + wrong.currency = "EUR".to_owned(); + r.tax = vec![wrong]; + assert!(matches!( + validate_shape(&r), + Err(DomainError::AmountOutOfRange(_)) + )); +} diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/manual.rs b/gears/bss/ledger/ledger/src/domain/adjustment/manual.rs new file mode 100644 index 000000000..02ccb20d2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/manual.rs @@ -0,0 +1,362 @@ +//! Governed manual-adjustment domain (Slice 3, Phase 3 / Group 1) — the **pure** +//! request shape + the deterministic [`govern`] gate a governed manual posting must +//! clear (design §4.6). Backend-agnostic: no DB / txn / async I/O. The infra +//! handler (Group 3) resolves the REST DTO into a [`ManualAdjustmentRequest`], runs +//! [`govern`] out-of-txn (a `Reject` short-circuits the post), then — on `Ok` — +//! binds the [`ManualLeg`]s onto posting lines and posts them atomically. +//! +//! **Why a governor, not a free-form post.** A manual adjustment is the ledger's +//! escape hatch for corrections the typed flows (invoice / settle / allocate / +//! S3 notes / S4 recognition) do not cover — rounding residue, suspense / +//! cash-clearing clean-up. Left ungoverned it is the obvious vector for a +//! disguised write-off (a hidden bad-debt) or a direct revenue restatement that +//! bypasses ASC 606. [`govern`] therefore enforces a **closed** contract: only the +//! per-action code-owned allow-list of account classes (below) may post, `REVENUE` +//! and `CONTRACT_LIABILITY` are globally off-limits, and any `CONTRA_REVENUE` leg +//! must be a *paired* revenue reduction or it is rejected as an attempted +//! write-off. +//! +//! **The code-owned allow-list (design §4.6 / Rev3 S3-minor).** Each +//! [`ManualAdjustmentAction`] owns a fixed `&[AccountClass]` it may touch — see +//! [`ManualAdjustmentAction::allowed_classes`]. This is a deliberate MVP: the +//! allow-list lives in code, so **every** new governed action (or a widening of an +//! existing one) is a reviewed, deliberate deploy rather than a runtime config +//! toggle. A data/config-driven allow-list + its meta-governance (who may edit it, +//! under what dual-control) is explicitly deferred. `CONTRA_REVENUE` is in **no** +//! allowed-set: it is reachable only through the write-off structural guard, which +//! is precisely what rejects it (a bare contra-revenue leg is a write-off). +//! +//! **The governor is sync + pure** (mirroring [`super::credit_note`] / +//! [`super::splitter`]): it is a function of the already-resolved request, so there +//! is no I/O to model and it stays unit-testable without a database. `SoD` +//! (`preparer ≠ approver`) and the dual-control threshold are NOT enforced here — +//! they are the `ApprovalService`'s concern (Group 4); this layer only carries the +//! actor ids so the handler can hand them on. + +use bss_ledger_sdk::{AccountClass, Side}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::invoice::builder::TaxBreakdown; + +/// Which governed manual-adjustment a request performs — the discriminator that +/// selects the [`Self::allowed_classes`] allow-list and is stamped (as +/// [`Self::as_str`]) onto the adjustment record. The set is deliberately small +/// (design §4.6 / Rev3 S3-minor): each variant is a reviewed governed capability, +/// NOT a free-form posting mode. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ManualAdjustmentAction { + /// Correct sub-minor rounding residue stranded in a parking class (e.g. a + /// 1-minor remainder in `SUSPENSE` / `CASH_CLEARING` after a split). + RoundingCorrection, + /// Clear a stale `SUSPENSE` balance to its resolved home (a clean-up move + /// between parking / clearing classes). An AR write-off to `GOODWILL` is NOT + /// permitted here: bad-debt / write-off is out of MVP scope (design §1.2), and + /// any goodwill relief must route through the governed credit-note path (with its + /// AR-floor cap + dual-control), never a bare suspense-clear. + SuspenseClear, +} + +impl ManualAdjustmentAction { + /// The stable wire/DB token. Inverse of [`Self::parse`]. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::RoundingCorrection => "ROUNDING_CORRECTION", + Self::SuspenseClear => "SUSPENSE_CLEAR", + } + } + + /// Parse a stored token back into an action — the inverse of [`Self::as_str`]. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "ROUNDING_CORRECTION" => Some(Self::RoundingCorrection), + "SUSPENSE_CLEAR" => Some(Self::SuspenseClear), + _ => None, + } + } + + /// The **code-owned** allow-list: the account classes this action may post to + /// (design §4.6 / Rev3 S3-minor). Anything outside the returned slice is a + /// [`ManualAdjustmentReject::NotAllowed`]. + /// + /// This is an intentional MVP shape: the allow-list is compiled in, so every + /// new governed action — and every widening of an existing one — is a + /// deliberate, reviewed deploy rather than a runtime config toggle. A + /// data/config-driven allow-list and its meta-governance (who may edit it, and + /// under what dual-control) are deferred. + /// + /// `CONTRA_REVENUE` is in **no** allowed-set on purpose: a bare contra-revenue + /// leg is the disguised-write-off shape the [`govern`] write-off guard rejects, + /// so it must never be reachable via the allow-list. `REVENUE` / + /// `CONTRACT_LIABILITY` are likewise absent (and additionally banned globally + /// by [`govern`]): revenue changes route through S3/S4/S6, never a manual post. + #[must_use] + pub fn allowed_classes(self) -> &'static [AccountClass] { + match self { + Self::RoundingCorrection => &[ + AccountClass::Suspense, + AccountClass::CashClearing, + AccountClass::Ar, + AccountClass::Unallocated, + ], + // `Goodwill` is deliberately ABSENT: an AR→GOODWILL move here is a bad-debt + // write-off (out of MVP scope, design §1.2) the write-off guard does not + // catch (it inspects only CONTRA_REVENUE legs). Goodwill relief routes + // through the governed credit-note path, never a manual suspense-clear. + Self::SuspenseClear => &[ + AccountClass::Suspense, + AccountClass::Ar, + AccountClass::CashClearing, + AccountClass::Unallocated, + ], + } + } +} + +/// One planned leg of a governed manual adjustment — a pure description the handler +/// (Group 3) maps onto a posting line (binding the chart `account_id` + scale). +/// All legs of a request share the request's currency; `revenue_stream` is `Some` +/// only for the per-stream classes (it is otherwise `None`). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManualLeg { + /// The account class this leg posts to (subject to the action's allow-list + + /// the global `REVENUE`/`CONTRACT_LIABILITY` ban + the write-off guard). + pub account_class: AccountClass, + /// DR / CR. + pub side: Side, + /// The leg amount in minor units (`> 0`; zero/negative legs are rejected by + /// [`govern`], inherited S1 / AC #4). + pub amount_minor: i64, + /// The revenue stream — `Some` only for a per-stream class + /// ([`AccountClass::is_per_stream`]); `None` for the stream-less classes. + pub revenue_stream: Option, +} + +/// One governed manual-adjustment request — the pure inputs the handler (Group 3) +/// resolves from the REST DTO before posting. Amounts are `i64` minor units; all +/// legs are in [`Self::currency`]. The request is idempotent on the `(tenant, +/// MANUAL_ADJUSTMENT, adjustment_id)` key. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManualAdjustmentRequest { + /// The tenant whose ledger this posts into. + pub tenant_id: Uuid, + /// The payer tenant the legs attribute to, when the adjustment touches a + /// payer-scoped balance (`AR` / `UNALLOCATED`); `None` for a payer-less + /// internal clean-up. + pub payer_tenant_id: Option, + /// The business id of this adjustment — the `(tenant, MANUAL_ADJUSTMENT, + /// adjustment_id)` idempotency key. + pub adjustment_id: String, + /// Which governed action this is (selects the allow-list, stamped on the row). + pub action: ManualAdjustmentAction, + /// ISO-4217 currency of the adjustment (every leg shares it). + pub currency: String, + /// The legs to post — must net to zero per [`govern`] (Σ DR == Σ CR). + pub legs: Vec, + /// The mandatory business reason code (AC #14). A governed manual adjustment + /// MUST justify itself; an empty/blank reason is rejected by [`govern`]. + pub reason_code: String, + /// The actor posting the adjustment (the preparer subject). Carried for the + /// audit record and the dual-control `SoD` check (enforced in Group 4, not here). + pub preparer_actor_id: Uuid, + /// The second actor when the adjustment is over the dual-control threshold (the + /// approver). `None` below threshold. The `preparer ≠ approver` `SoD` check is the + /// `ApprovalService`'s (Group 4); [`govern`] does NOT enforce it, but carries + /// the field so the handler can. + pub approver_actor_id: Option, + /// The authoritative tax breakdown for a tax-bearing action — never recomputed + /// here. Usually empty for the MVP actions (rounding / suspense clean-up rarely + /// move tax), but carried for the Group 2 tax-routing the handler will drive. + pub tax: Vec, +} + +/// The pure domain signal a governed manual adjustment is rejected (design §4.6) — +/// distinct from [`crate::domain::error::DomainError`]: the handler (Group 3) maps +/// **both** variants onto [`crate::domain::error::DomainError::ManualAdjustmentNotAllowed`] +/// (a 400), but treats them differently for observability. +/// +/// - [`Self::NotAllowed`] — a generic governance violation (blank reason, +/// unbalanced / zero / negative legs, a class outside the allow-list, or the +/// global `REVENUE`/`CONTRACT_LIABILITY` ban). A plain rejection, no alarm. +/// - [`Self::AttemptedWriteOff`] — a `CONTRA_REVENUE` leg with no paired same-stream +/// recognized-revenue reduction: the disguised bad-debt write-off shape. The +/// handler additionally fires a `SecuredAuditSink` capture + a page (the +/// `AttemptedWriteOff` alarm), because this is a deliberate-misuse signal, not a +/// benign typo. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ManualAdjustmentReject { + /// A generic governance violation (see the type doc). Carries a human-readable + /// detail the handler forwards into the canonical 400. + NotAllowed(String), + /// A `CONTRA_REVENUE` leg without a paired same-stream revenue reduction — an + /// attempted (disguised) write-off. The handler captures + pages on this. + AttemptedWriteOff(String), +} + +/// Govern a manual-adjustment request: the pure §4.6 gate every governed manual +/// posting must clear BEFORE it is allowed to post. No DB / txn — a function of the +/// already-resolved request. +/// +/// The checks run in this **order** (the order matters: the write-off shape is +/// detected as its own signal *before* the generic allow-list check, so a +/// disguised bad-debt pages rather than reading as a plain "class not allowed"): +/// +/// 1. **Reason** — a blank `reason_code` is [`ManualAdjustmentReject::NotAllowed`] +/// (AC #14: a governed adjustment must justify itself). +/// 2. **Shape** — no legs, or any `amount_minor <= 0`, is `NotAllowed` (zero / +/// negative legs are forbidden, inherited S1 / AC #4). +/// 3. **Balance** — the legs must net to zero (Σ `Side::Debit` == Σ `Side::Credit`, +/// one currency), else `NotAllowed`. Summed in `i128` so a pathological set +/// cannot overflow `i64`. +/// 4. **Global `REVENUE` / `CONTRACT_LIABILITY` ban** — a governed posting MUST NOT +/// directly DR/CR `REVENUE` or touch `CONTRACT_LIABILITY` (revenue changes route +/// through S3/S4/S6, design §4.6). Either is `NotAllowed`. +/// 5. **Write-off structural guard** — a `CONTRA_REVENUE` leg is legitimate ONLY if +/// the same entry carries a paired *recognized-revenue reduction* for the SAME +/// `revenue_stream` (a `REVENUE` leg on the revenue-reducing side, `Side::Debit`). +/// Since step 4 already banned every `REVENUE` leg the pair can never exist, so a +/// `CONTRA_REVENUE` leg here is always an [`ManualAdjustmentReject::AttemptedWriteOff`]. +/// The pairing is nonetheless checked *structurally* (not short-circuited) — it +/// is the contract shape Slice 6 (legitimate write-offs paired with a revenue +/// reduction) will reuse. +/// 6. **Allow-list** — every remaining leg's class must be in +/// [`ManualAdjustmentAction::allowed_classes`], else `NotAllowed`. +/// (`CONTRA_REVENUE` never reaches here — step 5 catches it; `REVENUE` / +/// `CONTRACT_LIABILITY` — step 4.) +/// +/// # Errors +/// [`ManualAdjustmentReject::AttemptedWriteOff`] for the unpaired-contra-revenue +/// shape; [`ManualAdjustmentReject::NotAllowed`] for every other governance +/// violation. +pub fn govern(req: &ManualAdjustmentRequest) -> Result<(), ManualAdjustmentReject> { + use ManualAdjustmentReject as R; + + // 1. Reason code (AC #14). + if req.reason_code.trim().is_empty() { + return Err(R::NotAllowed( + "manual adjustment requires a non-empty reason_code (AC #14)".to_owned(), + )); + } + + // 1b. Tax (Z7-1): the MVP governed actions move NO tax — `TAX_PAYABLE` is in no + // action's allow-list, and the dual-control snapshot (`ManualAdjustmentIntent`) + // deliberately does NOT carry `tax` (rebuilt empty on the approved replay). A + // non-empty `tax` here would therefore be SILENTLY DROPPED on an over-D2 + // replay, so reject it up front — this makes the snapshot-drop provably + // faithful and keeps the `tax` field from arming a future divergence. + if !req.tax.is_empty() { + return Err(R::NotAllowed( + "governed manual adjustment must not carry tax (no governed action moves tax)" + .to_owned(), + )); + } + + // 2. Shape: at least one leg, every leg strictly positive (inherited S1 / AC #4 + // — a governed adjustment is a real money move, never a zero/negative + // placeholder). + if req.legs.is_empty() { + return Err(R::NotAllowed("manual adjustment has no legs".to_owned())); + } + if let Some(bad) = req.legs.iter().find(|l| l.amount_minor <= 0) { + return Err(R::NotAllowed(format!( + "manual adjustment leg amount_minor must be > 0, got {} for {}", + bad.amount_minor, + bad.account_class.as_str() + ))); + } + + // 3. Balance: Σ DR == Σ CR (one currency, `req.currency`). i128 accumulation so + // a pathological leg set cannot overflow before the comparison. + let mut dr: i128 = 0; + let mut cr: i128 = 0; + for leg in &req.legs { + match leg.side { + Side::Debit => dr += i128::from(leg.amount_minor), + Side::Credit => cr += i128::from(leg.amount_minor), + } + } + if dr != cr { + return Err(R::NotAllowed(format!( + "manual adjustment legs do not net to zero (DR {dr} != CR {cr})" + ))); + } + + // 4. Global REVENUE / CONTRACT_LIABILITY ban (design §4.6): a governed posting + // must NOT directly move REVENUE or touch CONTRACT_LIABILITY on EITHER side — + // revenue changes route through S3 (notes), S4 (recognition), S6 (write-offs). + if req + .legs + .iter() + .any(|l| l.account_class == AccountClass::Revenue) + { + return Err(R::NotAllowed( + "manual adjustment must not post REVENUE directly (route revenue changes through \ + S3/S4)" + .to_owned(), + )); + } + if req + .legs + .iter() + .any(|l| l.account_class == AccountClass::ContractLiability) + { + return Err(R::NotAllowed( + "manual adjustment must not touch CONTRACT_LIABILITY outside S3/S4/S6".to_owned(), + )); + } + + // 5. Write-off structural guard (design §4.6 / Rev3 S3-minor): a CONTRA_REVENUE + // leg is legitimate ONLY paired — in the SAME entry — with a recognized- + // REVENUE reduction (a `REVENUE` leg on the revenue-reducing side, DR) for + // the SAME revenue_stream. The pairing is checked structurally for every + // contra-revenue leg (this is the contract shape Slice 6 reuses); because + // step 4 already banned every REVENUE leg, no pair can exist, so an unpaired + // CONTRA_REVENUE leg is an attempted (disguised bad-debt) write-off. + for contra in req + .legs + .iter() + .filter(|l| l.account_class == AccountClass::ContraRevenue) + { + let paired = req.legs.iter().any(|l| { + l.account_class == AccountClass::Revenue + && l.side == Side::Debit + && l.revenue_stream == contra.revenue_stream + }); + if !paired { + return Err(R::AttemptedWriteOff( + "CONTRA_REVENUE leg without a paired same-stream recognized-REVENUE reduction is \ + an attempted write-off (disguised bad-debt) — out of scope; route via GOODWILL / \ + S3" + .to_owned(), + )); + } + } + + // 6. Allow-list: every leg's class must be in the action's allowed-set. + // (CONTRA_REVENUE is filtered out by step 5; REVENUE / CONTRACT_LIABILITY by + // step 4 — so only the parking / clearing classes reach here.) + let allowed = req.action.allowed_classes(); + if let Some(bad) = req + .legs + .iter() + .find(|l| !allowed.contains(&l.account_class)) + { + return Err(R::NotAllowed(format!( + "account class {} is outside the {} allow-list", + bad.account_class.as_str(), + req.action.as_str() + ))); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "manual_tests.rs"] +mod manual_tests; diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/manual_tests.rs b/gears/bss/ledger/ledger/src/domain/adjustment/manual_tests.rs new file mode 100644 index 000000000..1e2c84333 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/manual_tests.rs @@ -0,0 +1,198 @@ +//! Tests for the pure governed manual-adjustment gate ([`govern`]) + the +//! [`ManualAdjustmentAction`] token round-trip: the code-owned allow-list, the +//! mandatory-reason / shape / balance checks, the global `REVENUE`/`CONTRACT_LIABILITY` +//! ban, and the write-off structural guard (a bare `CONTRA_REVENUE` leg ⇒ +//! `AttemptedWriteOff`, design §4.6). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use bss_ledger_sdk::{AccountClass, Side}; +use uuid::Uuid; + +use super::*; + +/// A balanced manual-adjustment request from an explicit leg list + action. +fn req(action: ManualAdjustmentAction, legs: Vec) -> ManualAdjustmentRequest { + ManualAdjustmentRequest { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Some(Uuid::now_v7()), + adjustment_id: "adj-1".to_owned(), + action, + currency: "USD".to_owned(), + legs, + reason_code: "ROUNDING_RESIDUE".to_owned(), + preparer_actor_id: Uuid::now_v7(), + approver_actor_id: None, + tax: Vec::new(), + } +} + +/// A leg helper — stream-less unless overridden via [`leg_stream`]. +fn leg(account_class: AccountClass, side: Side, amount_minor: i64) -> ManualLeg { + ManualLeg { + account_class, + side, + amount_minor, + revenue_stream: None, + } +} + +/// A per-stream leg helper. +fn leg_stream( + account_class: AccountClass, + side: Side, + amount_minor: i64, + stream: &str, +) -> ManualLeg { + ManualLeg { + account_class, + side, + amount_minor, + revenue_stream: Some(stream.to_owned()), + } +} + +#[test] +fn rounding_correction_within_allow_list_is_ok() { + // A balanced Suspense ⇄ CashClearing move — both in the RoundingCorrection + // allow-list. + let r = req( + ManualAdjustmentAction::RoundingCorrection, + vec![ + leg(AccountClass::Suspense, Side::Debit, 1), + leg(AccountClass::CashClearing, Side::Credit, 1), + ], + ); + assert_eq!(govern(&r), Ok(())); +} + +#[test] +fn class_outside_allow_list_is_not_allowed() { + // TAX_PAYABLE is in no RoundingCorrection allow-list — and it is neither + // REVENUE/CONTRACT_LIABILITY (step 4) nor CONTRA_REVENUE (step 5), so it falls + // through to the allow-list check (step 6). + let r = req( + ManualAdjustmentAction::RoundingCorrection, + vec![ + leg(AccountClass::TaxPayable, Side::Debit, 1), + leg(AccountClass::Suspense, Side::Credit, 1), + ], + ); + match govern(&r) { + Err(ManualAdjustmentReject::NotAllowed(m)) => assert!(m.contains("allow-list"), "{m}"), + other => panic!("expected NotAllowed(allow-list), got {other:?}"), + } +} + +#[test] +fn unbalanced_legs_rejected() { + let r = req( + ManualAdjustmentAction::RoundingCorrection, + vec![ + leg(AccountClass::Suspense, Side::Debit, 5), + leg(AccountClass::CashClearing, Side::Credit, 4), + ], + ); + match govern(&r) { + Err(ManualAdjustmentReject::NotAllowed(m)) => { + assert!(m.contains("net to zero"), "{m}"); + } + other => panic!("expected NotAllowed(net to zero), got {other:?}"), + } +} + +#[test] +fn empty_reason_code_rejected() { + let mut r = req( + ManualAdjustmentAction::RoundingCorrection, + vec![ + leg(AccountClass::Suspense, Side::Debit, 1), + leg(AccountClass::CashClearing, Side::Credit, 1), + ], + ); + r.reason_code = " ".to_owned(); + match govern(&r) { + Err(ManualAdjustmentReject::NotAllowed(m)) => assert!(m.contains("reason_code"), "{m}"), + other => panic!("expected NotAllowed(reason_code), got {other:?}"), + } +} + +#[test] +fn direct_revenue_leg_forbidden() { + // A balanced pair that touches REVENUE directly — banned globally (step 4), + // regardless of the action's allow-list. + let r = req( + ManualAdjustmentAction::SuspenseClear, + vec![ + leg_stream(AccountClass::Revenue, Side::Debit, 10, "SAAS"), + leg(AccountClass::Suspense, Side::Credit, 10), + ], + ); + match govern(&r) { + Err(ManualAdjustmentReject::NotAllowed(m)) => assert!(m.contains("REVENUE"), "{m}"), + other => panic!("expected NotAllowed(REVENUE), got {other:?}"), + } +} + +#[test] +fn contract_liability_leg_forbidden() { + let r = req( + ManualAdjustmentAction::SuspenseClear, + vec![ + leg_stream(AccountClass::ContractLiability, Side::Debit, 10, "SAAS"), + leg(AccountClass::Suspense, Side::Credit, 10), + ], + ); + match govern(&r) { + Err(ManualAdjustmentReject::NotAllowed(m)) => { + assert!(m.contains("CONTRACT_LIABILITY"), "{m}"); + } + other => panic!("expected NotAllowed(CONTRACT_LIABILITY), got {other:?}"), + } +} + +#[test] +fn contra_revenue_without_paired_reduction_is_attempted_write_off() { + // DR CONTRA_REVENUE / CR AR — a balanced pair, but the contra-revenue leg has + // no paired same-stream REVENUE reduction (it cannot, since REVENUE is banned), + // so it is the disguised-write-off shape ⇒ AttemptedWriteOff. + let r = req( + ManualAdjustmentAction::SuspenseClear, + vec![ + leg_stream(AccountClass::ContraRevenue, Side::Debit, 10, "SAAS"), + leg(AccountClass::Ar, Side::Credit, 10), + ], + ); + match govern(&r) { + Err(ManualAdjustmentReject::AttemptedWriteOff(m)) => { + assert!(m.contains("write-off"), "{m}"); + } + other => panic!("expected AttemptedWriteOff, got {other:?}"), + } +} + +#[test] +fn zero_amount_leg_rejected() { + let r = req( + ManualAdjustmentAction::RoundingCorrection, + vec![ + leg(AccountClass::Suspense, Side::Debit, 0), + leg(AccountClass::CashClearing, Side::Credit, 0), + ], + ); + match govern(&r) { + Err(ManualAdjustmentReject::NotAllowed(m)) => assert!(m.contains("> 0"), "{m}"), + other => panic!("expected NotAllowed(> 0), got {other:?}"), + } +} + +#[test] +fn action_str_round_trips() { + for a in [ + ManualAdjustmentAction::RoundingCorrection, + ManualAdjustmentAction::SuspenseClear, + ] { + assert_eq!(ManualAdjustmentAction::parse(a.as_str()), Some(a)); + } + assert_eq!(ManualAdjustmentAction::parse("NOPE"), None); +} diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/refund.rs b/gears/bss/ledger/ledger/src/domain/adjustment/refund.rs new file mode 100644 index 000000000..4b7644598 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/refund.rs @@ -0,0 +1,579 @@ +//! Refund domain (Slice 3, Phase 2 / Group B1) — the **pure** request shape and +//! the deterministic two-leg plan a refund posts (design §4.4). Backend-agnostic: +//! no DB / txn / async I/O. The infra +//! [`RefundHandler`](crate::infra::adjustment::refund_service) resolves the origin +//! `payment_settlement` (by `payment_id` + `currency`), routes by `phase` to the +//! right two-leg shape, calls [`build_refund_legs`] for the balanced plan, and +//! posts it (persisting the `refund` row + the caps in Group C). +//! +//! **A refund is money-OUT against a settled receipt.** Unlike a credit note (a +//! revenue/AR restatement on an invoice) a refund returns cash to the payer; it +//! NEVER restates revenue and NEVER debits `CONTRACT_LIABILITY` (design §4.4 — an +//! unreleased-deferred restatement on a refunded invoice rides a *paired* S3 credit +//! note, not the refund). There are two patterns, by what the refund unwinds: +//! +//! **Pattern A (`A_UNALLOCATED`) — on-account / unallocated money** (the receipt +//! sits in the payer's unallocated pool, never applied to an invoice): +//! +//! | Stage | Phase | DR | CR | +//! |-------|-------|----|----| +//! | 1 (initiated) | `initiated` | `UNALLOCATED` | `REFUND_CLEARING` | +//! | 2 (confirmed) | `confirmed` | `REFUND_CLEARING` | `CASH_CLEARING` | +//! +//! **Pattern B (`B_RESTORE_AR`) — after allocation, restore the receivable** (the +//! receipt had been applied to an invoice; refunding it re-opens that AR): +//! +//! | Stage | Phase | DR | CR | +//! |-------|-------|----|----| +//! | 1 (initiated) | `initiated` | `AR` | `REFUND_CLEARING` | +//! | 2 (confirmed) | `confirmed` | `REFUND_CLEARING` | `CASH_CLEARING` | +//! +//! Both patterns drain through the two-stage `REFUND_CLEARING` liability (a +//! credit-normal, no-negative-guarded clearing account): stage-1 CREDITS it (the +//! cash is committed-out but not yet disbursed), stage-2 DEBITS it back to zero as +//! the cash leaves (`CR CASH_CLEARING`). Neither stage touches Revenue / Contra / +//! Contract-liability — **No P&L** (design §4.4). +//! +//! **Single-step (D1 switch).** When the PSP / tenant guarantees the disbursement +//! is atomic (no clearing remainder ever exists), [`RefundRequest::two_stage`] is +//! `false` and a single `initiated` entry posts straight to cash: Pattern A `DR +//! UNALLOCATED · CR CASH_CLEARING`; Pattern B `DR AR · CR CASH_CLEARING` — no +//! `REFUND_CLEARING` leg at all. The default is two-stage (the conservative shape +//! with an explicit clearing balance and aging). The single-step path is only the +//! `initiated` phase — a single-step refund never has a separate `confirmed` post. +//! +//! `payment_id` + `currency` are mandatory in BOTH patterns (design §9 D7 / Rev2 +//! B-1): the handler resolves the origin `payment_settlement` from them (the +//! receipt the refund unwinds + its currency). Pattern B additionally requires the +//! `invoice_id` whose AR it restores; Pattern A must NOT carry one (its money never +//! reached an invoice). + +use bss_ledger_sdk::{AccountClass, Side}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::error::DomainError; + +/// The lifecycle phase of a PSP refund (design §3 data model / §4.4). The wire +/// literals are the `chk_ledger_refund_phase` CHECK set; [`Self::as_str`] is the +/// single mapping used both for the `refund.phase` column and for the +/// `(psp_refund_id:phase)` idempotency business id. `Initiated` is stage-1, +/// `Confirmed` is stage-2; `Rejected`/`Voided` are the PSP-failure terminal phases +/// (the stage-1 line-negation + clearing reversal lands in Group E, not here); +/// `UnknownFinal` is the indeterminate-disposition terminal (Group F). +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RefundPhase { + /// Stage-1: the refund was initiated at the PSP (cash committed-out, not yet + /// disbursed). Posts the `CR REFUND_CLEARING` leg (two-stage) or the whole + /// single-step entry. + Initiated, + /// Stage-2: the PSP confirmed the disbursement (cash left). Drains + /// `REFUND_CLEARING` to `CASH_CLEARING` (two-stage only). + Confirmed, + /// Terminal: the PSP rejected the initiated refund (Group E — line-negate + /// stage-1 + reverse the clearing leg; out of Group B scope). + Rejected, + /// Terminal: the initiated refund was voided before disbursement (Group E). + Voided, + /// Terminal: the refund's final disposition is indeterminate (Group F — clear + /// `REFUND_CLEARING` to a documented loss line + secured audit; out of scope). + UnknownFinal, +} + +impl RefundPhase { + /// The wire literal for this phase (the `chk_ledger_refund_phase` CHECK set + + /// the idempotency business-id suffix). Mirrors the SDK `str_enum!` `as_str`. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Initiated => "initiated", + Self::Confirmed => "confirmed", + Self::Rejected => "rejected", + Self::Voided => "voided", + Self::UnknownFinal => "unknown_final", + } + } + + /// Parse a stored wire literal back into a phase — the inverse of + /// [`Self::as_str`]. Used to rebuild a refund from its dual-control intent + /// snapshot on approve. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "initiated" => Some(Self::Initiated), + "confirmed" => Some(Self::Confirmed), + "rejected" => Some(Self::Rejected), + "voided" => Some(Self::Voided), + "unknown_final" => Some(Self::UnknownFinal), + _ => None, + } + } +} + +/// Which economic position a refund unwinds (design §4.4). The wire literals are +/// the `chk_ledger_refund_pattern` CHECK set; [`Self::as_str`] is the single +/// mapping for the `refund.pattern` column. The pattern fixes the stage-1 debit +/// account (`UNALLOCATED` for A, `AR` for B) and the `invoice_id` requirement +/// (None for A, required for B). +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RefundPattern { + /// On-account / unallocated money: the receipt sits in the payer's + /// `UNALLOCATED` pool (never applied to an invoice). Stage-1 debits + /// `UNALLOCATED`; carries NO `invoice_id`. + AUnallocated, + /// After allocation: the receipt was applied to an invoice; the refund + /// restores that receivable. Stage-1 debits `AR`; REQUIRES the `invoice_id`. + BRestoreAr, +} + +/// The economic DIRECTION of a refund-of-refund (Rev3 / S3-F1, design §4.4). A +/// refund-of-refund references a PRIOR refund (`relates_to_refund_id`); WHICH +/// money-out effect it carries is fixed by its sign, NOT by a lifecycle template: +/// +/// - [`Self::Clawback`] — the PSP returned the disbursed cash to the merchant (a +/// refund was undone). It DECREMENTS the origin payment's money-out counters +/// (`payment_settlement.refunded_minor`, + `refunded_unallocated_minor` for a +/// Pattern-A origin / `payment_allocation_refund.refunded_minor` for Pattern B) +/// so the total money-out cap reflects the NET refunded, and its +/// `REFUND_CLEARING` leg drains in the OPPOSITE direction to an outbound refund. +/// The **canonical default** for a refund-of-refund (design D8): when the PSP +/// event does not say otherwise, it is a claw-back. +/// - [`Self::Outbound`] — cash goes OUT again (a further refund). It INCREMENTS +/// the same counters exactly like a plain stage-1 refund and rides the SAME +/// money-out cap. A first-order refund (no `relates_to_refund_id`) is always +/// outbound. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum RefundDirection { + /// Cash leaves again — INCREMENT the money-out counters (a plain outbound + /// refund, the only direction for a first-order refund). + Outbound, + /// Cash is returned to the merchant — DECREMENT the money-out counters under + /// the underflow guard (the canonical default for a refund-of-refund, D8). + #[default] + Clawback, +} + +impl RefundDirection { + /// The wire literal for this direction (carried on the dual-control intent + /// snapshot + a future `refund` column / REST discriminator). Mirrors the + /// sibling enums' `as_str` convention. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Outbound => "OUTBOUND", + Self::Clawback => "CLAWBACK", + } + } + + /// Parse a stored wire literal back into a direction — the inverse of + /// [`Self::as_str`]. Used to rebuild a refund-of-refund from its dual-control + /// intent snapshot on approve / from a queued claw-back payload on drain. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "OUTBOUND" => Some(Self::Outbound), + "CLAWBACK" => Some(Self::Clawback), + _ => None, + } + } +} + +impl RefundPattern { + /// The wire literal for this pattern (the `chk_ledger_refund_pattern` CHECK + /// set + the `refund.pattern` column). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::AUnallocated => "A_UNALLOCATED", + Self::BRestoreAr => "B_RESTORE_AR", + } + } + + /// Parse a stored wire literal back into a pattern — the inverse of + /// [`Self::as_str`]. Used to rebuild a refund from its dual-control intent + /// snapshot on approve. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "A_UNALLOCATED" => Some(Self::AUnallocated), + "B_RESTORE_AR" => Some(Self::BRestoreAr), + _ => None, + } + } + + /// The stage-1 debit account class for this pattern: `UNALLOCATED` (A) draws + /// down the on-account pool; `AR` (B) re-opens the receivable. (Stage-2 / the + /// single-step credit side is always `CASH_CLEARING`; the stage-1 credit / + /// single-step debit pivot on this.) + #[must_use] + pub fn debit_class(self) -> AccountClass { + match self { + Self::AUnallocated => AccountClass::Unallocated, + Self::BRestoreAr => AccountClass::Ar, + } + } +} + +/// One refund request — the pure inputs the handler resolves from the REST DTO +/// (Group G) before reading any ledger state. Amounts are `i64` minor units; +/// `amount_minor` is the cash to return (`>= 0`). `payment_id` + `currency` resolve +/// the origin `payment_settlement` (both patterns, D7); `invoice_id` is the AR the +/// refund restores (Pattern B only — `None` for A). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +// The `*_id` fields mirror the storage / SDK column names verbatim; renaming to +// satisfy `struct_field_names` would diverge from `NewRefund` / the journal-line +// contract (the same `allow` the sibling note requests carry). +#[allow(clippy::struct_field_names)] +pub struct RefundRequest { + /// The seller tenant whose ledger this posts into. + pub tenant_id: Uuid, + /// The tenant the refund returns cash to (the original payer / `UNALLOCATED` + /// + `AR` owner; the cache grains key on it). + pub payer_tenant_id: Uuid, + /// The business id of this refund — the `refund` row's surrogate PK + /// (`(tenant, refund_id)`) and the REST handle (`GET /refunds/{refundId}`). + /// NOT the idempotency key (that is `(psp_refund_id:phase)`, see below). + pub refund_id: String, + /// The PSP's refund id — the natural idempotency grain together with `phase` + /// (`UNIQUE (tenant, psp_refund_id, phase)`): one PSP refund advances through + /// several phase rows. The engine claim is keyed on `(psp_refund_id:phase)`. + pub psp_refund_id: String, + /// The lifecycle phase this post records (`initiated` ⇒ stage-1, `confirmed` + /// ⇒ stage-2). Routes the leg shape ([`build_refund_legs`]). + pub phase: RefundPhase, + /// Which economic position the refund unwinds (A on-account / B restore-AR) — + /// fixes the stage-1 debit account + the `invoice_id` requirement. + pub pattern: RefundPattern, + /// The origin payment whose settlement the refund unwinds (NOT NULL both + /// patterns, D7). The handler resolves `payment_settlement` from it + + /// `currency`; a missing/mismatched settlement is a domain reject. + pub payment_id: String, + /// The invoice whose AR the refund restores — REQUIRED for Pattern B + /// (`B_RESTORE_AR`), MUST be `None` for Pattern A (its money never reached an + /// invoice). Enforced by [`validate_shape`]. + pub invoice_id: Option, + /// ISO-4217 currency of the refund (all legs share it; MUST match the origin + /// settlement's currency — the handler validates it). + pub currency: String, + /// The cash to return, in minor units (`>= 0`). + pub amount_minor: i64, + /// `true` ⇒ the conservative two-stage shape (stage-1 `… · CR REFUND_CLEARING`, + /// stage-2 `DR REFUND_CLEARING · CR CASH_CLEARING`); `false` ⇒ the single-step + /// shape (D1 — `… · CR CASH_CLEARING` in one `initiated` post, no clearing leg), + /// used only when the PSP/tenant guarantees atomic disbursement. Default + /// two-stage. + pub two_stage: bool, + /// The PRIOR refund this one references (refund-of-refund, Rev3 / S3-F1, design + /// §4.4 / §7) — the `refund.relates_to_refund_id` self-link. `None` ⇒ a + /// first-order refund (the only kind in Groups B–D). `Some` ⇒ a refund-of-refund + /// whose [`Self::direction`] fixes whether it claws back or sends more cash out; + /// [`validate_shape`] requires it for a `Clawback`. This is a free reference + /// link, NOT a strict line-negation — a claw-back is an ordinary entry that + /// carries the link, with its own `psp_refund_id` + full phase lifecycle. + pub relates_to_refund_id: Option, + /// The economic direction (refund-of-refund only — moot for a first-order + /// refund, which is always [`RefundDirection::Outbound`]). The canonical default + /// is [`RefundDirection::Clawback`] (design D8): a PSP refund-of-refund event, + /// absent a discriminator, returns cash to the merchant. A `Clawback` + /// DECREMENTS the origin money-out counters (under the underflow guard the + /// handler applies in Group E); an `Outbound` INCREMENTS them like a plain + /// stage-1 refund. + pub direction: RefundDirection, +} + +impl RefundRequest { + /// `true` iff this is a refund-of-refund claw-back (a `Clawback` direction with + /// a `relates_to_refund_id` link) — the path that DECREMENTS the origin + /// money-out counters under the underflow guard (Group E). An `Outbound` + /// refund-of-refund (cash out again) and every first-order refund INCREMENT + /// instead, so they are NOT claw-backs. + #[must_use] + pub fn is_clawback(&self) -> bool { + matches!(self.direction, RefundDirection::Clawback) && self.relates_to_refund_id.is_some() + } +} + +/// One planned leg of a refund entry — a pure description the handler maps onto a +/// posting line (binding the chart `account_id` + scale). All refund classes +/// (`UNALLOCATED`, `AR`, `REFUND_CLEARING`, `CASH_CLEARING`) are stream-less, so +/// `revenue_stream` is always `None`; the field is kept for shape-parity with the +/// sibling note `PlannedLeg`s and the `mk_line` mapping. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PlannedLeg { + /// The account class this leg posts to. + pub account_class: AccountClass, + /// DR / CR. + pub side: Side, + /// The leg amount in minor units (`> 0`; zero-amount legs are never emitted — + /// inherited S1 / AC #4 rejects a zero placeholder line, and the handler + /// guards a zero-amount refund up-front). + pub amount_minor: i64, + /// Always `None` for a refund (every refund class is stream-less); present for + /// shape-parity with the note leg plans. + pub revenue_stream: Option, +} + +/// The full balanced two-leg plan for one refund stage (design §4.4): the legs to +/// post plus the resulting `clearing_state` to stamp on the `refund` row. Pure data +/// — the handler posts the legs, persists the row, and (Group C) increments the +/// caps from the request. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RefundLegPlan { + /// The balanced legs (Σ DR == Σ CR), debit first then credit. Always exactly + /// two legs (one DR, one CR) — a refund stage is a single clearing/cash move. + pub legs: Vec, + /// The `clearing_state` literal (`chk_ledger_refund_clearing_state`) this stage + /// leaves the refund in: stage-1 two-stage ⇒ `PENDING` (the `REFUND_CLEARING` + /// balance is now open); stage-2 ⇒ `SETTLED` (it drained to zero); single-step + /// `initiated` ⇒ `SETTLED` (cash left in one move, no clearing balance ever). + pub clearing_state: &'static str, +} + +/// `clearing_state` literal: the `REFUND_CLEARING` balance is open (stage-1 +/// two-stage). +pub const CLEARING_STATE_PENDING: &str = "PENDING"; +/// `clearing_state` literal: the disbursement completed / no clearing balance +/// remains (stage-2, or single-step `initiated`). +pub const CLEARING_STATE_SETTLED: &str = "SETTLED"; +/// `clearing_state` literal: a stage-1 leg was reversed (PSP reject/void — Group E). +pub const CLEARING_STATE_REVERSED: &str = "REVERSED"; + +/// Validate a refund request's amounts + the pattern/phase/invoice shape (design +/// §4.4). Pure shape checks the handler does not own: +/// +/// - `amount_minor >= 0` (a zero refund is a benign no-op the handler rejects +/// up-front before the empty-entry engine check — out of this gate); +/// - Pattern B (`B_RESTORE_AR`) REQUIRES a non-empty `invoice_id` (the AR it +/// restores); Pattern A (`A_UNALLOCATED`) MUST NOT carry one (its money never +/// reached an invoice); +/// - the single-step path (`two_stage == false`) is only valid for the `initiated` +/// phase (a single-step refund has no separate `confirmed` post — its one entry +/// already moved the cash); +/// - `confirmed` is a two-stage-only phase (it drains the stage-1 +/// `REFUND_CLEARING`; there is none to drain in single-step). +/// - a refund-of-refund [`RefundDirection::Clawback`] REQUIRES a non-empty +/// `relates_to_refund_id` (the prior refund it claws back — Rev3 / S3-F1); a +/// `relates_to_refund_id` set with the (default) `Clawback` direction is the +/// canonical refund-of-refund (D8). An `Outbound` refund-of-refund (cash out +/// again) MAY carry the link but does not require it. +/// +/// # Errors +/// [`DomainError::AmountOutOfRange`] for a negative amount; +/// [`DomainError::InvalidRequest`] for an invoice/pattern mismatch, an invalid +/// phase/`two_stage` combination, or a `Clawback` missing its +/// `relates_to_refund_id`. +pub fn validate_shape(req: &RefundRequest) -> Result<(), DomainError> { + if req.amount_minor < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "refund amount_minor must be >= 0, got {}", + req.amount_minor + ))); + } + // A claw-back direction is meaningless without the prior refund it claws back + // (Rev3 / S3-F1, design §7): require the self-link. (`Outbound` is the default + // for a first-order refund and needs no link.) + if matches!(req.direction, RefundDirection::Clawback) + && req + .relates_to_refund_id + .as_deref() + .is_none_or(|i| i.trim().is_empty()) + { + return Err(DomainError::InvalidRequest( + "a claw-back refund-of-refund (direction = CLAWBACK) requires a non-empty \ + relates_to_refund_id (the prior refund it claws back)" + .to_owned(), + )); + } + match req.pattern { + RefundPattern::BRestoreAr => { + if req + .invoice_id + .as_deref() + .is_none_or(|i| i.trim().is_empty()) + { + return Err(DomainError::InvalidRequest( + "Pattern B (B_RESTORE_AR) refund requires a non-empty invoice_id (the AR it \ + restores)" + .to_owned(), + )); + } + } + RefundPattern::AUnallocated => { + if req.invoice_id.is_some() { + return Err(DomainError::InvalidRequest( + "Pattern A (A_UNALLOCATED) refund must not carry an invoice_id (its money \ + never reached an invoice)" + .to_owned(), + )); + } + } + } + // The single-step shape (D1) posts the whole cash move in one `initiated` + // entry; it has no `confirmed` stage to drain. Conversely a `confirmed` post + // only exists in the two-stage flow (it drains the stage-1 REFUND_CLEARING). + if !req.two_stage && req.phase == RefundPhase::Confirmed { + return Err(DomainError::InvalidRequest( + "single-step refund (two_stage = false) has no confirmed stage — its initiated entry \ + already moved the cash" + .to_owned(), + )); + } + Ok(()) +} + +/// Build the balanced two-leg plan for one refund stage (design §4.4). Pure — no +/// DB / txn. Routes by `(pattern, phase, two_stage)` AND +/// [`RefundRequest::direction`]: +/// +/// **Outbound (cash leaves — a plain refund or an `Outbound` refund-of-refund):** +/// - **stage-1 two-stage** (`phase == Initiated`, `two_stage`): DR `pattern.debit` +/// (`UNALLOCATED` / `AR`) · CR `REFUND_CLEARING` ⇒ `clearing_state = PENDING`; +/// - **stage-2** (`phase == Confirmed`, two-stage only): DR `REFUND_CLEARING` · CR +/// `CASH_CLEARING` ⇒ `clearing_state = SETTLED` (drains clearing to zero); +/// - **single-step** (`phase == Initiated`, `!two_stage`): DR `pattern.debit` · CR +/// `CASH_CLEARING` in one move ⇒ `clearing_state = SETTLED` (no clearing leg). +/// +/// **Claw-back (cash returns to the merchant — a `Clawback` refund-of-refund, +/// Rev3 / S3-F1):** the SAME accounts as outbound but every leg's SIDE is inverted +/// (`REFUND_CLEARING` drains in the OPPOSITE direction), so the cash flows back in +/// and the drawn-down `UNALLOCATED`(A) / `AR`(B) is RESTORED: +/// - **stage-1 two-stage**: DR `REFUND_CLEARING` · CR `pattern.debit` ⇒ +/// `clearing_state = PENDING` (the negative clearing balance opens); +/// - **stage-2**: DR `CASH_CLEARING` · CR `REFUND_CLEARING` ⇒ `SETTLED` (cash comes +/// back in, draining the clearing); +/// - **single-step**: DR `CASH_CLEARING` · CR `pattern.debit` ⇒ `SETTLED`. +/// +/// A claw-back is an ordinary entry with `relates_to_refund_id` plus its own +/// `psp_refund_id` / phase lifecycle — NOT a strict line-negation of the prior +/// refund. +/// +/// NEVER emits a `CONTRACT_LIABILITY` leg (design §4.4) and never touches Revenue / +/// Contra. The plan is balanced by construction (the single DR equals the single +/// CR, both `amount_minor`), asserted before returning. +/// +/// A terminal phase (`Rejected` / `Voided` / `UnknownFinal`) has no Group-B posting +/// shape — the stage-1 reversal (Group E) and the unknown-final disposition (Group +/// F) own those; this returns [`DomainError::InvalidRequest`] for them (the handler +/// only drives `Initiated` / `Confirmed` in this group). +/// +/// # Errors +/// [`DomainError::InvalidRequest`] for a terminal phase (no Group-B shape) or an +/// invalid phase/`two_stage` combination (mirrors [`validate_shape`], so a +/// validated request never hits these); [`DomainError::Internal`] if the +/// constructed plan does not balance (an invariant breach — the assertion guards a +/// silent unbalanced post). +pub fn build_refund_legs(req: &RefundRequest) -> Result { + let amount = req.amount_minor; + // The OUTBOUND (cash-out) `(debit_class, credit_class, clearing_state)` by the + // routing matrix. A claw-back uses the SAME accounts but inverts each leg's + // side below (cash flows the opposite way). A zero-amount refund still routes + // here (the handler guards amount == 0 before calling), but we keep the + // leg-emit guarded on `> 0` so a zero plan is empty rather than carrying zero + // legs (mirrors the note builders). + let (out_debit, out_credit, clearing_state) = match (req.phase, req.two_stage) { + // Stage-1, two-stage: pattern debit → REFUND_CLEARING (open the clearing). + (RefundPhase::Initiated, true) => ( + req.pattern.debit_class(), + AccountClass::RefundClearing, + CLEARING_STATE_PENDING, + ), + // Stage-2: drain REFUND_CLEARING → CASH_CLEARING (cash leaves). + (RefundPhase::Confirmed, true) => ( + AccountClass::RefundClearing, + AccountClass::CashClearing, + CLEARING_STATE_SETTLED, + ), + // Single-step `initiated`: pattern debit → CASH_CLEARING in one move. + (RefundPhase::Initiated, false) => ( + req.pattern.debit_class(), + AccountClass::CashClearing, + CLEARING_STATE_SETTLED, + ), + // `confirmed` with two_stage == false is rejected by validate_shape; defend + // it here too (no clearing to drain in single-step). + (RefundPhase::Confirmed, false) => { + return Err(DomainError::InvalidRequest( + "single-step refund has no confirmed stage".to_owned(), + )); + } + // Terminal phases have no Group-B posting shape (reversal = Group E, + // unknown-final = Group F). + (RefundPhase::Rejected | RefundPhase::Voided | RefundPhase::UnknownFinal, _) => { + return Err(DomainError::InvalidRequest(format!( + "refund phase {} has no two-stage posting shape in this group (reversal/disposition \ + are handled elsewhere)", + req.phase.as_str() + ))); + } + }; + + // A claw-back (cash returns to the merchant) posts the SAME two accounts as the + // matching outbound stage but with each leg's side flipped — `REFUND_CLEARING` + // drains the other way, restoring the drawn-down UNALLOCATED(A) / AR(B). An + // outbound refund posts the matrix as-is. The pair-flip preserves the balance + // (one DR, one CR of equal amount) either way. + let (debit_class, credit_class) = match req.direction { + RefundDirection::Outbound => (out_debit, out_credit), + RefundDirection::Clawback => (out_credit, out_debit), + }; + + let mut legs: Vec = Vec::with_capacity(2); + if amount > 0 { + legs.push(PlannedLeg { + account_class: debit_class, + side: Side::Debit, + amount_minor: amount, + revenue_stream: None, + }); + legs.push(PlannedLeg { + account_class: credit_class, + side: Side::Credit, + amount_minor: amount, + revenue_stream: None, + }); + } + + // Balance invariant (Σ DR == Σ CR). Both legs are `amount`, one DR one CR, so a + // non-zero refund always balances; a zero refund emits no legs (the handler + // rejects zero up-front — the engine rejects an empty entry). + let dr: i64 = legs + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount_minor) + .sum(); + let cr: i64 = legs + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| l.amount_minor) + .sum(); + debug_assert_eq!(dr, cr, "refund leg plan must balance"); + if dr != cr { + return Err(DomainError::Internal(format!( + "refund leg plan does not balance (DR {dr} != CR {cr})" + ))); + } + // A refund leg must NEVER debit CONTRACT_LIABILITY (design §4.4 — the + // unreleased-deferred restatement rides a paired credit note, not the refund). + // The routing matrix above can structurally never produce one; this debug + // assertion documents + pins the invariant for the test suite. + debug_assert!( + legs.iter() + .all(|l| l.account_class != AccountClass::ContractLiability), + "refund must never touch CONTRACT_LIABILITY" + ); + + Ok(RefundLegPlan { + legs, + clearing_state, + }) +} + +#[cfg(test)] +#[path = "refund_tests.rs"] +mod refund_tests; diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/refund_tests.rs b/gears/bss/ledger/ledger/src/domain/adjustment/refund_tests.rs new file mode 100644 index 000000000..67c041321 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/refund_tests.rs @@ -0,0 +1,472 @@ +//! Tests for the pure refund leg plan (`build_refund_legs`) + the request shape +//! gate (`validate_shape`): the A/B × stage-1/stage-2 × two-stage/single-step +//! routing matrix, the balanced invariant, the never-DR-CONTRACT_LIABILITY +//! guarantee (design §4.4), and the Pattern-B-without-invoice / Pattern-A-with- +//! invoice / single-step-confirmed rejects. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use bss_ledger_sdk::{AccountClass, Side}; +use uuid::Uuid; + +use super::*; +use crate::domain::error::DomainError; + +/// A baseline request for `(pattern, phase, two_stage)` over `amount`. Pattern B +/// carries `inv-1`; Pattern A carries none (the shape `validate_shape` enforces). +fn req(pattern: RefundPattern, phase: RefundPhase, two_stage: bool, amount: i64) -> RefundRequest { + let invoice_id = match pattern { + RefundPattern::BRestoreAr => Some("inv-1".to_owned()), + RefundPattern::AUnallocated => None, + }; + RefundRequest { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + refund_id: "rf-1".to_owned(), + psp_refund_id: "psp-1".to_owned(), + phase, + pattern, + payment_id: "pay-1".to_owned(), + invoice_id, + currency: "USD".to_owned(), + amount_minor: amount, + two_stage, + // Default to a first-order OUTBOUND refund; the refund-of-refund tests + // override `direction` + `relates_to_refund_id` explicitly. + relates_to_refund_id: None, + direction: RefundDirection::Outbound, + } +} + +fn assert_balanced(plan: &RefundLegPlan) { + let dr: i64 = plan + .legs + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount_minor) + .sum(); + let cr: i64 = plan + .legs + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| l.amount_minor) + .sum(); + assert_eq!(dr, cr, "plan must balance: {plan:?}"); + assert!(plan.legs.iter().all(|l| l.amount_minor > 0), "no zero legs"); + // The §4.4 invariant: a refund NEVER debits CONTRACT_LIABILITY. + assert!( + plan.legs + .iter() + .all(|l| l.account_class != AccountClass::ContractLiability), + "refund must never touch CONTRACT_LIABILITY: {plan:?}" + ); +} + +/// The (single) debit leg's class. +fn debit_class(plan: &RefundLegPlan) -> AccountClass { + plan.legs + .iter() + .find(|l| l.side == Side::Debit) + .expect("a debit leg") + .account_class +} + +/// The (single) credit leg's class. +fn credit_class(plan: &RefundLegPlan) -> AccountClass { + plan.legs + .iter() + .find(|l| l.side == Side::Credit) + .expect("a credit leg") + .account_class +} + +// --- Pattern A (A_UNALLOCATED) --- + +#[test] +fn pattern_a_stage1_two_stage_unallocated_to_clearing() { + let r = req( + RefundPattern::AUnallocated, + RefundPhase::Initiated, + true, + 500, + ); + let plan = build_refund_legs(&r).unwrap(); + assert_balanced(&plan); + assert_eq!(debit_class(&plan), AccountClass::Unallocated); + assert_eq!(credit_class(&plan), AccountClass::RefundClearing); + assert_eq!(plan.clearing_state, CLEARING_STATE_PENDING); + assert_eq!(plan.legs.len(), 2); +} + +#[test] +fn pattern_a_stage2_clearing_to_cash() { + let r = req( + RefundPattern::AUnallocated, + RefundPhase::Confirmed, + true, + 500, + ); + let plan = build_refund_legs(&r).unwrap(); + assert_balanced(&plan); + assert_eq!(debit_class(&plan), AccountClass::RefundClearing); + assert_eq!(credit_class(&plan), AccountClass::CashClearing); + assert_eq!(plan.clearing_state, CLEARING_STATE_SETTLED); +} + +#[test] +fn pattern_a_single_step_unallocated_to_cash() { + let r = req( + RefundPattern::AUnallocated, + RefundPhase::Initiated, + false, + 500, + ); + let plan = build_refund_legs(&r).unwrap(); + assert_balanced(&plan); + // Single-step skips REFUND_CLEARING entirely (D1). + assert_eq!(debit_class(&plan), AccountClass::Unallocated); + assert_eq!(credit_class(&plan), AccountClass::CashClearing); + assert_eq!(plan.clearing_state, CLEARING_STATE_SETTLED); + assert!( + plan.legs + .iter() + .all(|l| l.account_class != AccountClass::RefundClearing), + "single-step has no REFUND_CLEARING leg" + ); +} + +// --- Pattern B (B_RESTORE_AR) --- + +#[test] +fn pattern_b_stage1_two_stage_ar_to_clearing() { + let r = req(RefundPattern::BRestoreAr, RefundPhase::Initiated, true, 800); + let plan = build_refund_legs(&r).unwrap(); + assert_balanced(&plan); + // Pattern B restores AR (re-opens the receivable). + assert_eq!(debit_class(&plan), AccountClass::Ar); + assert_eq!(credit_class(&plan), AccountClass::RefundClearing); + assert_eq!(plan.clearing_state, CLEARING_STATE_PENDING); +} + +#[test] +fn pattern_b_stage2_clearing_to_cash() { + let r = req(RefundPattern::BRestoreAr, RefundPhase::Confirmed, true, 800); + let plan = build_refund_legs(&r).unwrap(); + assert_balanced(&plan); + assert_eq!(debit_class(&plan), AccountClass::RefundClearing); + assert_eq!(credit_class(&plan), AccountClass::CashClearing); + assert_eq!(plan.clearing_state, CLEARING_STATE_SETTLED); +} + +#[test] +fn pattern_b_single_step_ar_to_cash() { + let r = req( + RefundPattern::BRestoreAr, + RefundPhase::Initiated, + false, + 800, + ); + let plan = build_refund_legs(&r).unwrap(); + assert_balanced(&plan); + assert_eq!(debit_class(&plan), AccountClass::Ar); + assert_eq!(credit_class(&plan), AccountClass::CashClearing); + assert_eq!(plan.clearing_state, CLEARING_STATE_SETTLED); +} + +// --- The two-stage clearing round-trip drains to zero (the integration check, +// proven here on the pure legs: stage-1 CR REFUND_CLEARING == stage-2 DR +// REFUND_CLEARING, so the clearing nets to 0 across both stages). --- + +#[test] +fn two_stage_clearing_credit_then_debit_nets_to_zero() { + let amount = 1234; + let s1 = build_refund_legs(&req( + RefundPattern::AUnallocated, + RefundPhase::Initiated, + true, + amount, + )) + .unwrap(); + let s2 = build_refund_legs(&req( + RefundPattern::AUnallocated, + RefundPhase::Confirmed, + true, + amount, + )) + .unwrap(); + let clearing_delta = |plan: &RefundLegPlan| -> i64 { + plan.legs + .iter() + .filter(|l| l.account_class == AccountClass::RefundClearing) + .map(|l| match l.side { + // CR a credit-normal clearing liability raises it; DR drains it. + Side::Credit => l.amount_minor, + Side::Debit => -l.amount_minor, + }) + .sum() + }; + assert_eq!(clearing_delta(&s1), amount, "stage-1 opens the clearing"); + assert_eq!(clearing_delta(&s2), -amount, "stage-2 drains the clearing"); + assert_eq!( + clearing_delta(&s1) + clearing_delta(&s2), + 0, + "REFUND_CLEARING nets to zero across both stages" + ); +} + +// --- validate_shape --- + +#[test] +fn validate_shape_rejects_pattern_b_without_invoice() { + let mut r = req(RefundPattern::BRestoreAr, RefundPhase::Initiated, true, 100); + r.invoice_id = None; + assert!(matches!( + validate_shape(&r), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn validate_shape_rejects_pattern_b_with_blank_invoice() { + let mut r = req(RefundPattern::BRestoreAr, RefundPhase::Initiated, true, 100); + r.invoice_id = Some(" ".to_owned()); + assert!(matches!( + validate_shape(&r), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn validate_shape_rejects_pattern_a_with_invoice() { + let mut r = req( + RefundPattern::AUnallocated, + RefundPhase::Initiated, + true, + 100, + ); + r.invoice_id = Some("inv-x".to_owned()); + assert!(matches!( + validate_shape(&r), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn validate_shape_rejects_negative_amount() { + let r = req( + RefundPattern::AUnallocated, + RefundPhase::Initiated, + true, + -1, + ); + assert!(matches!( + validate_shape(&r), + Err(DomainError::AmountOutOfRange(_)) + )); +} + +#[test] +fn validate_shape_rejects_single_step_confirmed() { + // A single-step refund has no separate confirmed stage. + let r = req( + RefundPattern::AUnallocated, + RefundPhase::Confirmed, + false, + 100, + ); + assert!(matches!( + validate_shape(&r), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn validate_shape_accepts_zero_amount() { + // A zero-amount refund passes the shape gate (amount >= 0); the HANDLER rejects + // it up-front before the empty-entry engine check — not validate_shape's job. + let r = req(RefundPattern::AUnallocated, RefundPhase::Initiated, true, 0); + assert!(validate_shape(&r).is_ok()); +} + +// --- build_refund_legs rejects terminal / invalid combos --- + +#[test] +fn build_rejects_single_step_confirmed() { + let r = req( + RefundPattern::AUnallocated, + RefundPhase::Confirmed, + false, + 100, + ); + assert!(matches!( + build_refund_legs(&r), + Err(DomainError::InvalidRequest(_)) + )); +} + +#[test] +fn build_rejects_terminal_phases() { + for phase in [ + RefundPhase::Rejected, + RefundPhase::Voided, + RefundPhase::UnknownFinal, + ] { + let r = req(RefundPattern::AUnallocated, phase, true, 100); + assert!( + matches!(build_refund_legs(&r), Err(DomainError::InvalidRequest(_))), + "phase {phase:?} has no Group-B posting shape" + ); + } +} + +// --- wire mapping (as_str) round-trips the CHECK literals --- + +#[test] +fn phase_and_pattern_as_str_match_check_literals() { + assert_eq!(RefundPhase::Initiated.as_str(), "initiated"); + assert_eq!(RefundPhase::Confirmed.as_str(), "confirmed"); + assert_eq!(RefundPhase::Rejected.as_str(), "rejected"); + assert_eq!(RefundPhase::Voided.as_str(), "voided"); + assert_eq!(RefundPhase::UnknownFinal.as_str(), "unknown_final"); + assert_eq!(RefundPattern::AUnallocated.as_str(), "A_UNALLOCATED"); + assert_eq!(RefundPattern::BRestoreAr.as_str(), "B_RESTORE_AR"); +} + +// --- refund-of-refund: claw-back vs outbound legs + direction shape (Group E) --- + +/// Turn an outbound request into a refund-of-refund CLAW-BACK (sets the prior-refund +/// link + the `Clawback` direction). +fn clawback(mut r: RefundRequest) -> RefundRequest { + r.relates_to_refund_id = Some("rf-origin".to_owned()); + r.direction = RefundDirection::Clawback; + r +} + +#[test] +fn direction_default_is_clawback() { + // D8: the canonical default for a refund-of-refund is claw-back/decrement. + assert_eq!(RefundDirection::default(), RefundDirection::Clawback); +} + +#[test] +fn direction_as_str_round_trips() { + for d in [RefundDirection::Outbound, RefundDirection::Clawback] { + assert_eq!(RefundDirection::parse(d.as_str()), Some(d)); + } + assert_eq!(RefundDirection::Outbound.as_str(), "OUTBOUND"); + assert_eq!(RefundDirection::Clawback.as_str(), "CLAWBACK"); + assert_eq!(RefundDirection::parse("NOPE"), None); +} + +#[test] +fn is_clawback_requires_link_and_direction() { + // Clawback direction WITHOUT a link is not a claw-back (and is rejected by + // validate_shape) — both are required. + let mut r = req( + RefundPattern::AUnallocated, + RefundPhase::Initiated, + true, + 100, + ); + assert!(!r.is_clawback(), "outbound first-order is not a claw-back"); + r.direction = RefundDirection::Clawback; + assert!( + !r.is_clawback(), + "clawback direction with no link is not a claw-back" + ); + r.relates_to_refund_id = Some("rf-origin".to_owned()); + assert!(r.is_clawback(), "clawback direction + link IS a claw-back"); +} + +#[test] +fn validate_shape_requires_link_for_clawback() { + let mut r = req( + RefundPattern::AUnallocated, + RefundPhase::Initiated, + true, + 100, + ); + r.direction = RefundDirection::Clawback; // no relates_to_refund_id + assert!(matches!( + validate_shape(&r), + Err(DomainError::InvalidRequest(_)) + )); + // With the link it validates. + r.relates_to_refund_id = Some("rf-origin".to_owned()); + assert!(validate_shape(&r).is_ok()); +} + +#[test] +fn validate_shape_outbound_refund_of_refund_needs_no_link() { + // An OUTBOUND refund-of-refund (cash out again) may carry the link but does not + // require it — only a CLAWBACK does. + let mut r = req( + RefundPattern::AUnallocated, + RefundPhase::Initiated, + true, + 100, + ); + r.direction = RefundDirection::Outbound; + r.relates_to_refund_id = Some("rf-origin".to_owned()); + assert!(validate_shape(&r).is_ok()); +} + +/// Stage-1 two-stage claw-back is the side-flip of the outbound stage-1: same two +/// accounts, DR/CR swapped (`REFUND_CLEARING` drains the other way; the pattern debit +/// is RESTORED via the credit leg). Asserted for both patterns. +#[test] +fn clawback_stage1_inverts_outbound_legs() { + for pattern in [RefundPattern::AUnallocated, RefundPattern::BRestoreAr] { + let out = build_refund_legs(&req(pattern, RefundPhase::Initiated, true, 500)).unwrap(); + let cb = + build_refund_legs(&clawback(req(pattern, RefundPhase::Initiated, true, 500))).unwrap(); + assert_balanced(&cb); + // Outbound stage-1: DR pattern.debit · CR REFUND_CLEARING. + assert_eq!(debit_class(&out), pattern.debit_class()); + assert_eq!(credit_class(&out), AccountClass::RefundClearing); + // Claw-back stage-1: DR REFUND_CLEARING · CR pattern.debit (restores it). + assert_eq!(debit_class(&cb), AccountClass::RefundClearing); + assert_eq!(credit_class(&cb), pattern.debit_class()); + // Same clearing_state (PENDING) — only the sides differ. + assert_eq!(cb.clearing_state, CLEARING_STATE_PENDING); + } +} + +#[test] +fn clawback_stage2_pulls_cash_back_in() { + // Outbound stage-2 drains REFUND_CLEARING → CASH_CLEARING (cash out); the + // claw-back stage-2 is the inverse: DR CASH_CLEARING · CR REFUND_CLEARING. + let cb = build_refund_legs(&clawback(req( + RefundPattern::AUnallocated, + RefundPhase::Confirmed, + true, + 500, + ))) + .unwrap(); + assert_balanced(&cb); + assert_eq!(debit_class(&cb), AccountClass::CashClearing); + assert_eq!(credit_class(&cb), AccountClass::RefundClearing); + assert_eq!(cb.clearing_state, CLEARING_STATE_SETTLED); +} + +#[test] +fn clawback_single_step_restores_pattern_debit_from_cash() { + // Single-step outbound: DR pattern.debit · CR CASH_CLEARING. Single-step + // claw-back: DR CASH_CLEARING · CR pattern.debit (no REFUND_CLEARING leg). + let cb = build_refund_legs(&clawback(req( + RefundPattern::BRestoreAr, + RefundPhase::Initiated, + false, + 800, + ))) + .unwrap(); + assert_balanced(&cb); + assert_eq!(debit_class(&cb), AccountClass::CashClearing); + assert_eq!(credit_class(&cb), AccountClass::Ar); + assert!( + cb.legs + .iter() + .all(|l| l.account_class != AccountClass::RefundClearing), + "single-step claw-back has no REFUND_CLEARING leg" + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/splitter.rs b/gears/bss/ledger/ledger/src/domain/adjustment/splitter.rs new file mode 100644 index 000000000..87c7b9863 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/splitter.rs @@ -0,0 +1,473 @@ +//! [`RecognizedDeferredSplitter`] — the **pure** recognized-vs-deferred split of a +//! credit/debit-note ex-tax amount across the targeted obligation's +//! recognition-schedule state (design §4.2). Inputs → split, with **no DB / txn / +//! async I/O**: the Group C `CreditNoteHandler` (infra) reads the owning +//! `recognition_schedule` rows under the §4.7 lock order and hands their state in +//! as [`ScheduleStreamState`] slices; the splitter decides how much of the note +//! reduces already-**recognized** revenue (`CONTRA_REVENUE` leg) versus the +//! **unreleased deferred** balance (`CONTRACT_LIABILITY` leg + a per-stream +//! schedule reduction), per revenue stream, and records a deterministic +//! [`SplitResult::split_basis_ref`]. The splitter never imports the repo (DE0301 — +//! no infra in domain), exactly as the recognition `ScheduleBuilder` takes its +//! context rather than reading the DB. +//! +//! **The split rule (no silent pro-rata, design §4.2 / PRD L273).** The note's +//! ex-tax amount is partitioned into a `recognized_part_minor` and a +//! `deferred_part_minor` (`recognized + deferred == amount`). The `deferred_part` +//! is the portion the caller's intent targets the unreleased deferred balance +//! (`requested_deferred_minor`); the remainder is the recognized part. Each +//! stream's deferred reduction is bounded by that schedule's **remaining +//! releasable** amount (`total_deferred_minor − recognized_minor`, never below 0) +//! — already-released segments are never recomputed (Slice 4 §4.6) — so the +//! deferred part can be placed onto a stream ONLY up to its releasable remainder. +//! +//! **Block-on-ambiguous.** The split is a hard block +//! ([`DomainError::CreditNoteSplitAmbiguous`]) — never a degrade to pro-rata — +//! when the basis is indeterminable: +//! +//! - no schedule/stream state at all for a note that requests a deferred portion +//! (no item→schedule mapping to reduce); +//! - duplicate stream entries (the same `revenue_stream` twice — an ambiguous +//! item→schedule mapping); +//! - the requested deferred part exceeds the **summed** releasable remainder +//! across the supplied streams (over-reduction of in-flight schedules); +//! - a multi-stream note whose requested deferred part does not resolve to an +//! unambiguous per-stream placement (more than one stream carries a releasable +//! remainder AND the request neither drains them all nor targets exactly one) — +//! spreading it would be the forbidden pro-rata. +//! +//! The Group C handler maps this to the RFC 9457 `CREDIT_NOTE_SPLIT_AMBIGUOUS` +//! (400 — the platform `CanonicalError` ladder has no 422) + the +//! `CreditNoteSplitBlocked` alarm + an exception stub +//! (`// exception stub (full exception_queue is Slice 7)`). + +use toolkit_macros::domain_model; + +use crate::domain::error::DomainError; +use crate::domain::status::SCHEDULE_STATUS_ACTIVE; + +/// The already-read recognition-schedule state for ONE revenue stream of the +/// targeted obligation (Slice 4 one-schedule-per-stream, §4.5) — the pure input +/// the splitter derives over. Read by the Group C handler (infra) under the §4.7 +/// lock order and handed in; the splitter never reads the DB itself. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScheduleStreamState { + /// The revenue stream this schedule books under (the split keeps each + /// reduction on the SAME stream as the line it reduces, §4.5). + pub revenue_stream: String, + /// The owning `recognition_schedule` id — carried through to the per-stream + /// split so the handler reduces the right schedule, and stamped into the + /// deterministic [`SplitResult::split_basis_ref`]. + pub schedule_id: String, + /// The whole ex-tax amount this schedule deferred to `CONTRACT_LIABILITY`. + pub total_deferred_minor: i64, + /// The cumulative amount already RELEASED to revenue (`<= total_deferred`). + /// The releasable remainder is `total_deferred_minor − recognized_minor`. + pub recognized_minor: i64, + /// The schedule lifecycle status (`ACTIVE` / `COMPLETED` / `REPLACED` / + /// `CANCELLED`). Only an `ACTIVE` schedule has a reducible deferred remainder + /// (see [`Self::releasable_remaining_minor`]). + pub status: String, + /// The schedule's current lineage version at read time — stamped into + /// [`SplitResult::split_basis_ref`] so the basis pins the exact schedule state + /// the split was computed against (a later release/replace bumps it). + pub version: i64, +} + +impl ScheduleStreamState { + /// The deferred amount still releasable on this schedule — the cap on how much + /// of the note's deferred part may reduce this stream (design §4.2): the + /// not-yet-released remainder `total_deferred_minor − recognized_minor`, + /// **floored at 0** (a drained/over-recognized snapshot never yields a negative + /// reducible). A non-`ACTIVE` schedule (COMPLETED/REPLACED/CANCELLED) has no + /// live releasable balance the split may reduce, so it returns 0. + #[must_use] + pub fn releasable_remaining_minor(&self) -> i64 { + if self.status != SCHEDULE_STATUS_ACTIVE { + return 0; + } + self.total_deferred_minor + .saturating_sub(self.recognized_minor) + .max(0) + } +} + +/// The split decision for ONE revenue stream: how much of the note reduces that +/// stream's recognized revenue (`CONTRA_REVENUE`) versus its unreleased deferred +/// balance (`CONTRACT_LIABILITY` + the schedule reduction). `recognized + deferred` +/// is the note amount attributed to this stream. The handler builds the per-stream +/// legs + the per-stream `recognition_schedule` reduction (negative Δ on +/// `total_deferred_minor`) from these fields, keeping the SAME `revenue_stream`. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +// The `*_part_minor` fields mirror the `credit_note` column names verbatim (the +// storage contract); renaming to satisfy `struct_field_names` would diverge. +#[allow(clippy::struct_field_names)] +pub struct StreamSplit { + /// The stream this slice reduces (1:1 with the input [`ScheduleStreamState`]). + pub revenue_stream: String, + /// The owning schedule id this slice's deferred part reduces. + pub schedule_id: String, + /// Ex-tax amount of the note reducing this stream's **recognized** revenue + /// (the `CONTRA_REVENUE` debit). `>= 0`. + pub recognized_part_minor: i64, + /// Ex-tax amount of the note reducing this stream's **unreleased deferred** + /// balance (the `CONTRACT_LIABILITY` debit + the schedule reduction). `>= 0`, + /// `<= releasable_remaining_minor` of the stream. + pub deferred_part_minor: i64, +} + +/// The result of splitting a credit/debit-note ex-tax amount across the targeted +/// obligation's recognition-schedule state: the obligation-wide recognized vs +/// deferred totals, the per-stream breakdown, and the deterministic split basis to +/// record on the note row (`credit_note.split_basis_ref`). Pure data — the Group C +/// handler reads these public fields to build the legs + per-stream schedule +/// reductions; the splitter does not import the repo. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +// The `*_part_minor` fields mirror the `credit_note` columns; `split_basis_ref` +// mirrors the column name. Renaming to satisfy `struct_field_names` would diverge. +#[allow(clippy::struct_field_names)] +pub struct SplitResult { + /// Total ex-tax amount reducing already-recognized revenue (`Σ per-stream + /// recognized_part`; the obligation-wide `CONTRA_REVENUE` debit). + pub recognized_part_minor: i64, + /// Total ex-tax amount reducing the unreleased deferred balance (`Σ per-stream + /// deferred_part`; the obligation-wide `CONTRACT_LIABILITY` debit, and the sum + /// of the per-stream schedule reductions). + pub deferred_part_minor: i64, + /// The per-stream breakdown (one entry per supplied [`ScheduleStreamState`], in + /// input order). Each carries its own `revenue_stream` + `schedule_id` so the + /// handler reduces the right schedule on the right stream (§4.5). + pub per_stream: Vec, + /// The deterministic split-basis description recorded on the note + /// (`credit_note.split_basis_ref`, design §4.1): the PO/allocation group plus + /// the schedule id/version/releasable state each stream was split against at + /// the effective time. Reproducible for the same inputs (audit / replay). + pub split_basis_ref: String, +} + +/// The pure recognized-vs-deferred split derivation (design §4.2). A unit type — +/// the split is a pure function of its [`SplitInput`]; [`Self::split`] is the +/// single entry point. Mirrors the recognition `ScheduleBuilder` shape (a domain +/// type whose method takes the already-resolved context). +#[domain_model] +#[derive(Clone, Copy, Debug, Default)] +pub struct RecognizedDeferredSplitter; + +/// The pure inputs to one split: the targeted posted-invoice-item identity, its +/// PO/allocation group, the per-stream recognition-schedule state, the note's +/// ex-tax amount to split, and how much of that amount the caller's intent targets +/// the unreleased deferred balance. No DB handle — the schedule state is read by +/// the handler and passed in (DE0301). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SplitInput<'a> { + /// The targeted posted invoice-item ref (the line being credited/debited) — + /// stamped into [`SplitResult::split_basis_ref`] for audit. + pub source_invoice_item_ref: &'a str, + /// The PO / allocation group the targeted line books under (the split basis + /// dimension, §4.2) — stamped into [`SplitResult::split_basis_ref`]. `None` for + /// a line with no allocation group. + pub po_allocation_group: Option<&'a str>, + /// The already-read recognition-schedule state, one entry per revenue stream of + /// the targeted obligation (Slice 4 one-schedule-per-stream). Empty when the + /// line has no recognition schedule (a fully point-in-time line) — then the + /// whole amount is recognized and a deferred request blocks. + pub streams: &'a [ScheduleStreamState], + /// The note's ex-tax amount to split, in minor units (`>= 0`). The + /// `credit_note.amount_minor` is incl-tax; the caller passes the ex-tax revenue + /// portion here (tax is reversed on its own `TAX_PAYABLE` leg, §4.2). + pub amount_minor_ex_tax: i64, + /// How much of `amount_minor_ex_tax` the note's intent reduces the **unreleased + /// deferred** balance (`0 <= requested_deferred_minor <= + /// amount_minor_ex_tax`). The remainder reduces recognized revenue. A request + /// over the summed releasable remainder, or one that cannot be placed onto the + /// streams unambiguously, is a block (no pro-rata). + pub requested_deferred_minor: i64, +} + +impl RecognizedDeferredSplitter { + /// Split the note's ex-tax amount into recognized vs deferred parts across the + /// targeted obligation's per-stream recognition-schedule state. **Pure** — no + /// DB / txn / async. Order of operations (each a documented gate): + /// + /// 1. **Shape validation** — a negative amount, a negative requested deferred, + /// or a requested deferred over the note amount is a malformed request + /// ([`DomainError::AmountOutOfRange`]). + /// 2. **Duplicate-stream gate** — the same `revenue_stream` supplied twice is + /// an ambiguous item→schedule mapping ⇒ + /// [`DomainError::CreditNoteSplitAmbiguous`]. + /// 3. **No-schedule gate** — a note requesting a deferred part with NO schedule + /// state has no obligation to reduce ⇒ ambiguous. A zero-deferred request + /// with no streams is fine (wholly recognized — a fully point-in-time line). + /// 4. **Releasable cap** — the requested deferred part must not exceed the + /// summed releasable remainder across the streams (over-reducing in-flight + /// schedules) ⇒ ambiguous. + /// 5. **Per-stream placement** — place the deferred part onto the streams + /// deterministically (single-stream, drain-all, or single-releasable-target); + /// anything else would be pro-rata ⇒ ambiguous. The recognized part is the + /// per-stream remainder. + /// + /// # Errors + /// [`DomainError::AmountOutOfRange`] (malformed amounts) or + /// [`DomainError::CreditNoteSplitAmbiguous`] (an indeterminable split basis — + /// the block-on-ambiguous safety net; the handler maps it to the RFC 9457 + /// `CREDIT_NOTE_SPLIT_AMBIGUOUS` 400 + the `CreditNoteSplitBlocked` alarm). + pub fn split(input: &SplitInput<'_>) -> Result { + // 1. Shape validation. + if input.amount_minor_ex_tax < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "credit/debit-note ex-tax split amount must be >= 0, got {}", + input.amount_minor_ex_tax + ))); + } + if input.requested_deferred_minor < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "requested deferred part must be >= 0, got {}", + input.requested_deferred_minor + ))); + } + if input.requested_deferred_minor > input.amount_minor_ex_tax { + return Err(DomainError::AmountOutOfRange(format!( + "requested deferred part {} exceeds the ex-tax split amount {}", + input.requested_deferred_minor, input.amount_minor_ex_tax + ))); + } + + // 2. Duplicate-stream gate — the same revenue_stream twice is an ambiguous + // item→schedule mapping (which schedule does a reduction land on?). + for (i, s) in input.streams.iter().enumerate() { + if input.streams[..i] + .iter() + .any(|prev| prev.revenue_stream == s.revenue_stream) + { + return Err(DomainError::CreditNoteSplitAmbiguous(format!( + "duplicate recognition-schedule state for revenue stream `{}` (item `{}`): \ + ambiguous item→schedule mapping", + s.revenue_stream, input.source_invoice_item_ref + ))); + } + } + + let requested_deferred = input.requested_deferred_minor; + let recognized_total = input.amount_minor_ex_tax - requested_deferred; + + // 3. No-schedule gate — a deferred request needs a schedule to reduce. + if input.streams.is_empty() { + if requested_deferred > 0 { + return Err(DomainError::CreditNoteSplitAmbiguous(format!( + "note for item `{}` requests a deferred part of {} but the line has no \ + recognition-schedule state to reduce", + input.source_invoice_item_ref, requested_deferred + ))); + } + // Wholly recognized, no streams (a fully point-in-time line). The whole + // amount is the recognized part; no per-stream deferred reduction. + return Ok(SplitResult { + recognized_part_minor: recognized_total, + deferred_part_minor: 0, + per_stream: Vec::new(), + split_basis_ref: build_split_basis_ref(input), + }); + } + + // 4. Releasable cap — the requested deferred must fit the summed releasable + // remainder across the streams (no over-reduction of in-flight schedules; + // Slice 4's recognized_minor <= total_deferred CHECK is the authoritative + // durable guard, this is the up-front domain block). + let total_releasable: i64 = input + .streams + .iter() + .map(ScheduleStreamState::releasable_remaining_minor) + .sum(); + if requested_deferred > total_releasable { + return Err(DomainError::CreditNoteSplitAmbiguous(format!( + "requested deferred part {requested_deferred} exceeds the summed releasable \ + remainder {total_releasable} across {} stream(s) for item `{}`", + input.streams.len(), + input.source_invoice_item_ref + ))); + } + + // 5. Per-stream placement of the deferred part (deterministic, no pro-rata). + let deferred_by_stream = place_deferred(input, requested_deferred, total_releasable)?; + + // The recognized part is placed onto the SAME streams (each stream's note + // attribution is recognized + deferred). With the deferred part pinned per + // stream, place the recognized remainder so each stream's total is + // unambiguous too: a single-stream note carries it on that stream; a + // multi-stream note (which only reaches here with the deferred placement + // determinate) carries the recognized remainder on the stream that took the + // deferred part, else the sole stream. See place_recognized. + let recognized_by_stream = place_recognized(input, recognized_total, &deferred_by_stream)?; + + let per_stream: Vec = input + .streams + .iter() + .enumerate() + .map(|(i, s)| StreamSplit { + revenue_stream: s.revenue_stream.clone(), + schedule_id: s.schedule_id.clone(), + recognized_part_minor: recognized_by_stream[i], + deferred_part_minor: deferred_by_stream[i], + }) + .collect(); + + Ok(SplitResult { + recognized_part_minor: recognized_total, + deferred_part_minor: requested_deferred, + per_stream, + split_basis_ref: build_split_basis_ref(input), + }) + } +} + +/// Place `requested_deferred` onto the streams (index-aligned with +/// `input.streams`), deterministically and WITHOUT pro-rata. Returns the per-stream +/// deferred amounts, or [`DomainError::CreditNoteSplitAmbiguous`] when no +/// unambiguous placement exists. Resolvable cases: +/// +/// - `requested_deferred == 0` ⇒ all zero (wholly recognized). +/// - exactly ONE stream has a releasable remainder ⇒ the whole deferred part lands +/// on it (the others take 0). +/// - `requested_deferred == total_releasable` ⇒ DRAIN every stream to its releasable +/// remainder (an unambiguous full reduction, no proportioning choice). +/// +/// Any other multi-releasable-stream partial request is the forbidden pro-rata ⇒ +/// block. +fn place_deferred( + input: &SplitInput<'_>, + requested_deferred: i64, + total_releasable: i64, +) -> Result, DomainError> { + let n = input.streams.len(); + let mut deferred = vec![0_i64; n]; + + if requested_deferred == 0 { + return Ok(deferred); + } + + // Indices of streams that can absorb a deferred reduction. + let releasable_idx: Vec = input + .streams + .iter() + .enumerate() + .filter(|(_, s)| s.releasable_remaining_minor() > 0) + .map(|(i, _)| i) + .collect(); + + // Exactly one stream carries a releasable remainder ⇒ unambiguous single target + // (the cap gate already guaranteed requested_deferred <= total_releasable, i.e. + // <= this stream's remainder). + if releasable_idx.len() == 1 { + deferred[releasable_idx[0]] = requested_deferred; + return Ok(deferred); + } + + // Multiple releasable streams: the ONLY unambiguous multi-stream placement is a + // full drain (request == total releasable). A partial request would have to + // proportion across streams — the forbidden pro-rata. + if requested_deferred == total_releasable { + for &i in &releasable_idx { + deferred[i] = input.streams[i].releasable_remaining_minor(); + } + return Ok(deferred); + } + + Err(DomainError::CreditNoteSplitAmbiguous(format!( + "multi-stream deferred reduction of {requested_deferred} across {} releasable stream(s) \ + (summed releasable {total_releasable}) for item `{}` has no unambiguous per-stream \ + placement (a partial split would be pro-rata)", + releasable_idx.len(), + input.source_invoice_item_ref + ))) +} + +/// Place the `recognized_total` remainder onto the streams (index-aligned), +/// deterministically. The deferred part is already pinned per stream; the +/// recognized remainder must land unambiguously too: +/// +/// - single stream ⇒ the whole recognized part lands on it. +/// - `recognized_total == 0` ⇒ all zero. +/// - multi-stream ⇒ the recognized remainder lands on the SINGLE stream that took +/// the deferred part (the unambiguous "this stream is the one being reduced" +/// case). If the deferred placement spanned multiple streams (a full drain) AND a +/// non-zero recognized remainder must also be placed, there is no unambiguous +/// per-stream attribution for the recognized part ⇒ block. +fn place_recognized( + input: &SplitInput<'_>, + recognized_total: i64, + deferred_by_stream: &[i64], +) -> Result, DomainError> { + let n = input.streams.len(); + let mut recognized = vec![0_i64; n]; + + if recognized_total == 0 { + return Ok(recognized); + } + if n == 1 { + recognized[0] = recognized_total; + return Ok(recognized); + } + + // Multi-stream: attribute the recognized remainder to the single stream that + // took the deferred reduction (the line being reduced). More than one stream + // with a deferred part (a full drain) plus a recognized remainder cannot be + // attributed without proportioning ⇒ block. + let deferred_streams: Vec = (0..n).filter(|&i| deferred_by_stream[i] > 0).collect(); + if deferred_streams.len() == 1 { + recognized[deferred_streams[0]] = recognized_total; + return Ok(recognized); + } + + Err(DomainError::CreditNoteSplitAmbiguous(format!( + "multi-stream recognized reduction of {recognized_total} for item `{}` has no unambiguous \ + per-stream placement (the deferred part spans {} stream(s); attributing the recognized \ + remainder would be pro-rata)", + input.source_invoice_item_ref, + deferred_streams.len() + ))) +} + +/// Build the deterministic `split_basis_ref` (design §4.1): a stable, reproducible +/// description of the basis the split was computed against — the PO/allocation +/// group plus each stream's `schedule_id@version` and releasable state at the +/// effective time. The same inputs always render the same string (audit / replay); +/// streams are rendered in input order (the caller supplies them in a stable +/// order). Format is intentionally compact + greppable, not a parsed contract. +fn build_split_basis_ref(input: &SplitInput<'_>) -> String { + let po = input.po_allocation_group.unwrap_or("-"); + if input.streams.is_empty() { + return format!( + "item={};po={};streams=none", + input.source_invoice_item_ref, po + ); + } + let streams = input + .streams + .iter() + .map(|s| { + format!( + "{}:{}@v{}:def={}:rec={}:rel={}:{}", + s.revenue_stream, + s.schedule_id, + s.version, + s.total_deferred_minor, + s.recognized_minor, + s.releasable_remaining_minor(), + s.status, + ) + }) + .collect::>() + .join(","); + format!( + "item={};po={};streams=[{}]", + input.source_invoice_item_ref, po, streams + ) +} + +#[cfg(test)] +#[path = "splitter_tests.rs"] +mod splitter_tests; diff --git a/gears/bss/ledger/ledger/src/domain/adjustment/splitter_tests.rs b/gears/bss/ledger/ledger/src/domain/adjustment/splitter_tests.rs new file mode 100644 index 000000000..6a1cd550d --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/adjustment/splitter_tests.rs @@ -0,0 +1,288 @@ +//! Tests for the pure `RecognizedDeferredSplitter`: fully-recognized / fully-deferred +//! / mixed splits, the deferred-over-releasable block, correct multi-stream +//! (drain-all) splitting, the indeterminable per-stream block (no pro-rata), the +//! duplicate-stream / no-schedule blocks, and the deterministic `split_basis_ref`. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use crate::domain::error::DomainError; +use crate::domain::status::SCHEDULE_STATUS_COMPLETED; + +/// An ACTIVE schedule-stream state with the given deferred/recognized totals. +fn active( + stream: &str, + schedule_id: &str, + total_deferred: i64, + recognized: i64, +) -> ScheduleStreamState { + ScheduleStreamState { + revenue_stream: stream.to_owned(), + schedule_id: schedule_id.to_owned(), + total_deferred_minor: total_deferred, + recognized_minor: recognized, + status: SCHEDULE_STATUS_ACTIVE.to_owned(), + version: 1, + } +} + +/// A non-ACTIVE (here COMPLETED) schedule-stream state — no releasable remainder. +fn completed(stream: &str, schedule_id: &str, total_deferred: i64) -> ScheduleStreamState { + ScheduleStreamState { + revenue_stream: stream.to_owned(), + schedule_id: schedule_id.to_owned(), + total_deferred_minor: total_deferred, + recognized_minor: total_deferred, + status: SCHEDULE_STATUS_COMPLETED.to_owned(), + version: 5, + } +} + +/// A split input over `streams` for one item, ex-tax `amount` with +/// `requested_deferred` targeting the unreleased deferred balance. +fn input(streams: &[ScheduleStreamState], amount: i64, requested_deferred: i64) -> SplitInput<'_> { + SplitInput { + source_invoice_item_ref: "inv-1:item-1", + po_allocation_group: Some("po-grp-1"), + streams, + amount_minor_ex_tax: amount, + requested_deferred_minor: requested_deferred, + } +} + +fn split(input: &SplitInput<'_>) -> Result { + RecognizedDeferredSplitter::split(input) +} + +#[test] +fn fully_recognized_split_has_zero_deferred() { + // One stream, 600 deferred / 600 recognized (drained) ⇒ no releasable; a + // wholly-recognized note (requested_deferred = 0). + let streams = [active("recurring", "sch-1", 600, 600)]; + let result = split(&input(&streams, 10_000, 0)).unwrap(); + assert_eq!(result.recognized_part_minor, 10_000); + assert_eq!(result.deferred_part_minor, 0); + assert_eq!(result.per_stream.len(), 1); + assert_eq!(result.per_stream[0].recognized_part_minor, 10_000); + assert_eq!(result.per_stream[0].deferred_part_minor, 0); + assert_eq!(result.per_stream[0].revenue_stream, "recurring"); + assert_eq!(result.per_stream[0].schedule_id, "sch-1"); +} + +#[test] +fn fully_deferred_split_has_zero_recognized() { + // One ACTIVE stream with 10_000 releasable; the whole note is deferred. + let streams = [active("recurring", "sch-1", 12_000, 2_000)]; // releasable 10_000 + let result = split(&input(&streams, 10_000, 10_000)).unwrap(); + assert_eq!(result.recognized_part_minor, 0); + assert_eq!(result.deferred_part_minor, 10_000); + assert_eq!(result.per_stream[0].deferred_part_minor, 10_000); + assert_eq!(result.per_stream[0].recognized_part_minor, 0); +} + +#[test] +fn mixed_split_places_recognized_and_deferred_on_the_single_stream() { + // 10_000 note; 3_000 targets the deferred remainder (releasable 4_000), 7_000 + // reduces recognized revenue. + let streams = [active("recurring", "sch-1", 9_000, 5_000)]; // releasable 4_000 + let result = split(&input(&streams, 10_000, 3_000)).unwrap(); + assert_eq!(result.recognized_part_minor, 7_000); + assert_eq!(result.deferred_part_minor, 3_000); + assert_eq!(result.per_stream.len(), 1); + assert_eq!(result.per_stream[0].recognized_part_minor, 7_000); + assert_eq!(result.per_stream[0].deferred_part_minor, 3_000); + // recognized + deferred == the note amount. + let s = &result.per_stream[0]; + assert_eq!(s.recognized_part_minor + s.deferred_part_minor, 10_000); +} + +#[test] +fn deferred_request_over_releasable_remainder_blocks() { + // releasable is 4_000; a 5_000 deferred request over-reduces the in-flight + // schedule ⇒ block-on-ambiguous (NOT a silent clamp / pro-rata). + let streams = [active("recurring", "sch-1", 9_000, 5_000)]; // releasable 4_000 + let err = split(&input(&streams, 10_000, 5_000)).unwrap_err(); + assert!( + matches!(err, DomainError::CreditNoteSplitAmbiguous(_)), + "deferred over releasable must block, got {err:?}" + ); +} + +#[test] +fn deferred_request_with_no_schedule_state_blocks() { + // A note requesting a deferred portion but the line has NO schedule to reduce + // ⇒ ambiguous (no item→schedule mapping). + let streams: [ScheduleStreamState; 0] = []; + let err = split(&input(&streams, 10_000, 1)).unwrap_err(); + assert!(matches!(err, DomainError::CreditNoteSplitAmbiguous(_))); +} + +#[test] +fn wholly_recognized_note_with_no_schedule_state_is_ok() { + // No schedule + zero deferred request ⇒ a fully point-in-time line; the whole + // amount is recognized, no per-stream reduction. + let streams: [ScheduleStreamState; 0] = []; + let result = split(&input(&streams, 10_000, 0)).unwrap(); + assert_eq!(result.recognized_part_minor, 10_000); + assert_eq!(result.deferred_part_minor, 0); + assert!(result.per_stream.is_empty()); +} + +#[test] +fn multi_stream_full_drain_splits_per_stream() { + // Two ACTIVE streams, releasable 4_000 + 6_000 = 10_000; a deferred request of + // exactly 10_000 drains BOTH (unambiguous), each reduction kept on its stream. + let streams = [ + active("recurring", "sch-A", 4_000, 0), // releasable 4_000 + active("usage", "sch-B", 10_000, 4_000), // releasable 6_000 + ]; + let result = split(&input(&streams, 10_000, 10_000)).unwrap(); + assert_eq!(result.deferred_part_minor, 10_000); + assert_eq!(result.recognized_part_minor, 0); + assert_eq!(result.per_stream.len(), 2); + // Each stream is drained to its own releasable remainder, same stream/schedule. + assert_eq!(result.per_stream[0].revenue_stream, "recurring"); + assert_eq!(result.per_stream[0].schedule_id, "sch-A"); + assert_eq!(result.per_stream[0].deferred_part_minor, 4_000); + assert_eq!(result.per_stream[1].revenue_stream, "usage"); + assert_eq!(result.per_stream[1].schedule_id, "sch-B"); + assert_eq!(result.per_stream[1].deferred_part_minor, 6_000); +} + +#[test] +fn multi_stream_single_releasable_target_places_on_that_stream() { + // Two streams but only ONE is ACTIVE-with-remainder (the other is COMPLETED ⇒ + // releasable 0); the deferred part lands unambiguously on the live one. + let streams = [ + completed("recurring", "sch-A", 5_000), // releasable 0 + active("usage", "sch-B", 8_000, 3_000), // releasable 5_000 + ]; + let result = split(&input(&streams, 7_000, 5_000)).unwrap(); + assert_eq!(result.deferred_part_minor, 5_000); + assert_eq!(result.recognized_part_minor, 2_000); + // Deferred + recognized remainder both land on the single live stream (sch-B). + assert_eq!(result.per_stream[0].deferred_part_minor, 0); + assert_eq!(result.per_stream[0].recognized_part_minor, 0); + assert_eq!(result.per_stream[1].deferred_part_minor, 5_000); + assert_eq!(result.per_stream[1].recognized_part_minor, 2_000); +} + +#[test] +fn multi_stream_partial_deferred_split_is_indeterminable_and_blocks() { + // Two releasable streams (4_000 + 6_000 = 10_000) but a PARTIAL deferred request + // of 3_000 (< total releasable) ⇒ no unambiguous per-stream placement; spreading + // it would be pro-rata ⇒ block. + let streams = [ + active("recurring", "sch-A", 4_000, 0), // releasable 4_000 + active("usage", "sch-B", 6_000, 0), // releasable 6_000 + ]; + let err = split(&input(&streams, 10_000, 3_000)).unwrap_err(); + assert!( + matches!(err, DomainError::CreditNoteSplitAmbiguous(_)), + "partial multi-stream deferred split must block, got {err:?}" + ); +} + +#[test] +fn multi_stream_full_drain_with_recognized_remainder_blocks() { + // Drain both streams (deferred = total releasable 10_000) BUT the note also has + // a recognized remainder (amount 12_000 > 10_000) — attributing the 2_000 + // recognized part across two drained streams would be pro-rata ⇒ block. + let streams = [ + active("recurring", "sch-A", 4_000, 0), // releasable 4_000 + active("usage", "sch-B", 6_000, 0), // releasable 6_000 + ]; + let err = split(&input(&streams, 12_000, 10_000)).unwrap_err(); + assert!( + matches!(err, DomainError::CreditNoteSplitAmbiguous(_)), + "multi-stream drain with a recognized remainder must block, got {err:?}" + ); +} + +#[test] +fn wholly_recognized_note_against_multi_stream_obligation_blocks() { + // A note with NO deferred part (requested_deferred = 0) but a non-zero recognized + // remainder against a multi-stream obligation has no unambiguous stream to + // attribute the recognized reduction to (deferred placement is empty) ⇒ block + // (attributing across streams would be pro-rata). The handler must target a + // single stream (or supply single-stream state) for a recognized-only credit. + let streams = [ + active("recurring", "sch-A", 4_000, 0), + active("usage", "sch-B", 6_000, 0), + ]; + let err = split(&input(&streams, 5_000, 0)).unwrap_err(); + assert!( + matches!(err, DomainError::CreditNoteSplitAmbiguous(_)), + "recognized-only multi-stream note must block (no pro-rata), got {err:?}" + ); +} + +#[test] +fn duplicate_stream_state_blocks() { + // The same revenue_stream supplied twice ⇒ ambiguous item→schedule mapping. + let streams = [ + active("recurring", "sch-A", 4_000, 0), + active("recurring", "sch-B", 6_000, 0), + ]; + let err = split(&input(&streams, 5_000, 0)).unwrap_err(); + assert!(matches!(err, DomainError::CreditNoteSplitAmbiguous(_))); +} + +#[test] +fn negative_amount_is_rejected() { + let streams = [active("recurring", "sch-1", 9_000, 5_000)]; + let err = split(&input(&streams, -1, 0)).unwrap_err(); + assert!(matches!(err, DomainError::AmountOutOfRange(_))); +} + +#[test] +fn requested_deferred_over_amount_is_rejected() { + let streams = [active("recurring", "sch-1", 9_000, 0)]; + let err = split(&input(&streams, 1_000, 2_000)).unwrap_err(); + assert!(matches!(err, DomainError::AmountOutOfRange(_))); +} + +#[test] +fn non_active_schedule_has_no_releasable_remainder() { + // A COMPLETED schedule yields 0 releasable even though total_deferred > 0, so a + // deferred request against it blocks (no live balance to reduce). + let streams = [completed("recurring", "sch-1", 8_000)]; + assert_eq!(streams[0].releasable_remaining_minor(), 0); + let err = split(&input(&streams, 5_000, 1_000)).unwrap_err(); + assert!(matches!(err, DomainError::CreditNoteSplitAmbiguous(_))); +} + +#[test] +fn split_basis_ref_is_deterministic_and_carries_the_basis() { + // Same inputs ⇒ identical basis ref; it names the item, PO group, and each + // stream's schedule@version + releasable state. + let streams = [active("recurring", "sch-1", 9_000, 5_000)]; + let a = split(&input(&streams, 10_000, 3_000)).unwrap(); + let b = split(&input(&streams, 10_000, 3_000)).unwrap(); + assert_eq!( + a.split_basis_ref, b.split_basis_ref, + "basis must be reproducible" + ); + assert!(a.split_basis_ref.contains("inv-1:item-1")); + assert!(a.split_basis_ref.contains("po-grp-1")); + assert!(a.split_basis_ref.contains("recurring")); + assert!(a.split_basis_ref.contains("sch-1")); + // releasable remainder (4_000) is recorded for audit/replay. + assert!(a.split_basis_ref.contains("rel=4000")); +} + +#[test] +fn split_basis_ref_handles_no_po_group_and_no_streams() { + let streams: [ScheduleStreamState; 0] = []; + let inp = SplitInput { + source_invoice_item_ref: "inv-9:item-2", + po_allocation_group: None, + streams: &streams, + amount_minor_ex_tax: 500, + requested_deferred_minor: 0, + }; + let result = split(&inp).unwrap(); + assert!(result.split_basis_ref.contains("inv-9:item-2")); + assert!(result.split_basis_ref.contains("po=-")); + assert!(result.split_basis_ref.contains("streams=none")); +} diff --git a/gears/bss/ledger/ledger/src/domain/allocate.rs b/gears/bss/ledger/ledger/src/domain/allocate.rs new file mode 100644 index 000000000..b7ceb1a2c --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/allocate.rs @@ -0,0 +1,119 @@ +//! Deterministic proportional allocation. Splits `total` across `weights` +//! so the parts sum back to `total` exactly; the rounding residual is +//! placed by a pinned rule so every recompute lands it identically +//! (architecture §4.5, I-11). v1 ships the "last" disposition; "largest" +//! is provided for handlers that need it. + +use toolkit_macros::domain_model; + +use crate::domain::money_math::{MoneyError, checked_minor, round_half_even}; + +/// Where the rounding remainder is assigned. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Residual { + /// Last index (canonical-last built line). + Last, + /// Largest weight; ties broken by the lowest index. + Largest, +} + +/// Allocate `total` minor units across `weights` proportionally, with +/// banker's-rounded shares and the residual placed by `disposition`. +/// +/// # Errors +/// [`MoneyError::EmptyWeights`] if `weights` is empty, +/// [`MoneyError::NegativeWeight`] if any weight is negative, +/// [`MoneyError::DivByZero`] if the weights sum to zero, or +/// [`MoneyError::Overflow`] if a computed share exceeds `i64`. +pub fn allocate( + total: i64, + weights: &[i64], + disposition: Residual, +) -> Result, MoneyError> { + if weights.is_empty() { + return Err(MoneyError::EmptyWeights); + } + if weights.iter().any(|w| *w < 0) { + return Err(MoneyError::NegativeWeight); + } + let sum: i128 = weights.iter().map(|w| i128::from(*w)).sum(); + if sum == 0 { + return Err(MoneyError::DivByZero); + } + let total_i = i128::from(total); + + // Banker's-rounded proportional shares. + let mut shares: Vec = weights + .iter() + .map(|w| checked_minor(round_half_even(total_i * i128::from(*w), sum))) + .collect::>()?; + + // Residual = total - sum(shares); place it on the chosen index. + let placed: i128 = shares.iter().map(|s| i128::from(*s)).sum(); + let residual = checked_minor(total_i - placed)?; + if residual != 0 { + let idx = match disposition { + Residual::Last => shares.len() - 1, + Residual::Largest => weights + .iter() + .enumerate() + .max_by(|(ia, a), (ib, b)| a.cmp(b).then(ib.cmp(ia))) + .map_or(0, |(i, _)| i), + }; + shares[idx] = checked_minor(i128::from(shares[idx]) + i128::from(residual))?; + } + Ok(shares) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allocation_sums_to_total() { + let parts = allocate(100, &[1, 1, 1], Residual::Last).unwrap(); + assert_eq!(parts.iter().sum::(), 100); + assert_eq!(parts, vec![33, 33, 34]); // residual on last + } + + #[test] + fn residual_to_largest_weight() { + let parts = allocate(100, &[1, 8, 1], Residual::Largest).unwrap(); + assert_eq!(parts.iter().sum::(), 100); + assert_eq!(parts[1], parts.iter().copied().max().unwrap()); + } + + #[test] + fn zero_weight_sum_errors() { + assert_eq!( + allocate(100, &[0, 0], Residual::Last), + Err(MoneyError::DivByZero) + ); + } + + #[test] + fn largest_residual_with_equal_weights_breaks_tie_to_lowest_index() { + // Equal weights → residual 1; the Largest rule places it on the + // highest weight, ties broken by the lowest index (here index 0). + let parts = allocate(100, &[1, 1, 1], Residual::Largest).unwrap(); + assert_eq!(parts.iter().sum::(), 100); + assert_eq!(parts, vec![34, 33, 33]); + } + + #[test] + fn empty_weights_is_an_error_not_a_panic() { + assert_eq!( + allocate(100, &[], Residual::Last), + Err(MoneyError::EmptyWeights) + ); + } + + #[test] + fn negative_weight_is_an_error_not_a_panic() { + assert_eq!( + allocate(100, &[1, -2, 3], Residual::Last), + Err(MoneyError::NegativeWeight) + ); + } +} diff --git a/gears/bss/ledger/ledger/src/domain/approval.rs b/gears/bss/ledger/ledger/src/domain/approval.rs new file mode 100644 index 000000000..5f4c2f82e --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/approval.rs @@ -0,0 +1,139 @@ +//! Dual-control governance domain (VHP-1852): the approval `kind` discriminator, +//! the `PENDING → APPROVED | REJECTED | NEEDS_REWORK | CANCELLED | EXPIRED` state +//! type, and the pure threshold-policy resolver ([`policy`]). The state-machine +//! transition rules and the `preparer ≠ approver` enforcement live with the +//! `ApprovalService` (infra, Group D); this module holds the pure types + the +//! threshold logic so they are unit-testable without a database. + +use toolkit_macros::domain_model; + +pub mod intent; +pub mod policy; + +/// Which governed mutation an approval gates. Stamped (as [`Self::as_str`]) into +/// `ledger_approval.kind`; the same set is the DB CHECK in migration `000012`. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ApprovalKind { + Reverse, + MaterialBackdating, + CreditGrant, + ChargebackLoss, + PayerClosure, + PeriodReopen, + RecognitionScheduleChange, + Refund, + ManualAdjustment, + CreditNote, + DebitNote, +} + +impl ApprovalKind { + /// The stable wire/DB token. Inverse of [`Self::parse`]. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Reverse => "REVERSE", + Self::MaterialBackdating => "MATERIAL_BACKDATING", + Self::CreditGrant => "CREDIT_GRANT", + Self::ChargebackLoss => "CHARGEBACK_LOSS", + Self::PayerClosure => "PAYER_CLOSURE", + Self::PeriodReopen => "PERIOD_REOPEN", + Self::RecognitionScheduleChange => "RECOGNITION_SCHEDULE_CHANGE", + Self::Refund => "REFUND", + Self::ManualAdjustment => "MANUAL_ADJUSTMENT", + Self::CreditNote => "CREDIT_NOTE", + Self::DebitNote => "DEBIT_NOTE", + } + } + + /// Parse a stored token back into a kind — the inverse of [`Self::as_str`]. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "REVERSE" => Some(Self::Reverse), + "MATERIAL_BACKDATING" => Some(Self::MaterialBackdating), + "CREDIT_GRANT" => Some(Self::CreditGrant), + "CHARGEBACK_LOSS" => Some(Self::ChargebackLoss), + "PAYER_CLOSURE" => Some(Self::PayerClosure), + "PERIOD_REOPEN" => Some(Self::PeriodReopen), + "RECOGNITION_SCHEDULE_CHANGE" => Some(Self::RecognitionScheduleChange), + "REFUND" => Some(Self::Refund), + "MANUAL_ADJUSTMENT" => Some(Self::ManualAdjustment), + "CREDIT_NOTE" => Some(Self::CreditNote), + "DEBIT_NOTE" => Some(Self::DebitNote), + _ => None, + } + } +} + +/// The lifecycle state of a `ledger_approval` row. `PENDING`/`NEEDS_REWORK` are +/// the active states (idempotency + expiry apply to them); `APPROVING` is the +/// transient execute-then-mark latch (H2 — claimed before the mutation runs so a +/// concurrent reject/cancel/request-changes can no longer win, never released +/// early, never expired mid-flight); the rest are terminal. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ApprovalState { + Pending, + /// Transient: an approve has been authorized and is executing the mutation; + /// only the approve flow may move it on (→ `Approved`, or back to `Pending` + /// if the mutation failed without committing). Not cancellable/rejectable. + Approving, + Approved, + Rejected, + NeedsRework, + Cancelled, + Expired, +} + +impl ApprovalState { + /// The stable wire/DB token. Inverse of [`Self::parse`]. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Pending => "PENDING", + Self::Approving => "APPROVING", + Self::Approved => "APPROVED", + Self::Rejected => "REJECTED", + Self::NeedsRework => "NEEDS_REWORK", + Self::Cancelled => "CANCELLED", + Self::Expired => "EXPIRED", + } + } + + /// Parse a stored token back into a state — the inverse of [`Self::as_str`]. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "PENDING" => Some(Self::Pending), + "APPROVING" => Some(Self::Approving), + "APPROVED" => Some(Self::Approved), + "REJECTED" => Some(Self::Rejected), + "NEEDS_REWORK" => Some(Self::NeedsRework), + "CANCELLED" => Some(Self::Cancelled), + "EXPIRED" => Some(Self::Expired), + _ => None, + } + } + + /// The active states — the ones that still move through the workflow + /// (idempotency + expiry apply): `PENDING` and `NEEDS_REWORK`. Mirrors + /// [`Self::is_active`]; used to build the SQL `state IN (…)` predicate for the + /// active set (`.map(Self::as_str)`). Note the idempotency lookup + /// ([`ApprovalRepo::read_active`](crate::infra::storage::repo::ApprovalRepo)) + /// additionally counts the transient `APPROVING` latch as active — that is a + /// DIFFERENT, wider set and is spelled out explicitly there. + pub const ACTIVE: [ApprovalState; 2] = [Self::Pending, Self::NeedsRework]; + + /// An active state still moves through the workflow (idempotency + expiry + /// apply); a terminal state never transitions again. + #[must_use] + pub fn is_active(self) -> bool { + matches!(self, Self::Pending | Self::NeedsRework) + } +} + +#[cfg(test)] +#[path = "approval_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/approval/intent.rs b/gears/bss/ledger/ledger/src/domain/approval/intent.rs new file mode 100644 index 000000000..a25751240 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/approval/intent.rs @@ -0,0 +1,1077 @@ +//! Serializable replay payloads for dual-control approvals. Stored as the +//! `ledger_approval.intent` jsonb at create-pending time and replayed verbatim by +//! the `ApprovalExecutor` on approve — so the executed mutation is exactly the one +//! the preparer submitted (and edited on resubmit). Phase 1 covered the three +//! seams on the payments-and-allocation base (reverse / credit-grant / +//! chargeback-loss); Phase 2 adds payer-closure and material-backdating. The +//! period-reopen intent lands with Slice 7 (no reopen operation exists yet). + +use std::str::FromStr; + +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use super::ApprovalKind; +use crate::domain::adjustment::credit_note::CreditNoteRequest; +use crate::domain::adjustment::debit_note::DebitNoteRequest; +use crate::domain::adjustment::manual::{ + ManualAdjustmentAction, ManualAdjustmentRequest, ManualLeg, +}; +use crate::domain::adjustment::refund::{ + RefundDirection, RefundPattern, RefundPhase, RefundRequest, +}; +use crate::domain::error::DomainError; +use crate::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use crate::domain::recognition::input::{RecognitionInput, RecognitionTiming}; + +/// A governed mutation captured for later replay, discriminated by `kind`. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ApprovalIntent { + Reverse(ReverseIntent), + CreditGrant(CreditGrantIntent), + ChargebackLoss(ChargebackLossIntent), + PayerClosure(PayerClosureIntent), + PeriodReopen(PeriodReopenIntent), + MaterialBackdating(BackdatedPost), + RecognitionScheduleChange(RecognitionScheduleChangeIntent), + Refund(RefundIntent), + ManualAdjustment(ManualAdjustmentIntent), + CreditNote(CreditNoteIntent), + DebitNote(DebitNoteIntent), + /// An over-threshold refund-with-credit-note composite (K-3): carries BOTH + /// snapshots so the approved replay re-drives the SAME atomic two-entry post. + /// Stamped as [`ApprovalKind::Refund`] (it rides the refund's D2 grain), but the + /// executor replays the composite, not a bare refund. + RefundWithCreditNote(RefundWithCreditNoteIntent), +} + +impl ApprovalIntent { + /// The operation's TRANSACTION currency for the FX-aware dual-control threshold + /// (DC10): the D2 threshold is held in the tenant's FUNCTIONAL (reporting) + /// currency, so the dual-control gate translates the comparand from this + /// currency before comparing. `None` for non-amount kinds (payer-closure / + /// material-backdating) and for the two whose comparand is derived at gate time, + /// so their currency is not carried on the stored intent (`Reverse` reads the + /// original entry; `RecognitionScheduleChange` reads the schedule) — those keep + /// the pre-FX transaction-currency comparand (single-currency-correct; a + /// documented residual until the currency rides those intents). + #[must_use] + pub fn transaction_currency(&self) -> Option<&str> { + match self { + Self::Refund(i) => Some(&i.currency), + Self::RefundWithCreditNote(i) => Some(&i.refund.currency), + Self::CreditGrant(i) => Some(&i.currency), + Self::ChargebackLoss(i) => Some(&i.currency), + Self::ManualAdjustment(i) => Some(&i.currency), + Self::CreditNote(i) => Some(&i.currency), + Self::DebitNote(i) => Some(&i.currency), + Self::Reverse(_) + | Self::PayerClosure(_) + | Self::MaterialBackdating(_) + | Self::RecognitionScheduleChange(_) + | Self::PeriodReopen(_) => None, + } + } +} + +/// Replay payload for a fiscal-period reopen (Slice 7, design §7 / N-core-3): the +/// `CLOSED → REOPENED` transition (`fiscal_period` flipped back to `OPEN`) for the +/// `(tenant, legal_entity, period)` it targets. Always dual-control (policy +/// `requires_dual_control` returns `true` for `PeriodReopen`); the amount is +/// structural, so `amount_minor` / `currency` return `None`. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_field_names, + reason = "(tenant, legal_entity, period) is the canonical fiscal-period coordinate; the _id suffix is the domain convention, not redundant naming" +)] +pub struct PeriodReopenIntent { + pub tenant_id: Uuid, + pub legal_entity_id: Uuid, + pub period_id: String, +} + +/// Replay payload for a reversal. The reversed amount is derived from the original +/// entry at gate time (so it is not carried here); tenant + actor come from the +/// approve request's `ctx`. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReverseIntent { + pub entry_id: Uuid, + pub into_period_id: Option, + pub effective_at: Option, + pub reason: String, +} + +/// Replay payload for a high-value reusable-credit grant. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreditGrantIntent { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub credit_application_id: String, + pub currency: String, + pub amount_minor: i64, + pub credit_grant_event_type: Option, +} + +/// Replay payload for a chargeback-loss (`LOST`) dispute phase. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChargebackLossIntent { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub payment_id: String, + pub dispute_id: String, + pub invoice_id: Option, + pub cycle: i32, + pub funds_at_open: String, + pub disputed_amount_minor: i64, + pub currency: String, +} + +/// Replay payload for a payer-closure (sets `lifecycle_state = CLOSED`). The +/// `disposition` records the customer-balance election when closing with a +/// positive balance (design 01 §4.2); `tenant_id` is the seller ledger. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayerClosureIntent { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub closed_with_open_balance: bool, + pub disposition: Option, +} + +/// Replay payload for an ASC 606 recognition-schedule change/cancel (Group H × +/// dual-control). A plain-type mirror of the SDK `ChangeRecognitionSchedule` +/// command (no SDK import in `domain`): the api handler builds it from the command +/// at gate time, and the executor rebuilds the command from it on approve and +/// replays `change_recognition_schedule` (idempotent on `change_id`). The threshold +/// amount (the schedule's un-recognized deferred remainder) is read from the +/// schedule by the gate — like `Reverse` — so it is not carried here. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecognitionScheduleChangeIntent { + pub tenant_id: Uuid, + pub schedule_id: String, + pub change_id: String, + pub action: String, + pub treatment: String, + pub new_segments: Option>, +} + +/// One replacement segment in a [`RecognitionScheduleChangeIntent`] — the plain +/// mirror of the SDK `ChangeSegment` (`None`-list on a `cancel`). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecognitionChangeSegment { + pub period_id: String, + pub amount_minor: i64, +} + +/// Replay payload for a high-value refund (Slice 3 Group D × dual-control, +/// design §4.4 / §1.4 D2). A plain-type serde mirror of the domain +/// [`RefundRequest`] (no SDK/enum-with-no-serde imported into the stored jsonb): +/// `phase` + `pattern` are stored as their stable `as_str` tokens and rebuilt via +/// `parse` on approve (precedent: `BackdatedInvoiceItem.account_class`). The whole +/// request is carried because a refund is gated BEFORE its post (the journal entry +/// does not exist at gate time — like `MaterialBackdating`, unlike `Reverse`); the +/// executor rebuilds the [`RefundRequest`] from this and re-drives +/// `RefundHandler::post_refund` idempotently (the engine's +/// `(tenant, REFUND, psp_refund_id:phase)` claim makes the replay at-most-once). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +// The `*_id` fields mirror the storage / domain column names verbatim (the same +// `allow` `RefundRequest` carries). +#[allow(clippy::struct_field_names)] +pub struct RefundIntent { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub refund_id: String, + pub psp_refund_id: String, + /// The phase wire literal (`RefundPhase::as_str`), rebuilt via + /// `RefundPhase::parse` on replay. + pub phase: String, + /// The pattern wire literal (`RefundPattern::as_str`), rebuilt via + /// `RefundPattern::parse` on replay. + pub pattern: String, + pub payment_id: String, + pub invoice_id: Option, + pub currency: String, + pub amount_minor: i64, + pub two_stage: bool, + /// The prior refund this one claws back / extends (refund-of-refund, Group E); + /// `None` for a first-order refund. Snapshotted verbatim so the approved replay + /// re-drives the SAME self-link. + pub relates_to_refund_id: Option, + /// The economic direction wire literal (`RefundDirection::as_str`), rebuilt via + /// `RefundDirection::parse` on replay. Carries which money-out effect an + /// over-threshold refund-of-refund had at gate time (claw-back vs outbound). + pub direction: String, +} + +impl From<&RefundRequest> for RefundIntent { + fn from(req: &RefundRequest) -> Self { + Self { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + refund_id: req.refund_id.clone(), + psp_refund_id: req.psp_refund_id.clone(), + phase: req.phase.as_str().to_owned(), + pattern: req.pattern.as_str().to_owned(), + payment_id: req.payment_id.clone(), + invoice_id: req.invoice_id.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + two_stage: req.two_stage, + relates_to_refund_id: req.relates_to_refund_id.clone(), + direction: req.direction.as_str().to_owned(), + } + } +} + +impl TryFrom<&RefundIntent> for RefundRequest { + type Error = DomainError; + + fn try_from(i: &RefundIntent) -> Result { + let phase = RefundPhase::parse(&i.phase).ok_or_else(|| { + DomainError::Internal(format!("refund replay: unknown phase token {:?}", i.phase)) + })?; + let pattern = RefundPattern::parse(&i.pattern).ok_or_else(|| { + DomainError::Internal(format!( + "refund replay: unknown pattern token {:?}", + i.pattern + )) + })?; + let direction = RefundDirection::parse(&i.direction).ok_or_else(|| { + DomainError::Internal(format!( + "refund replay: unknown direction token {:?}", + i.direction + )) + })?; + Ok(Self { + tenant_id: i.tenant_id, + payer_tenant_id: i.payer_tenant_id, + refund_id: i.refund_id.clone(), + psp_refund_id: i.psp_refund_id.clone(), + phase, + pattern, + payment_id: i.payment_id.clone(), + invoice_id: i.invoice_id.clone(), + currency: i.currency.clone(), + amount_minor: i.amount_minor, + two_stage: i.two_stage, + relates_to_refund_id: i.relates_to_refund_id.clone(), + direction, + }) + } +} + +/// One leg of a [`ManualAdjustmentIntent`] — a plain-type serde mirror of the domain +/// [`ManualLeg`]. `account_class` + `side` are the SDK enums [`AccountClass`] / +/// [`Side`] (no serde — dylint DE0101), so they are stored as their stable `as_str` +/// tokens and rebuilt via `parse` (`FromStr`) on replay (precedent: +/// [`BackdatedInvoiceItem.account_class`](BackdatedInvoiceItem)). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManualLegIntent { + /// The `AccountClass::as_str` token, rebuilt via `AccountClass::from_str` on + /// replay. + pub account_class: String, + /// The `Side::as_str` token (`DR` / `CR`), rebuilt via `Side::from_str` on replay. + pub side: String, + pub amount_minor: i64, + pub revenue_stream: Option, +} + +/// Replay payload for an over-threshold governed manual adjustment (Slice 3 +/// Group 5 / Phase 3 governance, design §4.6 / §1.4 D2). A plain-type serde mirror +/// of the domain [`ManualAdjustmentRequest`] (no SDK/enum-with-no-serde stored in the +/// jsonb): `action` is stored as its `as_str` token and rebuilt via +/// `ManualAdjustmentAction::parse`, and each leg's `account_class` / `side` are +/// stored as their `as_str` tokens and rebuilt via `parse` (precedent: +/// [`RefundIntent`] / [`BackdatedInvoiceItem`]). The whole request is carried because +/// a manual adjustment is gated BEFORE its post (the journal entry does not exist at +/// gate time — like a refund); the executor rebuilds the [`ManualAdjustmentRequest`] +/// and re-drives [`post_manual_adjustment_approved`](crate::infra::adjustment::manual_adjustment_service::ManualAdjustmentHandler::post_manual_adjustment_approved) +/// idempotently (the engine's `(tenant, MANUAL_ADJUSTMENT, adjustment_id)` claim makes +/// the replay at-most-once). +/// +/// **No `tax` field.** The MVP governed actions move no tax — `TAX_PAYABLE` is in NO +/// action's allow-list (so a governed manual adjustment can never carry a tax leg) — +/// so the snapshot does not store it; the rebuilt request restores `tax: Vec::new()`. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +// The `*_id` fields mirror the storage / domain column names verbatim. +#[allow(clippy::struct_field_names)] +pub struct ManualAdjustmentIntent { + pub tenant_id: Uuid, + pub payer_tenant_id: Option, + pub adjustment_id: String, + /// The action wire literal (`ManualAdjustmentAction::as_str`), rebuilt via + /// `ManualAdjustmentAction::parse` on replay. + pub action: String, + pub currency: String, + pub legs: Vec, + pub reason_code: String, + pub preparer_actor_id: Uuid, + pub approver_actor_id: Option, +} + +impl From<&ManualAdjustmentRequest> for ManualAdjustmentIntent { + fn from(req: &ManualAdjustmentRequest) -> Self { + Self { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + adjustment_id: req.adjustment_id.clone(), + action: req.action.as_str().to_owned(), + currency: req.currency.clone(), + legs: req + .legs + .iter() + .map(|l| ManualLegIntent { + account_class: l.account_class.as_str().to_owned(), + side: l.side.as_str().to_owned(), + amount_minor: l.amount_minor, + revenue_stream: l.revenue_stream.clone(), + }) + .collect(), + reason_code: req.reason_code.clone(), + preparer_actor_id: req.preparer_actor_id, + approver_actor_id: req.approver_actor_id, + } + } +} + +impl TryFrom<&ManualAdjustmentIntent> for ManualAdjustmentRequest { + type Error = DomainError; + + fn try_from(i: &ManualAdjustmentIntent) -> Result { + let action = ManualAdjustmentAction::parse(&i.action).ok_or_else(|| { + DomainError::Internal(format!( + "manual adjustment replay: unknown action token {:?}", + i.action + )) + })?; + let legs = i + .legs + .iter() + .map(|l| { + let account_class = l.account_class.parse::().map_err(|_| { + DomainError::Internal(format!( + "manual adjustment replay: unknown account_class token {:?}", + l.account_class + )) + })?; + let side = l.side.parse::().map_err(|_| { + DomainError::Internal(format!( + "manual adjustment replay: unknown side token {:?}", + l.side + )) + })?; + Ok(ManualLeg { + account_class, + side, + amount_minor: l.amount_minor, + revenue_stream: l.revenue_stream.clone(), + }) + }) + .collect::, DomainError>>()?; + Ok(Self { + tenant_id: i.tenant_id, + payer_tenant_id: i.payer_tenant_id, + adjustment_id: i.adjustment_id.clone(), + action, + currency: i.currency.clone(), + legs, + reason_code: i.reason_code.clone(), + preparer_actor_id: i.preparer_actor_id, + approver_actor_id: i.approver_actor_id, + // The MVP governed actions move no tax (TAX_PAYABLE is in no allow-list), + // so the snapshot carries none — rebuild empty. + tax: Vec::new(), + }) + } +} + +impl ManualAdjustmentIntent { + /// Gross adjustment amount in minor units = `Σ DR` (== `Σ CR`; `govern` balanced + /// the legs). `i128` fold to avoid an intermediate overflow, saturating at + /// `i64::MAX` (the post / `govern` guards reject an out-of-i64 set). This is the + /// D2 comparand — matching the gross the handler passes the gate (so the resubmit + /// re-evaluation reads the same amount). + fn gross_minor(&self) -> i64 { + let dr_token = Side::Debit.as_str(); + let dr: i128 = self + .legs + .iter() + .filter(|l| l.side == dr_token) + .map(|l| i128::from(l.amount_minor)) + .sum(); + i64::try_from(dr).unwrap_or(i64::MAX) + } +} + +/// Replay payload for an over-threshold credit note (Slice 3 Phase 1 × dual-control, +/// design §5 D1–D2). A plain-type serde mirror of the domain [`CreditNoteRequest`] +/// (no SDK/enum-with-no-serde stored in the jsonb): the `tax` breakdown reuses the +/// [`BackdatedTaxBreakdown`] mirror. The whole request is carried because a credit +/// note is gated BEFORE its post (the journal entry does not exist at gate time — +/// like a refund); the executor rebuilds the [`CreditNoteRequest`] and re-drives +/// `post_credit_note_approved` idempotently (the engine's +/// `(tenant, CREDIT_NOTE, credit_note_id)` claim makes the replay at-most-once). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +// The `*_id` / `*_ref` / `*_group` fields mirror the domain column names verbatim. +#[allow(clippy::struct_field_names)] +pub struct CreditNoteIntent { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub credit_note_id: String, + pub origin_invoice_id: String, + pub origin_invoice_item_ref: Option, + pub po_allocation_group: Option, + pub revenue_stream: String, + pub currency: String, + pub amount_minor: i64, + pub tax_minor: i64, + /// The authoritative tax breakdown dims, mirrored verbatim (sums to `tax_minor`). + pub tax: Vec, + pub requested_deferred_minor: i64, + pub reason_code: String, + pub goodwill: bool, +} + +impl From<&CreditNoteRequest> for CreditNoteIntent { + fn from(req: &CreditNoteRequest) -> Self { + Self { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + credit_note_id: req.credit_note_id.clone(), + origin_invoice_id: req.origin_invoice_id.clone(), + origin_invoice_item_ref: req.origin_invoice_item_ref.clone(), + po_allocation_group: req.po_allocation_group.clone(), + revenue_stream: req.revenue_stream.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + tax_minor: req.tax_minor, + tax: req.tax.iter().map(BackdatedTaxBreakdown::from).collect(), + requested_deferred_minor: req.requested_deferred_minor, + reason_code: req.reason_code.clone(), + goodwill: req.goodwill, + } + } +} + +impl From<&CreditNoteIntent> for CreditNoteRequest { + fn from(i: &CreditNoteIntent) -> Self { + Self { + tenant_id: i.tenant_id, + payer_tenant_id: i.payer_tenant_id, + credit_note_id: i.credit_note_id.clone(), + origin_invoice_id: i.origin_invoice_id.clone(), + origin_invoice_item_ref: i.origin_invoice_item_ref.clone(), + po_allocation_group: i.po_allocation_group.clone(), + revenue_stream: i.revenue_stream.clone(), + currency: i.currency.clone(), + amount_minor: i.amount_minor, + tax_minor: i.tax_minor, + tax: i.tax.iter().map(TaxBreakdown::from).collect(), + requested_deferred_minor: i.requested_deferred_minor, + reason_code: i.reason_code.clone(), + goodwill: i.goodwill, + } + } +} + +/// Replay payload for an over-threshold debit note (Slice 3 Phase 1 × dual-control, +/// design §5 D1–D2). A plain-type serde mirror of the domain [`DebitNoteRequest`]; +/// the optional `recognition` spec (a deferred debit note builds a schedule) is +/// carried via [`DebitNoteRecognitionSnapshot`] so the approved replay rebuilds the +/// SAME schedule. The executor re-drives `post_debit_note_approved` idempotently +/// (the engine's `(tenant, DEBIT_NOTE, debit_note_id)` claim makes it at-most-once). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[allow(clippy::struct_field_names)] +pub struct DebitNoteIntent { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub debit_note_id: String, + pub origin_invoice_id: String, + pub origin_invoice_item_ref: Option, + pub revenue_stream: String, + pub currency: String, + pub amount_minor: i64, + pub tax_minor: i64, + pub tax: Vec, + pub deferred_minor: i64, + pub reason_code: String, + /// The ASC 606 recognition spec for a deferred debit note (Slice 4); `None` for + /// a fully-recognized note. Carried so a deferred over-D2 debit note rebuilds its + /// schedule on the approved replay (faithful K-3-style replay). + pub recognition: Option, +} + +/// Serde mirror of [`RecognitionInput`] — primitives + the [`DebitNoteTimingSnapshot`] +/// timing mirror (the domain timing carries no serde). Round-trips losslessly. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DebitNoteRecognitionSnapshot { + pub policy_ref: String, + pub timing: DebitNoteTimingSnapshot, + pub po_allocation_group: Option, + pub multi_po: bool, + pub ssp_snapshot_ref: Option, + pub subscription_ref: Option, + pub vc_estimate_ref: Option, + pub vc_method_ref: Option, + pub immaterial_one_shot_sku: bool, +} + +/// Serde mirror of [`RecognitionTiming`] (`POINT_IN_TIME` / `STRAIGHT_LINE { periods, +/// first_period_id }`). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "timing", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DebitNoteTimingSnapshot { + PointInTime, + StraightLine { + periods: u32, + first_period_id: Option, + }, +} + +impl From<&RecognitionTiming> for DebitNoteTimingSnapshot { + fn from(t: &RecognitionTiming) -> Self { + match t { + RecognitionTiming::PointInTime => Self::PointInTime, + RecognitionTiming::StraightLine { + periods, + first_period_id, + } => Self::StraightLine { + periods: *periods, + first_period_id: first_period_id.clone(), + }, + } + } +} + +impl From<&DebitNoteTimingSnapshot> for RecognitionTiming { + fn from(t: &DebitNoteTimingSnapshot) -> Self { + match t { + DebitNoteTimingSnapshot::PointInTime => Self::PointInTime, + DebitNoteTimingSnapshot::StraightLine { + periods, + first_period_id, + } => Self::StraightLine { + periods: *periods, + first_period_id: first_period_id.clone(), + }, + } + } +} + +impl From<&RecognitionInput> for DebitNoteRecognitionSnapshot { + fn from(r: &RecognitionInput) -> Self { + Self { + policy_ref: r.policy_ref.clone(), + timing: DebitNoteTimingSnapshot::from(&r.timing), + po_allocation_group: r.po_allocation_group.clone(), + multi_po: r.multi_po, + ssp_snapshot_ref: r.ssp_snapshot_ref.clone(), + subscription_ref: r.subscription_ref.clone(), + vc_estimate_ref: r.vc_estimate_ref.clone(), + vc_method_ref: r.vc_method_ref.clone(), + immaterial_one_shot_sku: r.immaterial_one_shot_sku, + } + } +} + +impl From<&DebitNoteRecognitionSnapshot> for RecognitionInput { + fn from(s: &DebitNoteRecognitionSnapshot) -> Self { + Self { + policy_ref: s.policy_ref.clone(), + timing: RecognitionTiming::from(&s.timing), + po_allocation_group: s.po_allocation_group.clone(), + multi_po: s.multi_po, + ssp_snapshot_ref: s.ssp_snapshot_ref.clone(), + subscription_ref: s.subscription_ref.clone(), + vc_estimate_ref: s.vc_estimate_ref.clone(), + vc_method_ref: s.vc_method_ref.clone(), + immaterial_one_shot_sku: s.immaterial_one_shot_sku, + } + } +} + +impl From<&DebitNoteRequest> for DebitNoteIntent { + fn from(req: &DebitNoteRequest) -> Self { + Self { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + debit_note_id: req.debit_note_id.clone(), + origin_invoice_id: req.origin_invoice_id.clone(), + origin_invoice_item_ref: req.origin_invoice_item_ref.clone(), + revenue_stream: req.revenue_stream.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + tax_minor: req.tax_minor, + tax: req.tax.iter().map(BackdatedTaxBreakdown::from).collect(), + deferred_minor: req.deferred_minor, + reason_code: req.reason_code.clone(), + recognition: req + .recognition + .as_ref() + .map(DebitNoteRecognitionSnapshot::from), + } + } +} + +impl From<&DebitNoteIntent> for DebitNoteRequest { + fn from(i: &DebitNoteIntent) -> Self { + Self { + tenant_id: i.tenant_id, + payer_tenant_id: i.payer_tenant_id, + debit_note_id: i.debit_note_id.clone(), + origin_invoice_id: i.origin_invoice_id.clone(), + origin_invoice_item_ref: i.origin_invoice_item_ref.clone(), + revenue_stream: i.revenue_stream.clone(), + currency: i.currency.clone(), + amount_minor: i.amount_minor, + tax_minor: i.tax_minor, + tax: i.tax.iter().map(TaxBreakdown::from).collect(), + deferred_minor: i.deferred_minor, + reason_code: i.reason_code.clone(), + recognition: i.recognition.as_ref().map(RecognitionInput::from), + } + } +} + +/// Replay payload for an over-threshold refund-with-credit-note composite (Slice 3 +/// Group G / K-3 × dual-control). Carries BOTH the refund and the credit-note +/// snapshots so the approved replay re-drives the SAME atomic two-entry post (the +/// refund's `(tenant, REFUND, psp_refund_id:phase)` claim is the composite's primary +/// idempotency grain). Stamped as [`ApprovalKind::Refund`] — it rides the refund's D2 +/// grain — but the executor replays `post_refund_with_credit_note_approved`, NOT a +/// bare refund (the bug Z5-1 fixed: a plain `Refund` intent dropped the credit note). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RefundWithCreditNoteIntent { + pub refund: RefundIntent, + pub credit_note: CreditNoteIntent, +} + +impl RefundWithCreditNoteIntent { + /// Build from the two domain requests at gate time. + #[must_use] + pub fn from_requests(refund: &RefundRequest, credit_note: &CreditNoteRequest) -> Self { + Self { + refund: RefundIntent::from(refund), + credit_note: CreditNoteIntent::from(credit_note), + } + } + + /// Rebuild the two domain requests on the approved replay. The refund half is + /// fallible (its phase/pattern/direction tokens parse); the credit-note half is + /// infallible. + /// + /// # Errors + /// [`DomainError::Internal`] if a refund token fails to parse. + pub fn to_requests(&self) -> Result<(RefundRequest, CreditNoteRequest), DomainError> { + let refund = RefundRequest::try_from(&self.refund)?; + let credit_note = CreditNoteRequest::from(&self.credit_note); + Ok((refund, credit_note)) + } +} + +/// A materially-backdated post captured for replay (design J). Backdating writes +/// a brand-new entry whose `effective_at` predates the tenant's A6 window — so at +/// gate time the ledger object does NOT exist yet (the gate fires *before* the +/// post), and the source invoice is external (pushed in the request body, never +/// pulled by the ledger). There is therefore no id to reference on approve: the +/// whole post is carried here. The variant discriminates which surface the +/// backdated post replays through; invoice-post is the first seam, and +/// settle / return / allocate / dispute / credit extend it (each gates the same +/// A6 way and replays its own command). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "post", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum BackdatedPost { + Invoice(BackdatedInvoiceSnapshot), +} + +impl BackdatedPost { + /// The external business id — the DC13 active-uniqueness key. + fn business_id(&self) -> &str { + match self { + Self::Invoice(s) => &s.invoice_id, + } + } + + /// Gross minor of the backdated post (for the approval-queue display). + fn gross_minor(&self) -> i64 { + match self { + Self::Invoice(s) => s.gross_minor(), + } + } + + /// The post currency, if any. + fn currency(&self) -> Option<&str> { + match self { + Self::Invoice(s) => s.currency(), + } + } +} + +/// Serializable mirror of [`PostedInvoice`] (`domain::invoice::builder`). The SDK +/// `PostedInvoice`/`InvoiceItem` carry `AccountClass`, a contract enum with no +/// serde (dylint DE0101), and a DTO may not live in `domain` (DE0301) — so the +/// replay payload mirrors the domain primitive here with serde-able fields, +/// storing each `AccountClass` as its stable `as_str` token (rebuilt via +/// `from_str` on replay). Precedent: `pending_event_queue.payload`. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackdatedInvoiceSnapshot { + pub invoice_id: String, + pub payer_tenant_id: Uuid, + pub resource_tenant_id: Option, + pub seller_tenant_id: Uuid, + pub effective_at: NaiveDate, + pub due_date: Option, + pub period_id: String, + pub items: Vec, + pub tax: Vec, + pub posted_by_actor_id: Uuid, + pub correlation_id: Uuid, +} + +impl BackdatedInvoiceSnapshot { + /// Gross receivable in minor units (`Σ items ex-tax + Σ tax`), mirroring + /// [`PostedInvoice::gross_minor`] — `i128` fold to avoid an intermediate + /// overflow, saturating at `i64::MAX` (the post guards reject an overflow). + fn gross_minor(&self) -> i64 { + let items: i128 = self + .items + .iter() + .map(|i| i128::from(i.amount_minor_ex_tax)) + .sum(); + let tax: i128 = self.tax.iter().map(|t| i128::from(t.amount_minor)).sum(); + i64::try_from(items + tax).unwrap_or(i64::MAX) + } + + /// The post currency — first item, else first tax breakdown. + fn currency(&self) -> Option<&str> { + self.items + .first() + .map(|i| i.currency.as_str()) + .or_else(|| self.tax.first().map(|t| t.currency.as_str())) + } +} + +/// Serializable mirror of [`InvoiceItem`] — the `account_class` fields are stored +/// as the `AccountClass::as_str` token (see [`BackdatedInvoiceSnapshot`]). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +// Field names mirror `InvoiceItem` / the storage columns verbatim. +#[allow(clippy::struct_field_names)] +pub struct BackdatedInvoiceItem { + pub amount_minor_ex_tax: i64, + pub currency: String, + pub revenue_stream: String, + pub catalog_class: Option, + pub contract_class: Option, + pub gl_code: Option, + pub invoice_item_ref: Option, + pub sku_or_plan_ref: Option, + pub price_id: Option, + pub pricing_snapshot_ref: Option, +} + +/// Serializable mirror of [`TaxBreakdown`] (all-primitive fields). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackdatedTaxBreakdown { + pub amount_minor: i64, + pub currency: String, + pub tax_jurisdiction: String, + pub tax_filing_period: String, + pub tax_rate_ref: Option, +} + +impl From<&PostedInvoice> for BackdatedInvoiceSnapshot { + fn from(inv: &PostedInvoice) -> Self { + Self { + invoice_id: inv.invoice_id.clone(), + payer_tenant_id: inv.payer_tenant_id, + resource_tenant_id: inv.resource_tenant_id, + seller_tenant_id: inv.seller_tenant_id, + effective_at: inv.effective_at, + due_date: inv.due_date, + period_id: inv.period_id.clone(), + items: inv.items.iter().map(BackdatedInvoiceItem::from).collect(), + tax: inv.tax.iter().map(BackdatedTaxBreakdown::from).collect(), + posted_by_actor_id: inv.posted_by_actor_id, + correlation_id: inv.correlation_id, + } + } +} + +impl From<&InvoiceItem> for BackdatedInvoiceItem { + fn from(i: &InvoiceItem) -> Self { + Self { + amount_minor_ex_tax: i.amount_minor_ex_tax, + currency: i.currency.clone(), + revenue_stream: i.revenue_stream.clone(), + catalog_class: i.catalog_class.map(|c| c.as_str().to_owned()), + contract_class: i.contract_class.map(|c| c.as_str().to_owned()), + gl_code: i.gl_code.clone(), + invoice_item_ref: i.invoice_item_ref.clone(), + sku_or_plan_ref: i.sku_or_plan_ref.clone(), + price_id: i.price_id.clone(), + pricing_snapshot_ref: i.pricing_snapshot_ref.clone(), + } + } +} + +impl From<&TaxBreakdown> for BackdatedTaxBreakdown { + fn from(t: &TaxBreakdown) -> Self { + Self { + amount_minor: t.amount_minor, + currency: t.currency.clone(), + tax_jurisdiction: t.tax_jurisdiction.clone(), + tax_filing_period: t.tax_filing_period.clone(), + tax_rate_ref: t.tax_rate_ref.clone(), + } + } +} + +impl TryFrom<&BackdatedInvoiceSnapshot> for PostedInvoice { + type Error = DomainError; + + fn try_from(s: &BackdatedInvoiceSnapshot) -> Result { + let items = s + .items + .iter() + .map(InvoiceItem::try_from) + .collect::, _>>()?; + Ok(Self { + invoice_id: s.invoice_id.clone(), + payer_tenant_id: s.payer_tenant_id, + resource_tenant_id: s.resource_tenant_id, + seller_tenant_id: s.seller_tenant_id, + effective_at: s.effective_at, + due_date: s.due_date, + period_id: s.period_id.clone(), + items, + tax: s.tax.iter().map(TaxBreakdown::from).collect(), + posted_by_actor_id: s.posted_by_actor_id, + correlation_id: s.correlation_id, + }) + } +} + +impl TryFrom<&BackdatedInvoiceItem> for InvoiceItem { + type Error = DomainError; + + fn try_from(i: &BackdatedInvoiceItem) -> Result { + Ok(Self { + amount_minor_ex_tax: i.amount_minor_ex_tax, + // SEAM (dual-control × Slice 4 recognition): the backdating snapshot + // `BackdatedInvoiceItem` predates ASC 606 recognition and does not + // capture the deferred split / schedule spec, so a replayed backdated + // post is treated as non-deferred — no recognition schedule is rebuilt. + // To let a backdated post carry recognition, add `deferred_minor` + + // `recognition` to the snapshot and thread them through here. + deferred_minor: 0, + currency: i.currency.clone(), + revenue_stream: i.revenue_stream.clone(), + catalog_class: parse_account_class(i.catalog_class.as_deref())?, + contract_class: parse_account_class(i.contract_class.as_deref())?, + gl_code: i.gl_code.clone(), + recognition: None, + invoice_item_ref: i.invoice_item_ref.clone(), + sku_or_plan_ref: i.sku_or_plan_ref.clone(), + price_id: i.price_id.clone(), + pricing_snapshot_ref: i.pricing_snapshot_ref.clone(), + }) + } +} + +impl From<&BackdatedTaxBreakdown> for TaxBreakdown { + fn from(t: &BackdatedTaxBreakdown) -> Self { + Self { + amount_minor: t.amount_minor, + currency: t.currency.clone(), + tax_jurisdiction: t.tax_jurisdiction.clone(), + tax_filing_period: t.tax_filing_period.clone(), + tax_rate_ref: t.tax_rate_ref.clone(), + } + } +} + +/// Parse a stored `AccountClass` token back to the enum (`None` passes through). +/// A corrupt token fails the replay rather than silently dropping the mapping. +fn parse_account_class(token: Option<&str>) -> Result, DomainError> { + token + .map(|t| { + AccountClass::from_str(t).map_err(|e| { + DomainError::Internal(format!( + "backdating replay: unknown account_class {t:?}: {e}" + )) + }) + }) + .transpose() +} + +impl ApprovalIntent { + /// The discriminator stamped on the `ledger_approval` row. + #[must_use] + pub fn kind(&self) -> ApprovalKind { + match self { + Self::Reverse(_) => ApprovalKind::Reverse, + Self::CreditGrant(_) => ApprovalKind::CreditGrant, + Self::ChargebackLoss(_) => ApprovalKind::ChargebackLoss, + Self::PayerClosure(_) => ApprovalKind::PayerClosure, + Self::PeriodReopen(_) => ApprovalKind::PeriodReopen, + Self::MaterialBackdating(_) => ApprovalKind::MaterialBackdating, + Self::RecognitionScheduleChange(_) => ApprovalKind::RecognitionScheduleChange, + // The composite rides the refund's D2 grain → stamped REFUND (no new + // kind), so it shares the `Refund` arm. + Self::Refund(_) | Self::RefundWithCreditNote(_) => ApprovalKind::Refund, + Self::ManualAdjustment(_) => ApprovalKind::ManualAdjustment, + Self::CreditNote(_) => ApprovalKind::CreditNote, + Self::DebitNote(_) => ApprovalKind::DebitNote, + } + } + + /// The idempotency/business key for the active-uniqueness guard (DC13): one + /// active approval per `(tenant, kind, business_key)`. + #[must_use] + pub fn business_key(&self) -> String { + match self { + Self::Reverse(i) => i.entry_id.to_string(), + Self::CreditGrant(i) => i.credit_application_id.clone(), + Self::ChargebackLoss(i) => format!("{}:{}:LOST", i.dispute_id, i.cycle), + Self::PayerClosure(i) => i.payer_tenant_id.to_string(), + Self::PeriodReopen(i) => format!("{}:{}", i.legal_entity_id, i.period_id), + Self::MaterialBackdating(p) => p.business_id().to_owned(), + // One active approval per change request (the change-service is itself + // idempotent on `change_id`). + Self::RecognitionScheduleChange(i) => i.change_id.clone(), + // Key on the engine's exact idempotency grain (`psp_refund_id:phase`): + // one active approval per PSP-refund phase, so the DC13 active-uniqueness + // slot lines up 1:1 with the at-most-once posting claim the executor + // replays against. + Self::Refund(i) => format!("{}:{}", i.psp_refund_id, i.phase), + // One active approval per adjustment id — lines up 1:1 with the engine's + // `(tenant, MANUAL_ADJUSTMENT, adjustment_id)` at-most-once posting claim + // the executor replays against. + Self::ManualAdjustment(i) => i.adjustment_id.clone(), + // One active approval per note id — lines up 1:1 with the engine's + // `(tenant, {CREDIT,DEBIT}_NOTE, note_id)` at-most-once posting claim. + Self::CreditNote(i) => i.credit_note_id.clone(), + Self::DebitNote(i) => i.debit_note_id.clone(), + // Keyed on the refund grain (`psp_refund_id:phase`), same as a plain + // refund — so a plain refund and a composite for the same PSP-refund + // phase cannot both be active at once. + Self::RefundWithCreditNote(i) => { + format!("{}:{}", i.refund.psp_refund_id, i.refund.phase) + } + } + } + + /// The native-currency minor amount for amount-gated kinds (credit-grant, + /// chargeback-loss, refund, manual-adjustment, material-backdating). `Reverse` / + /// `PayerClosure` / `RecognitionScheduleChange` return `None` — their threshold + /// amount is derived/structural (read from the entry/schedule by the gate), not + /// carried in the intent. + #[must_use] + pub fn amount_minor(&self) -> Option { + match self { + Self::Reverse(_) + | Self::PayerClosure(_) + | Self::PeriodReopen(_) + | Self::RecognitionScheduleChange(_) => None, + Self::CreditGrant(i) => Some(i.amount_minor), + Self::ChargebackLoss(i) => Some(i.disputed_amount_minor), + Self::MaterialBackdating(p) => Some(p.gross_minor()), + Self::Refund(i) => Some(i.amount_minor), + // The gross adjustment amount = Σ DR (== Σ CR; govern balanced the legs). + // `i128` fold then saturate (the post / govern guards reject an out-of-i64 + // set) — matches the D2 comparand the handler passes the gate. + Self::ManualAdjustment(i) => Some(i.gross_minor()), + Self::CreditNote(i) => Some(i.amount_minor), + Self::DebitNote(i) => Some(i.amount_minor), + // The composite gates on its refund leg's cash amount (the credit note + // rides the same approval). + Self::RefundWithCreditNote(i) => Some(i.refund.amount_minor), + } + } + + /// The operation currency for amount-gated kinds (for the USD-eq conversion, + /// DC10). `Reverse` carries no currency here (derived from the original entry). + #[must_use] + pub fn currency(&self) -> Option<&str> { + match self { + Self::Reverse(_) + | Self::PayerClosure(_) + | Self::PeriodReopen(_) + | Self::RecognitionScheduleChange(_) => None, + Self::CreditGrant(i) => Some(&i.currency), + Self::ChargebackLoss(i) => Some(&i.currency), + Self::MaterialBackdating(p) => p.currency(), + Self::Refund(i) => Some(&i.currency), + Self::ManualAdjustment(i) => Some(&i.currency), + Self::CreditNote(i) => Some(&i.currency), + Self::DebitNote(i) => Some(&i.currency), + Self::RefundWithCreditNote(i) => Some(&i.refund.currency), + } + } + + /// True iff `other` addresses the SAME approval target as `self` — same kind + /// and the same immutable recipient/target fields. The ONLY field a resubmit + /// (DC17) may edit is the scalar approval amount on the amount-bearing kinds + /// (`CreditGrant.amount_minor` / `ChargebackLoss.disputed_amount_minor`); every + /// other field (recipient tenant, entry / application / dispute / payment / + /// schedule id, currency, …) is pinned. So a resubmit can lower the amount on + /// rework but CANNOT swap the recipient under the still-frozen `business_key`: + /// a credit-grant's `payer_tenant_id` is orthogonal to its + /// `business_key` (`credit_application_id`), so without this a recipient swap + /// would clear the `kind` + `business_key` guards undetected. Kinds whose amount + /// is derived/structural (`Reverse`, `PayerClosure`, `RecognitionScheduleChange`, + /// `MaterialBackdating`) are compared whole. + #[must_use] + pub fn same_target(&self, other: &Self) -> bool { + match (self, other) { + (Self::CreditGrant(a), Self::CreditGrant(b)) => { + CreditGrantIntent { + amount_minor: 0, + ..a.clone() + } == CreditGrantIntent { + amount_minor: 0, + ..b.clone() + } + } + (Self::ChargebackLoss(a), Self::ChargebackLoss(b)) => { + ChargebackLossIntent { + disputed_amount_minor: 0, + ..a.clone() + } == ChargebackLossIntent { + disputed_amount_minor: 0, + ..b.clone() + } + } + // The remaining kinds carry no editable scalar amount (it is derived or + // structural), so the whole intent is the pinned target identity. + _ => self == other, + } + } +} + +#[cfg(test)] +#[path = "intent_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/approval/intent_tests.rs b/gears/bss/ledger/ledger/src/domain/approval/intent_tests.rs new file mode 100644 index 000000000..df4e8f0e0 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/approval/intent_tests.rs @@ -0,0 +1,521 @@ +//! Unit tests: `ApprovalIntent` jsonb roundtrip + derived keys. + +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::NaiveDate; +use uuid::Uuid; + +use super::{ + ApprovalIntent, BackdatedInvoiceItem, BackdatedInvoiceSnapshot, BackdatedPost, + BackdatedTaxBreakdown, ChargebackLossIntent, CreditGrantIntent, CreditNoteIntent, + DebitNoteIntent, ManualAdjustmentIntent, RecognitionChangeSegment, + RecognitionScheduleChangeIntent, RefundIntent, ReverseIntent, +}; +use crate::domain::adjustment::credit_note::CreditNoteRequest; +use crate::domain::adjustment::debit_note::DebitNoteRequest; +use crate::domain::adjustment::manual::{ + ManualAdjustmentAction, ManualAdjustmentRequest, ManualLeg, +}; +use crate::domain::adjustment::refund::{ + RefundDirection, RefundPattern, RefundPhase, RefundRequest, +}; +use crate::domain::approval::ApprovalKind; +use crate::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use crate::domain::recognition::input::{RecognitionInput, RecognitionTiming}; + +#[test] +fn credit_grant_intent_roundtrips() { + let intent = ApprovalIntent::CreditGrant(CreditGrantIntent { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + credit_application_id: "app-1".to_owned(), + currency: "USD".to_owned(), + amount_minor: 5_000, + credit_grant_event_type: Some("promo".to_owned()), + }); + let value = serde_json::to_value(&intent).unwrap(); + let back: ApprovalIntent = serde_json::from_value(value).unwrap(); + assert_eq!(intent, back); + assert_eq!(intent.kind(), ApprovalKind::CreditGrant); + assert_eq!(intent.business_key(), "app-1"); + assert_eq!(intent.amount_minor(), Some(5_000)); + assert_eq!(intent.currency(), Some("USD")); +} + +#[test] +fn reverse_intent_roundtrips_and_has_no_carried_amount() { + let entry_id = Uuid::now_v7(); + let intent = ApprovalIntent::Reverse(ReverseIntent { + entry_id, + into_period_id: Some("202606".to_owned()), + effective_at: None, + reason: "duplicate".to_owned(), + }); + let back: ApprovalIntent = + serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, back); + assert_eq!(intent.kind(), ApprovalKind::Reverse); + assert_eq!(intent.business_key(), entry_id.to_string()); + assert_eq!( + intent.amount_minor(), + None, + "reverse amount comes from the original entry" + ); +} + +#[test] +fn chargeback_loss_intent_roundtrips_and_keys_by_dispute_cycle() { + let intent = ApprovalIntent::ChargebackLoss(ChargebackLossIntent { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + payment_id: "pay-1".to_owned(), + dispute_id: "disp-1".to_owned(), + invoice_id: None, + cycle: 2, + funds_at_open: "withheld".to_owned(), + disputed_amount_minor: 250_000, + currency: "USD".to_owned(), + }); + let back: ApprovalIntent = + serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, back); + assert_eq!(intent.business_key(), "disp-1:2:LOST"); + assert_eq!(intent.amount_minor(), Some(250_000)); +} + +#[test] +fn recognition_schedule_change_intent_roundtrips_and_keys_by_change_id() { + let intent = ApprovalIntent::RecognitionScheduleChange(RecognitionScheduleChangeIntent { + tenant_id: Uuid::now_v7(), + schedule_id: "sched-1".to_owned(), + change_id: "chg-7".to_owned(), + action: "replace".to_owned(), + treatment: "prospective".to_owned(), + new_segments: Some(vec![ + RecognitionChangeSegment { + period_id: "202607".to_owned(), + amount_minor: 400, + }, + RecognitionChangeSegment { + period_id: "202608".to_owned(), + amount_minor: 400, + }, + ]), + }); + let back: ApprovalIntent = + serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, back); + assert_eq!(intent.kind(), ApprovalKind::RecognitionScheduleChange); + assert_eq!( + intent.business_key(), + "chg-7", + "keyed by the idempotency change_id" + ); + assert_eq!( + intent.amount_minor(), + None, + "the affected deferred remainder is read from the schedule at gate time" + ); + assert_eq!(intent.currency(), None); +} + +#[test] +fn refund_intent_roundtrips_and_keys_by_psp_phase() { + let intent = ApprovalIntent::Refund(RefundIntent { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + refund_id: "rf-9".to_owned(), + psp_refund_id: "psp-9".to_owned(), + phase: RefundPhase::Initiated.as_str().to_owned(), + pattern: RefundPattern::BRestoreAr.as_str().to_owned(), + payment_id: "pay-9".to_owned(), + invoice_id: Some("inv-9".to_owned()), + currency: "USD".to_owned(), + amount_minor: 150_000, + two_stage: true, + relates_to_refund_id: None, + direction: RefundDirection::Outbound.as_str().to_owned(), + }); + // The nested `kind`-tagged enum must survive the jsonb roundtrip verbatim. + let back: ApprovalIntent = + serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, back); + assert_eq!(intent.kind(), ApprovalKind::Refund); + assert_eq!( + intent.business_key(), + "psp-9:initiated", + "keyed by the engine idempotency grain psp_refund_id:phase" + ); + assert_eq!( + intent.amount_minor(), + Some(150_000), + "the returned cash is the D2 comparand" + ); + assert_eq!(intent.currency(), Some("USD")); +} + +#[test] +fn refund_intent_rebuilds_into_an_identical_request() { + let req = RefundRequest { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + refund_id: "rf-1".to_owned(), + psp_refund_id: "psp-1".to_owned(), + phase: RefundPhase::Confirmed, + pattern: RefundPattern::AUnallocated, + payment_id: "pay-1".to_owned(), + invoice_id: None, + currency: "EUR".to_owned(), + amount_minor: 999_999, + two_stage: true, + // A refund-of-refund claw-back so the round-trip also exercises the + // direction + relates_to_refund_id snapshot fields (Group E). + relates_to_refund_id: Some("rf-origin".to_owned()), + direction: RefundDirection::Clawback, + }; + // Snapshot -> jsonb -> snapshot -> RefundRequest reproduces the request exactly + // (the executor's replay path: phase/pattern/direction survive as wire tokens). + let snap = RefundIntent::from(&req); + let back: RefundIntent = serde_json::from_value(serde_json::to_value(&snap).unwrap()).unwrap(); + let rebuilt = RefundRequest::try_from(&back).unwrap(); + assert_eq!(req, rebuilt); +} + +#[test] +fn refund_intent_rejects_unknown_phase_or_pattern_token() { + let mut snap = RefundIntent::from(&RefundRequest { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + refund_id: "rf-1".to_owned(), + psp_refund_id: "psp-1".to_owned(), + phase: RefundPhase::Initiated, + pattern: RefundPattern::AUnallocated, + payment_id: "pay-1".to_owned(), + invoice_id: None, + currency: "USD".to_owned(), + amount_minor: 100, + two_stage: true, + relates_to_refund_id: None, + direction: RefundDirection::Outbound, + }); + snap.phase = "NOT_A_PHASE".to_owned(); + assert!( + RefundRequest::try_from(&snap).is_err(), + "a corrupt phase token must fail the replay, not silently default" + ); + snap.phase = RefundPhase::Initiated.as_str().to_owned(); + snap.pattern = "NOT_A_PATTERN".to_owned(); + assert!( + RefundRequest::try_from(&snap).is_err(), + "a corrupt pattern token must fail the replay" + ); + snap.pattern = RefundPattern::AUnallocated.as_str().to_owned(); + snap.direction = "NOT_A_DIRECTION".to_owned(); + assert!( + RefundRequest::try_from(&snap).is_err(), + "a corrupt direction token must fail the replay, not silently default" + ); +} + +/// A balanced two-leg manual adjustment (no tax) for the round-trip tests: +/// `DR SUSPENSE 1 · CR CASH_CLEARING 1` — a `RoundingCorrection` within its allow-list. +fn sample_manual_request() -> ManualAdjustmentRequest { + ManualAdjustmentRequest { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Some(Uuid::now_v7()), + adjustment_id: "adj-1".to_owned(), + action: ManualAdjustmentAction::RoundingCorrection, + currency: "USD".to_owned(), + legs: vec![ + ManualLeg { + account_class: AccountClass::Suspense, + side: Side::Debit, + amount_minor: 1, + revenue_stream: None, + }, + ManualLeg { + account_class: AccountClass::CashClearing, + side: Side::Credit, + amount_minor: 1, + revenue_stream: None, + }, + ], + reason_code: "ROUNDING".to_owned(), + preparer_actor_id: Uuid::now_v7(), + approver_actor_id: None, + // The MVP governed actions move no tax (TAX_PAYABLE is in no allow-list). + tax: Vec::new(), + } +} + +#[test] +fn manual_adjustment_intent_roundtrips_and_keys_by_adjustment_id() { + let req = sample_manual_request(); + let intent = ApprovalIntent::ManualAdjustment(ManualAdjustmentIntent::from(&req)); + // The nested `kind`-tagged enum must survive the jsonb roundtrip verbatim. + let back: ApprovalIntent = + serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, back); + assert_eq!(intent.kind(), ApprovalKind::ManualAdjustment); + assert_eq!( + intent.business_key(), + "adj-1", + "keyed by the engine idempotency grain adjustment_id" + ); + assert_eq!( + intent.amount_minor(), + Some(1), + "the gross adjustment amount (Σ DR) is the D2 comparand" + ); + assert_eq!(intent.currency(), Some("USD")); +} + +#[test] +fn manual_adjustment_intent_rebuilds_into_an_identical_request() { + let req = sample_manual_request(); + // Snapshot -> jsonb -> snapshot -> ManualAdjustmentRequest reproduces the request + // exactly (the executor's replay path: action/class/side survive as wire tokens, + // tax is rebuilt empty as it is never carried). + let snap = ManualAdjustmentIntent::from(&req); + let back: ManualAdjustmentIntent = + serde_json::from_value(serde_json::to_value(&snap).unwrap()).unwrap(); + let rebuilt = ManualAdjustmentRequest::try_from(&back).unwrap(); + assert_eq!(req.tenant_id, rebuilt.tenant_id); + assert_eq!(req.payer_tenant_id, rebuilt.payer_tenant_id); + assert_eq!(req.adjustment_id, rebuilt.adjustment_id); + assert_eq!(req.action, rebuilt.action); + assert_eq!(req.currency, rebuilt.currency); + assert_eq!(req.reason_code, rebuilt.reason_code); + assert_eq!(req.preparer_actor_id, rebuilt.preparer_actor_id); + assert_eq!(req.approver_actor_id, rebuilt.approver_actor_id); + // Per-leg class + side + amount survive (the SDK enums via as_str/parse). + assert_eq!(req.legs.len(), rebuilt.legs.len()); + for (orig, got) in req.legs.iter().zip(rebuilt.legs.iter()) { + assert_eq!(orig.account_class, got.account_class); + assert_eq!(orig.side, got.side); + assert_eq!(orig.amount_minor, got.amount_minor); + assert_eq!(orig.revenue_stream, got.revenue_stream); + } + // tax is never carried — empty in both. + assert!(req.tax.is_empty()); + assert!(rebuilt.tax.is_empty()); + // The whole request is reproduced exactly. + assert_eq!(req, rebuilt); +} + +#[test] +fn manual_adjustment_intent_rejects_unknown_tokens() { + let mut snap = ManualAdjustmentIntent::from(&sample_manual_request()); + snap.action = "NOT_AN_ACTION".to_owned(); + assert!( + ManualAdjustmentRequest::try_from(&snap).is_err(), + "a corrupt action token must fail the replay, not silently default" + ); + snap.action = ManualAdjustmentAction::RoundingCorrection + .as_str() + .to_owned(); + snap.legs[0].account_class = "NOT_A_CLASS".to_owned(); + assert!( + ManualAdjustmentRequest::try_from(&snap).is_err(), + "a corrupt account_class token must fail the replay" + ); + snap.legs[0].account_class = AccountClass::Suspense.as_str().to_owned(); + snap.legs[0].side = "NOT_A_SIDE".to_owned(); + assert!( + ManualAdjustmentRequest::try_from(&snap).is_err(), + "a corrupt side token must fail the replay" + ); +} + +#[test] +fn credit_note_intent_rebuilds_into_an_identical_request() { + // Z6-1: an over-D2 credit note is gated BEFORE its post, so the snapshot must + // round-trip the WHOLE request (incl. the per-component tax dims) → the approved + // replay re-drives the identical credit note. + let req = CreditNoteRequest { + tenant_id: Uuid::from_u128(1), + payer_tenant_id: Uuid::from_u128(2), + credit_note_id: "cn-1".to_owned(), + origin_invoice_id: "inv-1".to_owned(), + origin_invoice_item_ref: Some("item-1".to_owned()), + po_allocation_group: Some("po-1".to_owned()), + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + amount_minor: 5_000, + tax_minor: 500, + tax: vec![TaxBreakdown { + amount_minor: 500, + currency: "USD".to_owned(), + tax_jurisdiction: "US-CA".to_owned(), + tax_filing_period: "202606".to_owned(), + tax_rate_ref: Some("rate-1".to_owned()), + }], + requested_deferred_minor: 1_000, + reason_code: "SERVICE_CREDIT".to_owned(), + goodwill: false, + }; + let snap = CreditNoteIntent::from(&req); + let back: CreditNoteIntent = + serde_json::from_value(serde_json::to_value(&snap).unwrap()).unwrap(); + let rebuilt = CreditNoteRequest::from(&back); + assert_eq!( + req, rebuilt, + "credit-note replay must reproduce the request exactly" + ); +} + +#[test] +fn debit_note_intent_rebuilds_into_an_identical_request_with_recognition() { + // Z6-1: a DEFERRED over-D2 debit note carries a recognition spec; the snapshot must + // round-trip it (incl. the StraightLine timing) so the approved replay rebuilds the + // SAME schedule. + let req = DebitNoteRequest { + tenant_id: Uuid::from_u128(3), + payer_tenant_id: Uuid::from_u128(4), + debit_note_id: "dn-1".to_owned(), + origin_invoice_id: "inv-2".to_owned(), + origin_invoice_item_ref: Some("item-2".to_owned()), + revenue_stream: "subscription".to_owned(), + currency: "EUR".to_owned(), + amount_minor: 12_000, + tax_minor: 2_000, + tax: vec![TaxBreakdown { + amount_minor: 2_000, + currency: "EUR".to_owned(), + tax_jurisdiction: "DE".to_owned(), + tax_filing_period: "202606".to_owned(), + tax_rate_ref: None, + }], + deferred_minor: 6_000, + reason_code: "UPSELL".to_owned(), + recognition: Some(RecognitionInput { + policy_ref: "policy-1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods: 12, + first_period_id: Some("202607".to_owned()), + }, + po_allocation_group: Some("po-2".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + }; + let snap = DebitNoteIntent::from(&req); + let back: DebitNoteIntent = + serde_json::from_value(serde_json::to_value(&snap).unwrap()).unwrap(); + let rebuilt = DebitNoteRequest::from(&back); + assert_eq!( + req, rebuilt, + "debit-note replay must reproduce the request (incl. recognition) exactly" + ); +} + +fn sample_snapshot() -> BackdatedInvoiceSnapshot { + BackdatedInvoiceSnapshot { + invoice_id: "inv-backdated-1".to_owned(), + payer_tenant_id: Uuid::now_v7(), + resource_tenant_id: None, + seller_tenant_id: Uuid::now_v7(), + effective_at: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), + due_date: Some(NaiveDate::from_ymd_opt(2026, 2, 15).unwrap()), + period_id: "202601".to_owned(), + items: vec![BackdatedInvoiceItem { + amount_minor_ex_tax: 90_000, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some("REVENUE".to_owned()), + contract_class: None, + gl_code: Some("4000".to_owned()), + invoice_item_ref: Some("item-1".to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }], + tax: vec![BackdatedTaxBreakdown { + amount_minor: 10_000, + currency: "USD".to_owned(), + tax_jurisdiction: "US-CA".to_owned(), + tax_filing_period: "2026Q1".to_owned(), + tax_rate_ref: None, + }], + posted_by_actor_id: Uuid::now_v7(), + correlation_id: Uuid::now_v7(), + } +} + +#[test] +fn material_backdating_intent_roundtrips_and_keys_by_invoice() { + let intent = ApprovalIntent::MaterialBackdating(BackdatedPost::Invoice(sample_snapshot())); + // Nested internally-tagged enums (`kind` + `post`) must survive the jsonb roundtrip. + let back: ApprovalIntent = + serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, back); + assert_eq!(intent.kind(), ApprovalKind::MaterialBackdating); + assert_eq!(intent.business_key(), "inv-backdated-1"); + // gross = Σ items ex-tax (90_000) + Σ tax (10_000). + assert_eq!(intent.amount_minor(), Some(100_000)); + assert_eq!(intent.currency(), Some("USD")); +} + +#[test] +fn posted_invoice_to_snapshot_roundtrips_preserving_account_class() { + let original = PostedInvoice { + invoice_id: "inv-1".to_owned(), + payer_tenant_id: Uuid::now_v7(), + resource_tenant_id: Some(Uuid::now_v7()), + seller_tenant_id: Uuid::now_v7(), + effective_at: NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(), + due_date: None, + period_id: "202601".to_owned(), + items: vec![InvoiceItem { + amount_minor_ex_tax: 5_000, + // The backdating snapshot does not capture recognition (see the + // `TryFrom<&BackdatedInvoiceItem>` seam note), so the round-trip yields + // a non-deferred item. + deferred_minor: 0, + currency: "EUR".to_owned(), + revenue_stream: "usage".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: Some(AccountClass::ContractLiability), + gl_code: Some("4100".to_owned()), + recognition: None, + invoice_item_ref: Some("ii-1".to_owned()), + sku_or_plan_ref: Some("sku-9".to_owned()), + price_id: None, + pricing_snapshot_ref: None, + }], + tax: vec![TaxBreakdown { + amount_minor: 950, + currency: "EUR".to_owned(), + tax_jurisdiction: "DE".to_owned(), + tax_filing_period: "2026Q1".to_owned(), + tax_rate_ref: Some("vat-19".to_owned()), + }], + posted_by_actor_id: Uuid::now_v7(), + correlation_id: Uuid::now_v7(), + }; + let snapshot = BackdatedInvoiceSnapshot::from(&original); + // `AccountClass` is stored as its stable string token, not the enum. + assert_eq!(snapshot.items[0].catalog_class.as_deref(), Some("REVENUE")); + assert_eq!( + snapshot.items[0].contract_class.as_deref(), + Some("CONTRACT_LIABILITY") + ); + // Survives a jsonb roundtrip and rebuilds into an identical PostedInvoice. + let back: BackdatedInvoiceSnapshot = + serde_json::from_value(serde_json::to_value(&snapshot).unwrap()).unwrap(); + let rebuilt = PostedInvoice::try_from(&back).unwrap(); + assert_eq!(original, rebuilt); +} + +#[test] +fn snapshot_rebuild_rejects_unknown_account_class_token() { + let mut snapshot = sample_snapshot(); + snapshot.items[0].catalog_class = Some("NOT_A_REAL_CLASS".to_owned()); + assert!( + PostedInvoice::try_from(&snapshot).is_err(), + "a corrupt account_class token must fail the replay, not silently drop" + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/approval/policy.rs b/gears/bss/ledger/ledger/src/domain/approval/policy.rs new file mode 100644 index 000000000..b678da4cf --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/approval/policy.rs @@ -0,0 +1,215 @@ +//! Pure dual-control threshold policy (§4.2). Given the tenant's effective-dated +//! policy versions and an operation's facts, decide whether a governed mutation +//! must go through the preparer→approver flow; and validate tenant config against +//! the ratified ranges. No FX and no clock here — the caller passes the +//! USD-equivalent (computed with the *operation's own* rate snapshot, DC10) and +//! the current date, so the whole module is deterministic and unit-testable. + +use chrono::{DateTime, Datelike, NaiveDate, Utc, Weekday}; +use toolkit_macros::domain_model; + +use super::ApprovalKind; + +/// Ratified platform defaults applied when a tenant has no policy row: +/// D2 = 1000 USD (scale 2) = `100_000` minor (DECISIONS D-1); A6 = 5 business days +/// (foundation §1.4); pending TTL = 7 days (DC12). +pub const DEFAULT_D2_THRESHOLD_MINOR: i64 = 100_000; +pub const DEFAULT_A6_BACKDATING_BIZ_DAYS: i32 = 5; +pub const DEFAULT_PENDING_TTL_SECONDS: i64 = 7 * 24 * 60 * 60; + +/// D2 tenant-config bounds in USD-eq minor: [100 .. 1,000,000] USD (DECISIONS D-1). +pub const D2_MIN_MINOR: i64 = 10_000; +pub const D2_MAX_MINOR: i64 = 100_000_000; +/// A6 tenant-config bounds in business days: [1 .. 30] (foundation §1.4). +pub const A6_MIN_DAYS: i32 = 1; +pub const A6_MAX_DAYS: i32 = 30; + +/// The resolved thresholds in effect for a tenant at a point in time. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DualControlPolicy { + pub d2_threshold_minor: i64, + pub a6_backdating_biz_days: i32, + pub pending_ttl_seconds: i64, +} + +impl DualControlPolicy { + /// The ratified platform defaults (used when a tenant has no policy row). + pub const DEFAULT: Self = Self { + d2_threshold_minor: DEFAULT_D2_THRESHOLD_MINOR, + a6_backdating_biz_days: DEFAULT_A6_BACKDATING_BIZ_DAYS, + pending_ttl_seconds: DEFAULT_PENDING_TTL_SECONDS, + }; +} + +/// One effective-dated policy version (a `ledger_dual_control_policy` row). +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PolicyVersion { + pub effective_from: DateTime, + pub version: i64, + pub policy: DualControlPolicy, +} + +/// Rejected tenant policy config (out of the ratified range — no silent clamp). +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PolicyConfigError { + D2OutOfRange(i64), + A6OutOfRange(i32), + TtlNotPositive(i64), +} + +/// The facts a threshold check needs. This module only COMPARES — it does no FX. +/// +/// **DC10 / FX.** `amount_usd_eq_minor` is the operation's amount valued in the +/// threshold (FUNCTIONAL / reporting) currency. Callers pass the operation's +/// TRANSACTION-currency minor; the dual-control gate (`ApprovalService::gate`) +/// translates it to the tenant's functional currency at the current rate before +/// this module compares — reading the operation currency off the `ApprovalIntent` +/// (`ApprovalIntent::transaction_currency`). A single-currency tenant (or a +/// same-currency op) compares unchanged. This module itself does NO FX — it only +/// compares. Residual: `Reverse` / `RecognitionScheduleChange` derive their +/// comparand at gate time and carry no currency on the stored intent, so they keep +/// the transaction-currency comparand (single-currency-correct) until the currency +/// rides those intents — for those kinds the threshold compares transaction-currency +/// minor, which is exact only while the tenant is single-currency. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OperationFacts { + pub kind: ApprovalKind, + /// The D2 comparand for amount-gated kinds (reverse / credit-grant / chargeback + /// / recognition-schedule-change); `None` for non-amount kinds. Transaction- + /// currency minor today; functional/USD-eq once the FX slice lands (type doc). + pub amount_usd_eq_minor: Option, + /// For `MaterialBackdating`: the effective date of the backdated post. + pub effective_at: Option, + /// For `PayerClosure`: whether the payer holds a non-zero AR or a positive + /// customer balance at closure. + pub has_outstanding_balance: bool, +} + +/// The policy *version* in effect at `now`: the row with the greatest +/// `effective_from <= now`, highest `version` on a tie; `None` when no row +/// applies (the caller then falls back to the ratified +/// [`DualControlPolicy::DEFAULT`]). Carries the `version` + `effective_from` +/// provenance the read surface renders — [`resolve_policy`] keeps only the +/// resolved thresholds. +#[must_use] +pub fn effective_version(versions: &[PolicyVersion], now: DateTime) -> Option { + versions + .iter() + .filter(|v| v.effective_from <= now) + .max_by(|a, b| { + a.effective_from + .cmp(&b.effective_from) + .then(a.version.cmp(&b.version)) + }) + .copied() +} + +/// Resolve the policy in effect at `now`: the row with the greatest +/// `effective_from <= now`, highest `version` on a tie; the ratified defaults +/// when none applies. +#[must_use] +pub fn resolve_policy(versions: &[PolicyVersion], now: DateTime) -> DualControlPolicy { + effective_version(versions, now).map_or(DualControlPolicy::DEFAULT, |v| v.policy) +} + +/// Validate a tenant policy config against the ratified ranges (DECISIONS D-1, +/// foundation §1.4). Out-of-range is **rejected** — never clamped (DC9/DC11). +/// +/// # Errors +/// [`PolicyConfigError`] when D2, A6, or the TTL is outside its allowed range. +pub fn validate_config( + d2_threshold_minor: i64, + a6_backdating_biz_days: i32, + pending_ttl_seconds: i64, +) -> Result<(), PolicyConfigError> { + if !(D2_MIN_MINOR..=D2_MAX_MINOR).contains(&d2_threshold_minor) { + return Err(PolicyConfigError::D2OutOfRange(d2_threshold_minor)); + } + if !(A6_MIN_DAYS..=A6_MAX_DAYS).contains(&a6_backdating_biz_days) { + return Err(PolicyConfigError::A6OutOfRange(a6_backdating_biz_days)); + } + if pending_ttl_seconds <= 0 { + return Err(PolicyConfigError::TtlNotPositive(pending_ttl_seconds)); + } + Ok(()) +} + +/// Decide whether `op` must go through dual-control under `policy`, given the +/// current date `today` (for the A6 backdating window). Per-policy (DC: only over +/// threshold) — below threshold the caller proceeds single-actor. +#[must_use] +pub fn requires_dual_control( + op: &OperationFacts, + policy: DualControlPolicy, + today: NaiveDate, +) -> bool { + match op.kind { + // Amount-gated: USD-eq at or above the D2 threshold. A recognition schedule + // change/cancel is sized by the un-recognized deferred remainder it re-plans + // or strands (the gate reads it from the schedule, like `Reverse`). + ApprovalKind::Reverse + | ApprovalKind::CreditGrant + | ApprovalKind::ChargebackLoss + | ApprovalKind::RecognitionScheduleChange + // A refund / credit-note is money-OUT; the D2 threshold gates it on the + // cash returned (design §1.4 D2 — refunds & credit-notes above the + // tenant-configurable amount need preparer/approver). Reuses the SAME D2 + // policy row as the other amount-gated kinds (no separate refund threshold). + | ApprovalKind::Refund + // A governed manual adjustment is a money-affecting governed posting; gated + // on the gross adjustment amount (Σ DR == Σ CR), the SAME D2 row as the other + // amount-gated kinds (no separate manual-adjustment threshold). + | ApprovalKind::ManualAdjustment + // A credit note is money-OUT (reduces AR / recognized revenue / seeds a + // refundable wallet); a debit note is a money-affecting additional charge + // that can book fresh revenue + a recognition schedule outside the normal + // invoice flow. Both are material adjustments to a posted invoice, gated on + // the note amount against the SAME D2 row (design §5 D1–D2). No separate + // note threshold. + | ApprovalKind::CreditNote + | ApprovalKind::DebitNote => op + .amount_usd_eq_minor + // Gate on magnitude (VHP-1855 #11): a negative USD-eq amount must not + // slip below the threshold and skip the gate. Defensive — the + // amount-bearing kinds reject a non-positive amount upstream + // (`build_grant_entry`, the dispute `CHECK`; the derived Reverse / + // RecognitionScheduleChange amounts are `>= 0`) — but the gate must not + // depend on those guards holding. + .is_some_and(|amount| amount.saturating_abs() >= policy.d2_threshold_minor), + // Open-period material backdating: effective date older than A6 biz days. + ApprovalKind::MaterialBackdating => op + .effective_at + .is_some_and(|eff| business_days_between(eff, today) > policy.a6_backdating_biz_days), + // Closing a payer that still holds a balance always needs sign-off. + ApprovalKind::PayerClosure => op.has_outstanding_balance, + // Reopening a closed fiscal period is always dual-control (Slice 7 prep). + ApprovalKind::PeriodReopen => true, + } +} + +/// Count business days (Mon–Fri) after `from` up to and including `to`. `0` when +/// `from >= to`. MVP uses the Mon–Fri weekday rule; tenant holiday calendars +/// (foundation AC #20) are a follow-up. +#[must_use] +pub fn business_days_between(from: NaiveDate, to: NaiveDate) -> i32 { + let mut count = 0; + let mut day = from; + while day < to { + // `succ_opt` only returns `None` at the maximum representable date, far + // outside any fiscal-period range — saturate there rather than panic. + let Some(next) = day.succ_opt() else { break }; + day = next; + if !matches!(day.weekday(), Weekday::Sat | Weekday::Sun) { + count += 1; + } + } + count +} + +#[cfg(test)] +#[path = "policy_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/approval/policy_tests.rs b/gears/bss/ledger/ledger/src/domain/approval/policy_tests.rs new file mode 100644 index 000000000..4c45728f5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/approval/policy_tests.rs @@ -0,0 +1,260 @@ +//! Unit tests for the pure dual-control threshold policy. + +use super::*; +use chrono::TimeZone; + +fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +fn ts(y: i32, m: u32, d: u32) -> DateTime { + Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).unwrap() +} + +fn version(eff: DateTime, version: i64, d2: i64, a6: i32) -> PolicyVersion { + PolicyVersion { + effective_from: eff, + version, + policy: DualControlPolicy { + d2_threshold_minor: d2, + a6_backdating_biz_days: a6, + pending_ttl_seconds: DEFAULT_PENDING_TTL_SECONDS, + }, + } +} + +#[test] +fn resolve_empty_yields_ratified_defaults() { + assert_eq!( + resolve_policy(&[], ts(2026, 6, 25)), + DualControlPolicy::DEFAULT + ); +} + +#[test] +fn resolve_picks_latest_effective_from() { + let versions = [ + version(ts(2026, 1, 1), 1, 50_000, 5), + version(ts(2026, 6, 1), 2, 200_000, 10), + ]; + let p = resolve_policy(&versions, ts(2026, 6, 25)); + assert_eq!(p.d2_threshold_minor, 200_000); + assert_eq!(p.a6_backdating_biz_days, 10); +} + +#[test] +fn resolve_breaks_effective_tie_on_highest_version() { + let versions = [ + version(ts(2026, 6, 1), 1, 50_000, 5), + version(ts(2026, 6, 1), 2, 300_000, 7), + ]; + let p = resolve_policy(&versions, ts(2026, 6, 25)); + assert_eq!(p.d2_threshold_minor, 300_000); +} + +#[test] +fn resolve_ignores_not_yet_effective_versions() { + let versions = [ + version(ts(2026, 1, 1), 1, 50_000, 5), + version(ts(2026, 12, 1), 2, 999_999, 30), + ]; + let p = resolve_policy(&versions, ts(2026, 6, 25)); + assert_eq!( + p.d2_threshold_minor, 50_000, + "future version must not apply" + ); +} + +fn amount_op(kind: ApprovalKind, usd_eq_minor: Option) -> OperationFacts { + OperationFacts { + kind, + amount_usd_eq_minor: usd_eq_minor, + effective_at: None, + has_outstanding_balance: false, + } +} + +#[test] +fn amount_kinds_gate_at_or_above_threshold() { + let policy = DualControlPolicy::DEFAULT; // d2 = 100_000 + let today = date(2026, 6, 25); + for kind in [ + ApprovalKind::Reverse, + ApprovalKind::CreditGrant, + ApprovalKind::ChargebackLoss, + ApprovalKind::RecognitionScheduleChange, + // A refund shares the SAME D2 row as the other money-out kinds (Group D). + ApprovalKind::Refund, + // A governed manual adjustment shares the SAME D2 row (Group 5 / Phase 3). + ApprovalKind::ManualAdjustment, + // Credit + debit notes share the SAME D2 row (Slice 3 §5 D1–D2, Z6-1). + ApprovalKind::CreditNote, + ApprovalKind::DebitNote, + ] { + // At the threshold → gated (>=). + assert!(requires_dual_control( + &amount_op(kind, Some(100_000)), + policy, + today + )); + // Just below → single-actor. + assert!(!requires_dual_control( + &amount_op(kind, Some(99_999)), + policy, + today + )); + // Well above → gated. + assert!(requires_dual_control( + &amount_op(kind, Some(5_000_000)), + policy, + today + )); + // No amount known → not gated by amount. + assert!(!requires_dual_control( + &amount_op(kind, None), + policy, + today + )); + } +} + +#[test] +fn material_backdating_gates_beyond_a6_window() { + let policy = DualControlPolicy::DEFAULT; // a6 = 5 business days + let backdate = |eff: NaiveDate, today: NaiveDate| { + requires_dual_control( + &OperationFacts { + kind: ApprovalKind::MaterialBackdating, + amount_usd_eq_minor: None, + effective_at: Some(eff), + has_outstanding_balance: false, + }, + policy, + today, + ) + }; + // 2024-01-01 is a Monday. Exactly 5 business days later is Mon 2024-01-08 → + // at the window, NOT beyond → single-actor. + assert!(!backdate(date(2024, 1, 1), date(2024, 1, 8))); + // One more business day (Tue 2024-01-09) → 6 > 5 → gated. + assert!(backdate(date(2024, 1, 1), date(2024, 1, 9))); + // Same day → 0 business days → not gated. + assert!(!backdate(date(2024, 1, 9), date(2024, 1, 9))); +} + +#[test] +fn payer_closure_gated_only_with_outstanding_balance() { + let policy = DualControlPolicy::DEFAULT; + let today = date(2026, 6, 25); + let with_balance = OperationFacts { + kind: ApprovalKind::PayerClosure, + amount_usd_eq_minor: None, + effective_at: None, + has_outstanding_balance: true, + }; + let clean = OperationFacts { + has_outstanding_balance: false, + ..with_balance + }; + assert!(requires_dual_control(&with_balance, policy, today)); + assert!(!requires_dual_control(&clean, policy, today)); +} + +#[test] +fn period_reopen_is_always_gated() { + let policy = DualControlPolicy::DEFAULT; + let op = OperationFacts { + kind: ApprovalKind::PeriodReopen, + amount_usd_eq_minor: None, + effective_at: None, + has_outstanding_balance: false, + }; + assert!(requires_dual_control(&op, policy, date(2026, 6, 25))); +} + +#[test] +fn validate_config_accepts_in_range_and_rejects_out_of_range() { + // In range (the ratified defaults + bounds). + assert!(validate_config(100_000, 5, DEFAULT_PENDING_TTL_SECONDS).is_ok()); + assert!(validate_config(D2_MIN_MINOR, A6_MIN_DAYS, 1).is_ok()); + assert!(validate_config(D2_MAX_MINOR, A6_MAX_DAYS, 1).is_ok()); + // Out of range → rejected, no clamp. + assert_eq!( + validate_config(D2_MIN_MINOR - 1, 5, 1), + Err(PolicyConfigError::D2OutOfRange(D2_MIN_MINOR - 1)) + ); + assert_eq!( + validate_config(D2_MAX_MINOR + 1, 5, 1), + Err(PolicyConfigError::D2OutOfRange(D2_MAX_MINOR + 1)) + ); + assert_eq!( + validate_config(100_000, 0, 1), + Err(PolicyConfigError::A6OutOfRange(0)) + ); + assert_eq!( + validate_config(100_000, 31, 1), + Err(PolicyConfigError::A6OutOfRange(31)) + ); + assert_eq!( + validate_config(100_000, 5, 0), + Err(PolicyConfigError::TtlNotPositive(0)) + ); +} + +#[test] +fn business_days_skips_weekends() { + // Mon 2024-01-01 → Mon 2024-01-08 spans one weekend → 5 business days. + assert_eq!(business_days_between(date(2024, 1, 1), date(2024, 1, 8)), 5); + // Backwards / same day → 0. + assert_eq!(business_days_between(date(2024, 1, 8), date(2024, 1, 1)), 0); + assert_eq!(business_days_between(date(2024, 1, 1), date(2024, 1, 1)), 0); + // Fri 2024-01-05 → Mon 2024-01-08: Sat/Sun skipped, only Mon counts → 1. + assert_eq!(business_days_between(date(2024, 1, 5), date(2024, 1, 8)), 1); +} + +#[test] +fn effective_version_returns_the_row_in_force() { + let versions = [ + version(ts(2026, 6, 1), 1, 50_000, 5), + version(ts(2026, 6, 20), 2, 200_000, 7), + ]; + let v = effective_version(&versions, ts(2026, 6, 25)).expect("a version is in force"); + assert_eq!(v.version, 2); + assert_eq!(v.policy.d2_threshold_minor, 200_000); + assert_eq!(v.effective_from, ts(2026, 6, 20)); +} + +#[test] +fn effective_version_breaks_effective_tie_on_highest_version() { + let versions = [ + version(ts(2026, 6, 20), 1, 50_000, 5), + version(ts(2026, 6, 20), 2, 200_000, 7), + ]; + let v = effective_version(&versions, ts(2026, 6, 25)).expect("a version is in force"); + assert_eq!(v.version, 2); + assert_eq!(v.policy.d2_threshold_minor, 200_000); +} + +#[test] +fn effective_version_none_when_no_row_applies() { + // No rows at all → None (the caller falls back to the platform defaults). + assert!(effective_version(&[], ts(2026, 6, 25)).is_none()); + // A not-yet-effective row does not apply. + let future = [version(ts(2026, 7, 1), 1, 50_000, 5)]; + assert!(effective_version(&future, ts(2026, 6, 25)).is_none()); +} + +#[test] +fn effective_version_agrees_with_resolve_policy() { + let versions = [ + version(ts(2026, 6, 1), 1, 50_000, 5), + version(ts(2026, 6, 20), 2, 200_000, 7), + ]; + let now = ts(2026, 6, 25); + // resolve_policy is effective_version's thresholds, defaults when none. + assert_eq!( + resolve_policy(&versions, now), + effective_version(&versions, now).expect("in force").policy + ); + assert_eq!(resolve_policy(&[], now), DualControlPolicy::DEFAULT); +} diff --git a/gears/bss/ledger/ledger/src/domain/approval_tests.rs b/gears/bss/ledger/ledger/src/domain/approval_tests.rs new file mode 100644 index 000000000..dbd0a7749 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/approval_tests.rs @@ -0,0 +1,57 @@ +//! Unit tests for the dual-control `ApprovalKind` / `ApprovalState` tokens. + +use super::*; + +#[test] +fn approval_kind_token_roundtrips() { + for k in [ + ApprovalKind::Reverse, + ApprovalKind::MaterialBackdating, + ApprovalKind::CreditGrant, + ApprovalKind::ChargebackLoss, + ApprovalKind::PayerClosure, + ApprovalKind::PeriodReopen, + ApprovalKind::RecognitionScheduleChange, + ApprovalKind::Refund, + ApprovalKind::ManualAdjustment, + ] { + assert_eq!(ApprovalKind::parse(k.as_str()), Some(k), "roundtrip {k:?}"); + } + assert_eq!(ApprovalKind::parse("NOT_A_KIND"), None); +} + +#[test] +fn approval_state_token_roundtrips() { + for s in [ + ApprovalState::Pending, + ApprovalState::Approving, + ApprovalState::Approved, + ApprovalState::Rejected, + ApprovalState::NeedsRework, + ApprovalState::Cancelled, + ApprovalState::Expired, + ] { + assert_eq!(ApprovalState::parse(s.as_str()), Some(s), "roundtrip {s:?}"); + } + assert_eq!(ApprovalState::parse("NOT_A_STATE"), None); +} + +#[test] +fn only_pending_and_needs_rework_are_active() { + assert!(ApprovalState::Pending.is_active()); + assert!(ApprovalState::NeedsRework.is_active()); + // `APPROVING` is transient (the H2 execute latch), NOT active: it is not + // cancellable/rejectable — only the approve flow may move it on. + assert!( + !ApprovalState::Approving.is_active(), + "APPROVING is transient, not active" + ); + for terminal in [ + ApprovalState::Approved, + ApprovalState::Rejected, + ApprovalState::Cancelled, + ApprovalState::Expired, + ] { + assert!(!terminal.is_active(), "{terminal:?} must be terminal"); + } +} diff --git a/gears/bss/ledger/ledger/src/domain/audit_chain.rs b/gears/bss/ledger/ledger/src/domain/audit_chain.rs new file mode 100644 index 000000000..a2fa7fe80 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/audit_chain.rs @@ -0,0 +1,125 @@ +//! Canonical, byte-reproducible serialization of a secured-audit record for +//! the audit store's OWN per-tenant tamper-evidence hash chain (Slice 6 +//! Phase 2 Group 2A). Distinct from the posting chain in [`super::chain`]: it +//! has its own domain-separation tag ([`AUDIT_DOMAIN_SEP`]) and its own tip +//! table (`audit_chain_state`), so the two chains never collide. +//! +//! Every field is length-prefixed and NULL-safe (via the shared +//! [`super::canonical`] primitives) so two different field boundaries can never +//! collide. The free-form `before_after` jsonb is hashed over its CANONICAL +//! bytes (sorted keys) so a re-serialization with reordered keys yields the +//! same hash. SHA-256 via the FIPS-validated `aws-lc-rs` provider. + +use chrono::{DateTime, Utc}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use super::canonical::{digest32, put, put_i64, put_opt_str, put_opt_uuid, put_str, put_uuid}; + +/// Versioned domain-separation tag for the secured-audit chain; bump only on an +/// intentional re-freeze of the encoding (which also requires regenerating the +/// byte-repro vector in `audit_chain_tests.rs`). +pub const AUDIT_DOMAIN_SEP: &[u8] = b"VHP-BSS-LEDGER-AUDIT-v1\x1f"; + +/// The hashing input for one secured-audit record. Borrows its string/json +/// fields. Carries `#[domain_model]` because it is a `pub` type in the domain +/// layer (dylint DE0309); it never crosses the repo boundary. +#[domain_model] +pub struct AuditHashInput<'a> { + pub audit_id: Uuid, + pub tenant_id: Uuid, + pub event_type: &'a str, + pub actor_ref: Option<&'a str>, + pub reason_code: Option<&'a str>, + pub correlation_id: Option, + pub at_utc: DateTime, + pub before_after: &'a serde_json::Value, +} + +/// The genesis `prev_hash` for a tenant's first audit record. Tenant-bound and +/// never NULL, so a sealed row's `prev_hash` column is always non-NULL. +#[must_use] +pub fn audit_genesis_prev_hash(tenant_id: Uuid) -> [u8; 32] { + let mut buf = Vec::with_capacity(64); + buf.extend_from_slice(AUDIT_DOMAIN_SEP); + put_uuid(&mut buf, tenant_id); + put_str(&mut buf, "GENESIS"); + digest32(&buf) +} + +/// Canonical bytes of a JSON value with object keys **sorted**, independent of +/// the `serde_json` `preserve_order` feature. We cannot assume the default +/// `BTreeMap` ordering: in a monorepo build another crate may enable +/// `preserve_order` (making `serde_json::Map` an insertion-ordered `IndexMap`), +/// which would otherwise make this hash depend on key insertion order. We first +/// rebuild the value with recursively sorted-key objects, so the serialized +/// bytes are deterministic and canonical in either build — byte-identical to the +/// historical sorted encoding (the domain-sep vector is unchanged). +/// +/// # Errors +/// Returns the `serde_json::Error` if `value` fails to serialize. This is not +/// reachable for an in-memory `Value`, but we propagate the error +/// rather than degrade to `b"{}"`: a silent fallback would make an +/// un-serializable record hash IDENTICALLY to an empty-object record (a +/// collision target). Failing the append is correct — a record whose content +/// cannot be canonicalized must never be sealed into the chain. +fn canonical_json(value: &serde_json::Value) -> Result, serde_json::Error> { + serde_json::to_vec(&canonicalized(value)) +} + +/// Recursively rebuild `value` with every object's keys in sorted order. With +/// `preserve_order` off (`BTreeMap`) this re-sorts into a `BTreeMap` (a no-op on +/// ordering); with it on (`IndexMap`) the sorted insertion order is preserved on +/// serialize — either way the emitted keys are sorted. Arrays keep their order +/// (order is semantic in a JSON array); scalars are returned as-is. +fn canonicalized(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(map) => { + let mut entries: Vec<(&String, &serde_json::Value)> = map.iter().collect(); + entries.sort_unstable_by(|a, b| a.0.cmp(b.0)); + let mut out = serde_json::Map::with_capacity(map.len()); + for (k, v) in entries { + out.insert(k.clone(), canonicalized(v)); + } + serde_json::Value::Object(out) + } + serde_json::Value::Array(items) => { + serde_json::Value::Array(items.iter().map(canonicalized).collect()) + } + other => other.clone(), + } +} + +/// `row_hash = SHA-256(domain_sep ‖ audit_id ‖ tenant_id ‖ event_type ‖ +/// actor_ref ‖ reason_code ‖ correlation_id ‖ at_utc(micros) ‖ +/// canonical_json(before_after) ‖ prev_hash)`. +/// +/// `prev_hash` is the prior audit record's `row_hash`, or +/// [`audit_genesis_prev_hash`] for a tenant's first record. +/// +/// # Errors +/// Returns the `serde_json::Error` from [`canonical_json`] if `before_after` +/// fails to serialize — never hash a record we cannot canonicalize. +pub fn audit_row_hash( + rec: &AuditHashInput, + prev_hash: &[u8; 32], +) -> Result<[u8; 32], serde_json::Error> { + let mut buf = Vec::with_capacity(256); + buf.extend_from_slice(AUDIT_DOMAIN_SEP); + + put_uuid(&mut buf, rec.audit_id); + put_uuid(&mut buf, rec.tenant_id); + put_str(&mut buf, rec.event_type); + put_opt_str(&mut buf, rec.actor_ref); + put_opt_str(&mut buf, rec.reason_code); + put_opt_uuid(&mut buf, rec.correlation_id); + put_i64(&mut buf, rec.at_utc.timestamp_micros()); + put(&mut buf, &canonical_json(rec.before_after)?); + + put(&mut buf, prev_hash); + Ok(digest32(&buf)) +} + +#[cfg(test)] +#[path = "audit_chain_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/audit_chain_tests.rs b/gears/bss/ledger/ledger/src/domain/audit_chain_tests.rs new file mode 100644 index 000000000..43124275b --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/audit_chain_tests.rs @@ -0,0 +1,127 @@ +//! Unit tests for the secured-audit canonical encoder ([`super`]): +//! determinism, `before_after` key-order independence, prev_hash sensitivity, +//! and genesis tenant-binding. + +#![allow(clippy::doc_markdown)] + +use chrono::{TimeZone, Utc}; +use serde_json::json; +use uuid::Uuid; + +use super::{AuditHashInput, audit_genesis_prev_hash}; + +/// Test helper: hash and unwrap. `audit_row_hash` is fallible only on a +/// `before_after` serialize failure, which is unreachable for the in-memory +/// `Value`s these tests build. +fn h(input: &AuditHashInput, prev: &[u8; 32]) -> [u8; 32] { + super::audit_row_hash(input, prev).expect("canonicalize") +} + +fn input(before_after: &serde_json::Value) -> AuditHashInput<'_> { + AuditHashInput { + audit_id: Uuid::from_u128(1), + tenant_id: Uuid::from_u128(2), + event_type: "metadata-change", + actor_ref: Some("actor-7"), + reason_code: Some("rc-1"), + correlation_id: Some(Uuid::from_u128(9)), + at_utc: Utc.timestamp_opt(1_750_000_000, 0).unwrap(), + before_after, + } +} + +const PREV: [u8; 32] = [7u8; 32]; + +#[test] +fn deterministic() { + let ba = json!({"a": 1, "b": 2}); + assert_eq!(h(&input(&ba), &PREV), h(&input(&ba), &PREV)); +} + +/// The same JSON content written with the keys in a different source order must +/// hash identically — the canonical encoder sorts object keys, so the canonical +/// bytes are order-independent (regardless of the `preserve_order` feature). +#[test] +fn before_after_key_order_independent() { + let a = json!({"alpha": 1, "beta": 2, "gamma": 3}); + let b = json!({"gamma": 3, "beta": 2, "alpha": 1}); + assert_eq!(h(&input(&a), &PREV), h(&input(&b), &PREV)); +} + +/// A different `before_after` VALUE must change the hash. +#[test] +fn before_after_value_sensitive() { + let a = json!({"k": 1}); + let b = json!({"k": 2}); + assert_ne!(h(&input(&a), &PREV), h(&input(&b), &PREV)); +} + +#[test] +fn prev_hash_sensitive() { + let ba = json!({}); + assert_ne!(h(&input(&ba), &[1u8; 32]), h(&input(&ba), &[2u8; 32])); +} + +#[test] +fn event_type_sensitive() { + let ba = json!({}); + let mut other = input(&ba); + other.event_type = "erasure"; + assert_ne!(h(&input(&ba), &PREV), h(&other, &PREV)); +} + +/// `None` actor and an empty-string actor must hash differently (NULL-safety). +#[test] +fn actor_none_vs_empty_string() { + let ba = json!({}); + let mut none_actor = input(&ba); + none_actor.actor_ref = None; + let mut empty_actor = input(&ba); + empty_actor.actor_ref = Some(""); + assert_ne!(h(&none_actor, &PREV), h(&empty_actor, &PREV)); +} + +/// Encoding guard: the canonical encoder emits object keys SORTED regardless of +/// whether another workspace crate enabled serde_json's `preserve_order` feature +/// (which makes `serde_json::Map` insertion-ordered). The audit chain's +/// byte-reproducibility must not depend on that global toggle, so we assert the +/// encoder itself canonicalizes rather than assuming the default map ordering. +#[test] +fn canonical_json_sorts_keys_regardless_of_preserve_order() { + let v = json!({ "b": 1, "a": 2, "c": 3 }); + let bytes = super::canonical_json(&v).expect("canonicalize"); + assert_eq!( + String::from_utf8(bytes).expect("utf8"), + r#"{"a":2,"b":1,"c":3}"#, + "canonical_json must sort object keys independent of preserve_order" + ); +} + +#[test] +fn genesis_is_tenant_bound() { + assert_ne!( + audit_genesis_prev_hash(Uuid::from_u128(1)), + audit_genesis_prev_hash(Uuid::from_u128(2)) + ); +} + +/// The byte-reproducibility vector for the audit chain (mirrors +/// `chain_tests.rs::byte_reproducibility_vector`). Pins the exact hash of a +/// fixed `AuditHashInput` so an UNINTENDED encoding drift (a reordered/added +/// field, a changed [`super::AUDIT_DOMAIN_SEP`]) breaks CI — which the +/// `deterministic` test (same input twice in one build) cannot catch. The audit +/// chain backs the secured-audit store's tamper evidence, so a silent drift +/// would invalidate every persisted `row_hash`. REGENERATE only on an +/// intentional encoding change: run once, paste the printed hex into `EXPECTED`. +#[test] +fn audit_byte_reproducibility_vector() { + use std::fmt::Write as _; + const EXPECTED: &str = "6ff8e36cd242da3ed8d1764d819d34fcc8246027b3a24b609a272cff153c0bb1"; + let ba = json!({"k": 1}); + let digest = h(&input(&ba), &[0u8; 32]); + let mut hex = String::with_capacity(64); + for b in digest { + let _ = write!(hex, "{b:02x}"); + } + assert_eq!(hex, EXPECTED, "audit-chain encoding changed — got {hex}"); +} diff --git a/gears/bss/ledger/ledger/src/domain/canonical.rs b/gears/bss/ledger/ledger/src/domain/canonical.rs new file mode 100644 index 000000000..402a2c1a7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/canonical.rs @@ -0,0 +1,67 @@ +//! Canonical, byte-reproducible framing primitives shared by the gear's +//! tamper-evidence hash encoders (the per-tenant posting chain in +//! [`super::chain`] and the secured-audit chain in [`super::audit_chain`]). +//! +//! Every field is length-prefixed and NULL-safe so two different field +//! boundaries can never collide: a NULL field is a bare [`ABSENT`] byte; a +//! present field is [`PRESENT`] ‖ u32-BE length ‖ bytes. A `None` and an empty +//! string therefore hash differently. SHA-256 via the FIPS-validated +//! `aws-lc-rs` provider — `sha2` is blocked by dylint DE0708. + +use aws_lc_rs::digest::{SHA256, digest as sha256}; +use uuid::Uuid; + +/// NULL-safe presence marker for an absent (NULL) field: a bare byte. +pub(crate) const ABSENT: u8 = 0x00; +/// NULL-safe presence marker for a present field: `PRESENT` ‖ u32-BE len ‖ bytes. +pub(crate) const PRESENT: u8 = 0x01; + +pub(crate) fn put(buf: &mut Vec, bytes: &[u8]) { + buf.push(PRESENT); + let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX); + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(bytes); +} +pub(crate) fn put_none(buf: &mut Vec) { + buf.push(ABSENT); +} +pub(crate) fn put_opt(buf: &mut Vec, bytes: Option<&[u8]>) { + match bytes { + Some(b) => put(buf, b), + None => put_none(buf), + } +} +pub(crate) fn put_str(buf: &mut Vec, s: &str) { + put(buf, s.as_bytes()); +} +pub(crate) fn put_opt_str(buf: &mut Vec, s: Option<&str>) { + put_opt(buf, s.map(str::as_bytes)); +} +pub(crate) fn put_uuid(buf: &mut Vec, u: Uuid) { + put(buf, u.as_bytes()); +} +pub(crate) fn put_opt_uuid(buf: &mut Vec, u: Option) { + match u { + Some(u) => put_uuid(buf, u), + None => put_none(buf), + } +} +pub(crate) fn put_i64(buf: &mut Vec, n: i64) { + put(buf, &n.to_be_bytes()); +} +pub(crate) fn put_opt_i64(buf: &mut Vec, n: Option) { + match n { + Some(n) => put_i64(buf, n), + None => put_none(buf), + } +} +pub(crate) fn put_i32(buf: &mut Vec, n: i32) { + put(buf, &n.to_be_bytes()); +} + +pub(crate) fn digest32(buf: &[u8]) -> [u8; 32] { + let d = sha256(&SHA256, buf); + let mut out = [0u8; 32]; + out.copy_from_slice(d.as_ref()); + out +} diff --git a/gears/bss/ledger/ledger/src/domain/chain.rs b/gears/bss/ledger/ledger/src/domain/chain.rs new file mode 100644 index 000000000..d0ae9a39e --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/chain.rs @@ -0,0 +1,106 @@ +//! Canonical, byte-reproducible serialization of a posted entry for the +//! per-tenant tamper-evidence hash chain (Slice 6, design §4.2). +//! +//! Every field is length-prefixed and NULL-safe so two different field +//! boundaries can never collide; PII, `correlation_id`, and the free-form +//! `rounding_evidence` jsonb are **excluded** (so GDPR erasure never breaks +//! the chain). The financially-binding AR sub-class `ar_status` (Slice 2, set +//! on chargeback/dispute reclass lines) **is** covered (design §4.2, Rev2 B-8). +//! The encoding still **reserves** `rate_snapshot_ref` (Slice 5) and the +//! per-line `legal_entity_id` override as NULL slots so the byte format never +//! re-freezes when those slices populate them (Rev2 B-8 / R-9). Lines are +//! hashed in `line_id` order (matches the Verifier re-walk). SHA-256 via the +//! FIPS-validated `aws-lc-rs` provider — `sha2` is blocked by dylint DE0708. + +use chrono::Datelike; +use uuid::Uuid; + +use super::canonical::{ + digest32, put, put_i32, put_i64, put_none, put_opt_i64, put_opt_str, put_opt_uuid, put_str, + put_uuid, +}; +use crate::domain::model::{NewEntry, NewLine}; + +/// Versioned domain-separation tag; bump only on an intentional re-freeze of +/// the encoding (which also requires regenerating the §11 byte-repro vector). +pub const CHAIN_DOMAIN_SEP: &[u8] = b"VHP-BSS-LEDGER-CHAIN-v2\x1f"; + +/// The genesis `prev_hash` for a tenant's first posted entry. Tenant-bound and +/// never NULL, so a sealed row's `prev_hash` column is always non-NULL. +#[must_use] +pub fn genesis_prev_hash(tenant_id: Uuid) -> [u8; 32] { + let mut buf = Vec::with_capacity(64); + buf.extend_from_slice(CHAIN_DOMAIN_SEP); + put_uuid(&mut buf, tenant_id); + put_str(&mut buf, "GENESIS"); + digest32(&buf) +} + +/// `row_hash = SHA-256(domain_sep ‖ entry fields ‖ lines(line_id order) ‖ prev_hash)`. +/// +/// `prev_hash` is the prior entry's `row_hash`, or [`genesis_prev_hash`] for a +/// tenant's first entry. +#[must_use] +pub fn chain_row_hash(entry: &NewEntry, lines: &[NewLine], prev_hash: &[u8; 32]) -> [u8; 32] { + let mut buf = Vec::with_capacity(512); + buf.extend_from_slice(CHAIN_DOMAIN_SEP); + + // --- entry fields (design §4.2 order; correlation_id + rounding_evidence EXCLUDED) --- + put_uuid(&mut buf, entry.tenant_id); + put_uuid(&mut buf, entry.entry_id); + put_str(&mut buf, &entry.period_id); + put_uuid(&mut buf, entry.legal_entity_id); + put_str(&mut buf, &entry.entry_currency); + put_str(&mut buf, entry.source_doc_type.as_str()); + put_str(&mut buf, &entry.source_business_id); + put_opt_uuid(&mut buf, entry.reverses_entry_id); + put_opt_str(&mut buf, entry.reverses_period_id.as_deref()); + put_i32(&mut buf, entry.effective_at.num_days_from_ce()); + put_i64(&mut buf, entry.posted_at_utc.timestamp_micros()); + put_str(&mut buf, &entry.origin); + put_uuid(&mut buf, entry.posted_by_actor_id); + + // --- lines in line_id order --- + let mut ordered: Vec<&NewLine> = lines.iter().collect(); + ordered.sort_by_key(|l| l.line_id); + let count = u64::try_from(ordered.len()).unwrap_or(u64::MAX); + put(&mut buf, &count.to_be_bytes()); + for l in ordered { + put_uuid(&mut buf, l.line_id); + put_uuid(&mut buf, l.account_id); + put_str(&mut buf, l.account_class.as_str()); + put_opt_str(&mut buf, l.gl_code.as_deref()); + put_str(&mut buf, l.side.as_str()); + put_i64(&mut buf, l.amount_minor); + put_str(&mut buf, &l.currency); + put(&mut buf, &[l.currency_scale]); + put_opt_i64(&mut buf, l.functional_amount_minor); + put_opt_str(&mut buf, l.functional_currency.as_deref()); + put_uuid(&mut buf, l.payer_tenant_id); + put_opt_uuid(&mut buf, l.seller_tenant_id); + put_opt_uuid(&mut buf, l.resource_tenant_id); + put_opt_str(&mut buf, l.invoice_id.as_deref()); + put_opt_str(&mut buf, l.revenue_stream.as_deref()); + put_opt_str(&mut buf, l.tax_jurisdiction.as_deref()); + put_opt_str(&mut buf, l.tax_filing_period.as_deref()); + put_opt_str(&mut buf, l.tax_rate_ref.as_deref()); + put_opt_str(&mut buf, l.ar_status.as_deref()); // Slice 2 AR dispute sub-class (chargeback reclass) — financially binding + put_str(&mut buf, l.mapping_status.as_str()); + put_none(&mut buf); // RESERVED: rate_snapshot_ref (Slice 5) — always NULL in v1 + put_opt_str(&mut buf, l.credit_grant_event_type.as_deref()); + put_opt_str(&mut buf, l.invoice_item_ref.as_deref()); + put_opt_str(&mut buf, l.sku_or_plan_ref.as_deref()); + put_opt_str(&mut buf, l.price_id.as_deref()); + put_opt_str(&mut buf, l.pricing_snapshot_ref.as_deref()); + put_opt_str(&mut buf, l.po_allocation_group.as_deref()); + put_opt_uuid(&mut buf, l.legal_entity_id); // reserved per-line override; NULL in v1 + } + + // --- prev link --- + put(&mut buf, prev_hash); + digest32(&buf) +} + +#[cfg(test)] +#[path = "chain_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/chain_tests.rs b/gears/bss/ledger/ledger/src/domain/chain_tests.rs new file mode 100644 index 000000000..d58f38f6e --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/chain_tests.rs @@ -0,0 +1,174 @@ +//! Unit tests for the canonical tamper-evidence chain encoder ([`super`]): +//! determinism, line-order independence, NULL-safety, field sensitivity, the +//! PII/correlation/rounding-evidence exclusions, and the §11 byte-repro vector. + +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, TimeZone, Utc}; +use serde_json::json; +use uuid::Uuid; + +use super::{chain_row_hash, genesis_prev_hash}; +use crate::domain::model::{NewEntry, NewLine}; + +fn entry() -> NewEntry { + NewEntry { + entry_id: Uuid::from_u128(1), + tenant_id: Uuid::from_u128(2), + legal_entity_id: Uuid::from_u128(3), + period_id: "202606".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "biz-1".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc.timestamp_opt(1_750_000_000, 0).unwrap(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: Uuid::from_u128(4), + correlation_id: Uuid::from_u128(5), + rounding_evidence: json!({}), + rate_snapshot_ref: None, + } +} + +fn line(id: u128, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::from_u128(id), + payer_tenant_id: Uuid::from_u128(2), + seller_tenant_id: None, + resource_tenant_id: None, + account_id: Uuid::from_u128(20), + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +const PREV: [u8; 32] = [7u8; 32]; + +#[test] +fn deterministic() { + let (e, ls) = (entry(), vec![line(10, 100), line(11, -100)]); + assert_eq!( + chain_row_hash(&e, &ls, &PREV), + chain_row_hash(&e, &ls, &PREV) + ); +} + +#[test] +fn line_input_order_independent() { + let e = entry(); + let a = vec![line(10, 100), line(11, -100)]; + let b = vec![line(11, -100), line(10, 100)]; + assert_eq!(chain_row_hash(&e, &a, &PREV), chain_row_hash(&e, &b, &PREV)); +} + +#[test] +fn prev_hash_sensitive() { + let (e, ls) = (entry(), vec![line(10, 100)]); + assert_ne!( + chain_row_hash(&e, &ls, &[1u8; 32]), + chain_row_hash(&e, &ls, &[2u8; 32]) + ); +} + +#[test] +fn amount_sensitive() { + let e = entry(); + assert_ne!( + chain_row_hash(&e, &[line(10, 100)], &PREV), + chain_row_hash(&e, &[line(10, 101)], &PREV) + ); +} + +#[test] +fn ar_status_covered() { + // `ar_status` is financially binding (Slice 2 chargeback reclass routes a + // disputed sub-balance on `DISPUTED`, projector.rs); a tamper flipping it + // MUST change the row hash so the Verifier catches it (design §4.2, Rev2 B-8). + let e = entry(); + let mut active = line(10, 100); + active.ar_status = Some("ACTIVE".to_owned()); + let mut disputed = line(10, 100); + disputed.ar_status = Some("DISPUTED".to_owned()); + let none = line(10, 100); // ar_status: None + let h_active = chain_row_hash(&e, &[active], &PREV); + let h_disputed = chain_row_hash(&e, &[disputed], &PREV); + let h_none = chain_row_hash(&e, &[none], &PREV); + assert_ne!( + h_active, h_disputed, + "ACTIVE vs DISPUTED must change the hash" + ); + assert_ne!(h_none, h_disputed, "None vs DISPUTED must change the hash"); + assert_ne!(h_none, h_active, "None vs ACTIVE must change the hash"); +} + +#[test] +fn excludes_correlation_and_rounding_evidence() { + let ls = vec![line(10, 100)]; + let mut e2 = entry(); + e2.correlation_id = Uuid::from_u128(999); + e2.rounding_evidence = json!({"x": 1}); + assert_eq!( + chain_row_hash(&entry(), &ls, &PREV), + chain_row_hash(&e2, &ls, &PREV) + ); +} + +#[test] +fn null_safe_none_vs_empty_string() { + let e = entry(); + let mut none_gl = line(10, 100); + none_gl.gl_code = None; + let mut empty_gl = line(10, 100); + empty_gl.gl_code = Some(String::new()); + assert_ne!( + chain_row_hash(&e, &[none_gl], &PREV), + chain_row_hash(&e, &[empty_gl], &PREV) + ); +} + +#[test] +fn genesis_is_tenant_bound() { + assert_ne!( + genesis_prev_hash(Uuid::from_u128(1)), + genesis_prev_hash(Uuid::from_u128(2)) + ); +} + +/// The §11 byte-reproducibility vector. REGENERATE only on an intentional +/// encoding change: run once, paste the printed hex into `EXPECTED`. A silent +/// change means the chain encoding drifted — exactly what this guards. +#[test] +fn byte_reproducibility_vector() { + use std::fmt::Write as _; + const EXPECTED: &str = "b943d061aa92913945784ef003bc6b43cc8c9a800fdd758bbe1a53ee39d994ec"; + let e = entry(); + let ls = vec![line(10, 100), line(11, -100)]; + let digest = chain_row_hash(&e, &ls, &[0u8; 32]); + let mut hex = String::with_capacity(64); + for b in digest { + let _ = write!(hex, "{b:02x}"); + } + assert_eq!(hex, EXPECTED, "tamper-chain encoding changed — got {hex}"); +} diff --git a/gears/bss/ledger/ledger/src/domain/error.rs b/gears/bss/ledger/ledger/src/domain/error.rs new file mode 100644 index 000000000..2aa4e678b --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/error.rs @@ -0,0 +1,191 @@ +//! `DomainError` — the gear's internal ledger-rejection vocabulary. Per +//! ADR-0005 (SDK canonical projection) this enum is mapped to the AIP-193 +//! [`toolkit_canonical_errors::CanonicalError`] envelope by the SINGLE +//! authoritative ladder in [`crate::infra::error_mapping`]; both surfaces (REST +//! and the in-process `LedgerClientV1`) consume that ladder, so a domain +//! variant is assigned a canonical category in exactly one place. SDK +//! consumers see only the typed [`bss_ledger_sdk::LedgerError`] projection of +//! the resulting `CanonicalError` — never this enum. + +use toolkit_macros::domain_model; + +/// A ledger operation rejection. Each variant carries a human-readable detail +/// and maps to exactly one canonical category in `infra::error_mapping`. +#[domain_model] +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +pub enum DomainError { + // ── InvalidArgument (bad request shape / value) ── + #[error("entry does not net to zero per currency: {0}")] + Unbalanced(String), + #[error("entry has no lines: {0}")] + Empty(String), + #[error("entry spans more than one payer tenant: {0}")] + MixedPayer(String), + #[error("entry is missing its payer: {0}")] + MissingPayer(String), + #[error("entry spans more than one legal entity: {0}")] + MixedLegalEntity(String), + #[error("lines in the same currency carry different scales: {0}")] + InconsistentScale(String), + #[error("amount out of range: {0}")] + AmountOutOfRange(String), + #[error("entry exceeds the maximum line count: {0}")] + EntryTooLarge(String), + #[error("invalid provisioning request: {0}")] + InvalidRequest(String), + #[error("currency scale out of range: {0}")] + ScaleOutOfRange(String), + #[error("reusable-credit residual is undisposed: {0}")] + CreditResidualUndisposed(String), + #[error("allocation exceeds settled amount: {0}")] + MoneyOutCapExceeded(String), + #[error("allocation spans too many invoices: {0}")] + AllocationTooLarge(String), + #[error("allocation currency mismatch: {0}")] + AllocationCurrencyMismatch(String), + #[error("currency does not match the settled payment: {0}")] + CurrencyMismatch(String), + // ── FX & multi-currency (Slice 5) ── + // Raised at rate-lock time (RateLocker), BEFORE the post txn — like the + // governed-adjustment guard these are decided pre-post and never ride the + // posting sentinel; they are listed in domain_parts/from_parts for the + // exhaustive-match contract only. + #[error("no acceptable FX rate is available for the currency pair: {0}")] + FxRateUnavailable(String), + #[error("FX rate is stale and tenant policy forbids the fallback: {0}")] + FxRateStaleNotAllowed(String), + #[error("caller-computed allocation split is invalid: {0}")] + AllocationSplitInvalid(String), + #[error("credit grant exceeds available unallocated: {0}")] + GrantExceedsUnallocated(String), + #[error("credit application exceeds open AR: {0}")] + CreditExceedsOpenAr(String), + #[error("credit application exceeds available wallet: {0}")] + CreditExceedsWallet(String), + #[error("recognition schedule exceeds the configured segment ceiling: {0}")] + ScheduleTooLong(String), + #[error("multi-PO recognition line is missing a resolvable SSP snapshot ref: {0}")] + SspSnapshotRequired(String), + #[error("recognition line is missing a resolvable PO allocation group: {0}")] + MissingPoAllocationGroup(String), + #[error("recognition deferral/timing policy is ambiguous or unresolvable: {0}")] + RecognitionPolicyConflict(String), + #[error( + "credit/debit-note recognized-vs-deferred split is ambiguous (no silent pro-rata): {0}" + )] + CreditNoteSplitAmbiguous(String), + #[error("credit note exceeds the invoice's remaining headroom: {0}")] + CreditNoteExceedsHeadroom(String), + #[error("refund exceeds the settled amount available to refund: {0}")] + RefundExceedsSettled(String), + #[error("refund exceeds the amount allocated to the invoice: {0}")] + RefundExceedsAllocated(String), + #[error("schedule modification needs treatment review (not auto-prospective): {0}")] + ModificationTreatmentReview(String), + #[error("deferred recognition line has no resolvable invoice-item link: {0}")] + RecognitionWithoutInvoiceLink(String), + #[error("manual adjustment is not allowed (governed allow-list / write-off guard): {0}")] + ManualAdjustmentNotAllowed(String), + // Group 2B controlled-metadata guard: the PATCH value carried raw customer + // PII (an email / phone / payment number, or a prohibited key). The + // secured-audit record is append-only, so the value is screened BEFORE any + // write and rejected here — a 400 InvalidArgument carrying the + // `PII_IN_METADATA_VALUE` wire code on the `value` field. + #[error("metadata value carries prohibited customer PII: {0}")] + PiiInMetadataValue(String), + // Group 2C cross-tenant elevation guard: an investigation reason + // (`reason` + `reason_code`) is required before a cross-tenant audit read + // may open the target tenant. Architecturally a 422 Unprocessable Entity; + // the toolkit CanonicalError has no 422 category, so it projects as a 400 + // FailedPrecondition carrying the `MISSING_INVESTIGATION_REASON` wire code + // (same convention as `PiiInMetadataValue`'s sibling failed-precondition codes). + #[error("a cross-tenant audit read requires an investigation reason: {0}")] + MissingInvestigationReason(String), + + // ── PermissionDenied (cross-tenant elevation not authorized) ── + #[error("cross-tenant access denied: {0}")] + CrossTenantAccessDenied(String), + + // ── FailedPrecondition (resource state forbids the op) ── + #[error("fiscal period is closed: {0}")] + PeriodClosed(String), + #[error("account is closed: {0}")] + AccountClosed(String), + #[error("payer is closed: {0}")] + PayerClosed(String), + #[error("account mapping is missing and the tenant policy is HARD_BLOCK: {0}")] + AccountMappingMissing(String), + #[error("posting would drive a guarded balance negative: {0}")] + NegativeBalance(String), + #[error("settlement return exceeds the returnable settled amount: {0}")] + SettlementReturnOverAllocated(String), + #[error("invalid dispute phase transition: {0}")] + InvalidDisputeTransition(String), + #[error("chargeback clawback exceeds the settled amount: {0}")] + ChargebackExceedsSettled(String), + #[error("chargeback lost on an already-refunded payment: {0}")] + ChargebackOnRefunded(String), + #[error("clock skew quarantine: {0}")] + ClockSkewQuarantine(String), + #[error("fiscal period is not OPEN: {0}")] + PeriodNotOpen(String), + #[error("fiscal period close is blocked: {0}")] + PeriodCloseBlocked(String), + + // ── Aborted (conflict; the caller may retry) ── + #[error("a period close is already in progress: {0}")] + PeriodCloseInProgress(String), + #[error("idempotency key reused with a different payload: {0}")] + IdempotencyConflict(String), + #[error("currency scale is locked: {0}")] + CurrencyScaleLocked(String), + #[error("recognition release would exceed the schedule's deferred total: {0}")] + OverRecognition(String), + #[error("refund claw-back deferred (out-of-order / would underflow money-out): {0}")] + RefundClawbackDeferred(String), + #[error( + "cross-currency operation not yet supported (functional carry-forward needs a \ + prior locked rate — Slice 7): {0}" + )] + FxOperationUnsupported(String), + #[error("refund held: the origin payment has an open dispute: {0}")] + RefundDisputeHeld(String), + #[error("operation requires dual-control approval: {0}")] + DualControlRequired(String), + #[error("approver must differ from preparer: {0}")] + SelfApprovalForbidden(String), + #[error("approval is not in an actionable state: {0}")] + ApprovalNotActionable(String), + #[error("dual-control policy config is out of range: {0}")] + DualControlPolicyOutOfRange(String), + #[error("tamper verification failed: {0}")] + TamperVerificationFailed(String), + // Slice 6 §4.6 (AC #15): a correction must REUSE the original posting's + // pinned evidence refs — it may not invent a pinned ref the original never + // had. A 409 Conflict (aborted) carrying the `POLICY_VERSION_VIOLATION` + // wire code (same family as the other aborted conflicts). + #[error("policy version violation: {0}")] + PolicyVersionViolation(String), + + // ── ResourceExhausted (backpressure) ── + #[error("tenant posting is locked: {0}")] + TenantPostingLocked(String), + + // ── NotFound ── + #[error("fiscal period not found: {0}")] + PeriodNotFound(String), + #[error("approval not found: {0}")] + ApprovalNotFound(String), + // Group 3A PII erasure / re-identification: no `payer_pii_map` row exists + // for the `(tenant, payer_tenant_id)` the request targets. + #[error("payer PII map not found: {0}")] + PayerPiiNotFound(String), + #[error("originating posted invoice not found: {0}")] + NoteInvoiceNotFound(String), + #[error("origin settled payment not found for refund: {0}")] + RefundOriginNotFound(String), + + // ── Internal (infrastructure fault; diagnostic stays server-side) ── + #[error("internal: {0}")] + Internal(String), +} diff --git a/gears/bss/ledger/ledger/src/domain/exception.rs b/gears/bss/ledger/ledger/src/domain/exception.rs new file mode 100644 index 000000000..68c31a781 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/exception.rs @@ -0,0 +1,164 @@ +//! Exception-queue taxonomy (Slice 7, design §4.6 / §7). +//! +//! `ExceptionType` is the closed set of durable close-blocking exception kinds +//! (the `ledger_exception_queue.exception_type` CHECK), and `ExceptionStatus` is +//! the resolution lifecycle (`OPEN → ACK → RESOLVED`, or `OPEN → APPROVED_EXCEPTION` +//! for the one acknowledge-to-non-block `GL_WRITEOFF_VARIANCE`). Both are state +//! machines with a persisted wire contract, so they are enums (the +//! [`crate::domain::status`] convention) — the `as_str` token is the stored value, +//! the values never change. + +use std::fmt; + +use toolkit_macros::domain_model; + +/// One durable exception-queue kind (design §4.6 / §7). Every kind blocks period +/// close while `OPEN`; `GL_WRITEOFF_VARIANCE` is the one Finance can acknowledge to +/// `APPROVED_EXCEPTION` (a knowingly-accumulating BSS↔ERP AR overstatement), after +/// which it no longer blocks (N-pay-5). `as_str` is the stored / wire token. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExceptionType { + /// A settled payment with no allocation match (Slice 2). + SettledNoMatch, + /// A Slice 1 `mapping_status = PENDING` suspense line. + MappingGap, + /// A reconciliation variance / a clawback that never matched an outbound + /// refund (the Slice 3 `CLAWBACK_UNDERFLOW` escalation). + ReconMismatch, + /// A Payments↔PSP settlement variance (Phase 3). + PspVariance, + /// A credit note whose recognized-vs-deferred split basis is ambiguous + /// (Slice 3 §4.2 / `CreditNoteSplitAmbiguous`). + SplitAmbiguous, + /// A recognition policy conflict (e.g. a debit-note schedule's catalog-vs- + /// contract ambiguity, `RecognitionPolicyConflict`). + RecognitionPolicyConflict, + /// An unscheduled deferral (Slice 4 §4.2). Reserved — no upstream stub + /// materialises it yet. + UnscheduledDeferral, + /// A stage-1 `REFUND_CLEARING` unrelieved past the 14-day page threshold + /// (Slice 3 aging / Slice 7 PSP tie, §4.6). + StuckRefundClearing, + /// A settlement return that cannot fit under the money-out cap (Slice 2 + /// §4.2, `SettlementReturnOverAllocated`). + SettlementReturnOverAllocated, + /// A chargeback `lost` clawback on an already-refunded payment (Slice 2 §4.5, + /// `ChargebackOnRefunded`). + ChargebackOnRefunded, + /// A GL-side write-off variance — the one acknowledge-to-non-block kind + /// (N-pay-5): Finance approves it to `APPROVED_EXCEPTION` and it no longer + /// blocks close (a tracked, accumulating BSS↔ERP AR overstatement). + GlWriteoffVariance, + /// An issued invoice in the upstream manifest with no committed `INVOICE_POST` + /// (Rev3 / N-recon-1; Phase 3 invoice-completeness check). + MissedPosting, +} + +impl ExceptionType { + /// The stored / wire token (the `exception_type` column value). MUST match the + /// migration CHECK and never change. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::SettledNoMatch => "SETTLED_NO_MATCH", + Self::MappingGap => "MAPPING_GAP", + Self::ReconMismatch => "RECON_MISMATCH", + Self::PspVariance => "PSP_VARIANCE", + Self::SplitAmbiguous => "SPLIT_AMBIGUOUS", + Self::RecognitionPolicyConflict => "RECOGNITION_POLICY_CONFLICT", + Self::UnscheduledDeferral => "UNSCHEDULED_DEFERRAL", + Self::StuckRefundClearing => "STUCK_REFUND_CLEARING", + Self::SettlementReturnOverAllocated => "SETTLEMENT_RETURN_OVER_ALLOCATED", + Self::ChargebackOnRefunded => "CHARGEBACK_ON_REFUNDED", + Self::GlWriteoffVariance => "GL_WRITEOFF_VARIANCE", + Self::MissedPosting => "MISSED_POSTING", + } + } + + /// Parse a stored token back to the enum (`None` on an unknown literal). + #[must_use] + pub fn parse(s: &str) -> Option { + Some(match s { + "SETTLED_NO_MATCH" => Self::SettledNoMatch, + "MAPPING_GAP" => Self::MappingGap, + "RECON_MISMATCH" => Self::ReconMismatch, + "PSP_VARIANCE" => Self::PspVariance, + "SPLIT_AMBIGUOUS" => Self::SplitAmbiguous, + "RECOGNITION_POLICY_CONFLICT" => Self::RecognitionPolicyConflict, + "UNSCHEDULED_DEFERRAL" => Self::UnscheduledDeferral, + "STUCK_REFUND_CLEARING" => Self::StuckRefundClearing, + "SETTLEMENT_RETURN_OVER_ALLOCATED" => Self::SettlementReturnOverAllocated, + "CHARGEBACK_ON_REFUNDED" => Self::ChargebackOnRefunded, + "GL_WRITEOFF_VARIANCE" => Self::GlWriteoffVariance, + "MISSED_POSTING" => Self::MissedPosting, + _ => return None, + }) + } +} + +impl fmt::Display for ExceptionType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The resolution lifecycle of an exception row (design §4.6). An `OPEN` row +/// blocks close; `ACK`/`RESOLVED`/`APPROVED_EXCEPTION` do not. The transitions: +/// `OPEN → ACK → RESOLVED` (operator triage) or `OPEN → APPROVED_EXCEPTION` +/// (Finance, `GL_WRITEOFF_VARIANCE` only). +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExceptionStatus { + /// Open — blocks period close until resolved or approved. + Open, + /// Operator-acknowledged (triage started); no longer blocks close. + Ack, + /// Resolved (the underlying condition cleared). + Resolved, + /// A Finance-approved standing exception (`GL_WRITEOFF_VARIANCE` only). + ApprovedException, +} + +impl ExceptionStatus { + /// The stored / wire token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Open => "OPEN", + Self::Ack => "ACK", + Self::Resolved => "RESOLVED", + Self::ApprovedException => "APPROVED_EXCEPTION", + } + } + + /// Parse a stored token (`None` on an unknown literal). + #[must_use] + pub fn parse(s: &str) -> Option { + Some(match s { + "OPEN" => Self::Open, + "ACK" => Self::Ack, + "RESOLVED" => Self::Resolved, + "APPROVED_EXCEPTION" => Self::ApprovedException, + _ => return None, + }) + } + + /// Whether an exception in this status blocks period close. Only `OPEN` does; + /// every resolution state (incl. the Finance-approved GL-writeoff) clears the + /// block (mirrors the close gate's `list_open_in_txn`, which filters `OPEN`). + #[must_use] + pub const fn blocks_close(self) -> bool { + matches!(self, Self::Open) + } +} + +impl fmt::Display for ExceptionStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +#[path = "exception_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/exception_tests.rs b/gears/bss/ledger/ledger/src/domain/exception_tests.rs new file mode 100644 index 000000000..42510197c --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/exception_tests.rs @@ -0,0 +1,80 @@ +//! Tests for the exception-queue taxonomy enums. + +#![allow(clippy::unwrap_used)] + +use super::{ExceptionStatus, ExceptionType}; + +/// Every `ExceptionType` round-trips through its stored token. +#[test] +fn exception_type_round_trips() { + let all = [ + ExceptionType::SettledNoMatch, + ExceptionType::MappingGap, + ExceptionType::ReconMismatch, + ExceptionType::PspVariance, + ExceptionType::SplitAmbiguous, + ExceptionType::RecognitionPolicyConflict, + ExceptionType::UnscheduledDeferral, + ExceptionType::StuckRefundClearing, + ExceptionType::SettlementReturnOverAllocated, + ExceptionType::ChargebackOnRefunded, + ExceptionType::GlWriteoffVariance, + ExceptionType::MissedPosting, + ]; + for ty in all { + assert_eq!( + ExceptionType::parse(ty.as_str()), + Some(ty), + "{ty} round-trip" + ); + } + assert_eq!(ExceptionType::parse("NOPE"), None); +} + +/// The tokens match the migration CHECK literals exactly (a drift would make a +/// routed row fail the constraint at insert). +#[test] +fn exception_type_tokens_are_stable() { + assert_eq!(ExceptionType::ReconMismatch.as_str(), "RECON_MISMATCH"); + assert_eq!(ExceptionType::SplitAmbiguous.as_str(), "SPLIT_AMBIGUOUS"); + assert_eq!( + ExceptionType::ChargebackOnRefunded.as_str(), + "CHARGEBACK_ON_REFUNDED" + ); + assert_eq!( + ExceptionType::SettlementReturnOverAllocated.as_str(), + "SETTLEMENT_RETURN_OVER_ALLOCATED" + ); + assert_eq!( + ExceptionType::StuckRefundClearing.as_str(), + "STUCK_REFUND_CLEARING" + ); + assert_eq!( + ExceptionType::GlWriteoffVariance.as_str(), + "GL_WRITEOFF_VARIANCE" + ); +} + +/// Only `OPEN` blocks close; every resolution state (incl. the Finance-approved +/// GL-writeoff) clears the block. +#[test] +fn only_open_blocks_close() { + assert!(ExceptionStatus::Open.blocks_close()); + assert!(!ExceptionStatus::Ack.blocks_close()); + assert!(!ExceptionStatus::Resolved.blocks_close()); + assert!(!ExceptionStatus::ApprovedException.blocks_close()); +} + +/// `ExceptionStatus` round-trips through its stored token. +#[test] +fn exception_status_round_trips() { + for st in [ + ExceptionStatus::Open, + ExceptionStatus::Ack, + ExceptionStatus::Resolved, + ExceptionStatus::ApprovedException, + ] { + assert_eq!(ExceptionStatus::parse(st.as_str()), Some(st)); + } + assert_eq!(ExceptionStatus::parse("nope"), None); +} diff --git a/gears/bss/ledger/ledger/src/domain/fx.rs b/gears/bss/ledger/ledger/src/domain/fx.rs new file mode 100644 index 000000000..47f84ecff --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/fx.rs @@ -0,0 +1,9 @@ +//! FX & multi-currency domain (Slice 5): pure functional-currency translation +//! (`translate`), pure realized-FX gain/loss on a cross-currency close +//! (`realized`), pure period-end unrealized revaluation (`revaluation`), and the +//! per-tenant revaluation mode (`revaluation_mode`, VHP-1986). + +pub mod realized; +pub mod revaluation; +pub mod revaluation_mode; +pub mod translate; diff --git a/gears/bss/ledger/ledger/src/domain/fx/realized.rs b/gears/bss/ledger/ledger/src/domain/fx/realized.rs new file mode 100644 index 000000000..4318f0e7d --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/fx/realized.rs @@ -0,0 +1,198 @@ +//! Pure realized-FX (design §3.5 / §4.4): when a cross-currency position +//! **closes** (settle / allocate / refund / chargeback) at a rate ≠ its carried +//! rate, compute the net realized FX as a single `FX_GAIN_LOSS` functional-only +//! line so the close entry's **functional** column balances by construction. +//! +//! Each closing account is relieved at **its own** carried functional value (the +//! grain's `functional_balance_minor`), weighted-average (WAC) **pro-rata** for a +//! partial close (`functional_balance_minor / balance_minor`, banker's rounding — +//! the ratified carried-rate policy, decision 3). The net functional imbalance +//! between the relief legs is the realized gain/loss, plugged to one +//! `FX_GAIN_LOSS` line. +//! +//! **Forbidden (spec §3.5):** rescanning `journal_line`, or averaging across +//! grains — each leg's carried functional is read ONLY from its own grain input, +//! so two grains that received settlements at different rates keep their distinct +//! carried values (no cross-grain blend). The structure here enforces it: every +//! [`ClosingLeg`] carries its own grain values and is relieved independently. +//! +//! No infra; the close-path caller (Phase 2 Group F — deferred with the live +//! S1/S2/S3 functional-stamping hook) reads the carried grain values and the +//! relieved transaction amounts and feeds them here. + +use bss_ledger_sdk::Side; +use toolkit_macros::domain_model; + +use crate::domain::money_math::{checked_minor, round_half_even}; + +/// One account relieved by a close entry, valued at its grain's carried +/// functional. The transaction-relief leg posts on `side` (CR to relieve a +/// debit-normal AR balance, DR to relieve a credit-normal Unallocated balance, +/// …); the realized poster carries the WAC-pro-rata functional relief on the SAME +/// side, then plugs the net imbalance to `FX_GAIN_LOSS`. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClosingLeg { + /// The side the relief leg posts on (the side that moves the grain's + /// transaction balance toward zero). + pub side: Side, + /// The grain's carried functional balance (minor) before this relief + /// (`functional_balance_minor`). A cross-currency grain always has it + /// populated (P1 decision 8) — a real carried value. MUST be `>= 0`. + pub carried_functional_minor: i64, + /// The grain's transaction balance (minor) before this relief + /// (`balance_minor`) — the WAC denominator. MUST be `> 0` (a closing grain + /// holds a positive balance). + pub carried_transaction_minor: i64, + /// The transaction amount relieved on this leg (minor): a FULL close relieves + /// the whole `carried_transaction_minor`; a PARTIAL close relieves less. MUST + /// be `> 0` and `<= carried_transaction_minor`. + pub relieved_transaction_minor: i64, +} + +/// The realized-FX result: the per-leg functional relief (input order, for the +/// in-txn grain decrement) + the single net `FX_GAIN_LOSS` line (or `None` when +/// the close is at the carried rate — Δ == 0, no realized FX). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RealizedFx { + /// Functional relieved per leg (input order) — what the sidecar decrements + /// from each grain's `functional_balance_minor` in the same close txn. + pub leg_functional_minor: Vec, + /// The balancing `FX_GAIN_LOSS` line, or `None` at the carried rate. + pub fx_line: Option, +} + +/// The single net `FX_GAIN_LOSS` functional-only line that balances the close +/// entry's functional column. `side` encodes the sign-by-role: a realized **loss** +/// is a **debit** (expense), a **gain** a **credit**; `functional_minor` is always +/// `> 0`. The transaction `amount_minor` of this line is `0` (functional-only) — +/// the caller sets that when it materializes the line. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RealizedFxLine { + pub side: Side, + pub functional_minor: i64, +} + +/// A realized-FX computation failure — every variant is a caller **misuse** (a +/// malformed closing leg), not a business "no rate" condition; the close-path +/// caller maps it to [`crate::domain::error::DomainError::Internal`] (a 500 whose +/// diagnostic stays server-side), exactly like `RateLocker`'s `map_translate_err`. +#[domain_model] +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RealizedFxError { + #[error("closing leg carried transaction balance must be > 0")] + NonPositiveCarriedTransaction, + #[error("closing leg relieved amount must be > 0 and <= the carried transaction balance")] + RelievedOutOfRange, + #[error("closing leg carried functional balance must be >= 0")] + NegativeCarriedFunctional, + #[error("realized FX amount out of range: {0}")] + Overflow(i128), +} + +/// WAC pro-rata functional relief for one leg — the [`ClosingLeg`] adapter over +/// [`carried_relief`]. +fn relieved_functional(leg: &ClosingLeg) -> Result { + carried_relief( + leg.carried_functional_minor, + leg.carried_transaction_minor, + leg.relieved_transaction_minor, + ) +} + +/// WAC pro-rata functional relief for a single closing position: +/// `carried_functional × relieved / carried_transaction`, banker's rounding. A +/// full close (`relieved == carried_transaction`) relieves exactly +/// `carried_functional` (the formula is `f × t / t = f`, no drift). +/// +/// The shared carried-rate primitive (decision 3): the realized-FX poster reaches +/// it through a [`ClosingLeg`] (allocate close, F1), and the chargeback functional +/// **carry-forward** (F3) calls it directly — a chargeback relieves the closing +/// grain at this WAC value and stamps the SAME value on the counter-leg, so the +/// entry's functional column nets to zero (a reclassification carries the +/// historical cost basis; realized FX is recognised only at a cash in/out point). +/// +/// # Errors +/// [`RealizedFxError`] when `carried_transaction_minor <= 0`, +/// `relieved_transaction_minor` is out of `(0, carried_transaction_minor]`, +/// `carried_functional_minor < 0`, or the product overflows `i64`. +pub fn carried_relief( + carried_functional_minor: i64, + carried_transaction_minor: i64, + relieved_transaction_minor: i64, +) -> Result { + if carried_transaction_minor <= 0 { + return Err(RealizedFxError::NonPositiveCarriedTransaction); + } + if relieved_transaction_minor <= 0 || relieved_transaction_minor > carried_transaction_minor { + return Err(RealizedFxError::RelievedOutOfRange); + } + if carried_functional_minor < 0 { + return Err(RealizedFxError::NegativeCarriedFunctional); + } + let raw = round_half_even( + i128::from(carried_functional_minor) * i128::from(relieved_transaction_minor), + i128::from(carried_transaction_minor), + ); + checked_minor(raw).map_err(|_| RealizedFxError::Overflow(raw)) +} + +/// Compute the realized FX for a close that relieves `legs`. Each leg is relieved +/// at its OWN grain's carried functional (WAC pro-rata for a partial close); the +/// net functional imbalance between the relief legs is plugged to a single +/// `FX_GAIN_LOSS` line so the close entry's functional column balances by +/// construction. +/// +/// Sign-by-role — the relief legs sum `Δ = Σ_DR_func − Σ_CR_func`: +/// - `Δ < 0` (debit relief short) → **DEBIT** `FX_GAIN_LOSS` of `|Δ|` (a realized +/// **loss**), so `DR_total` rises to meet `CR_total`. +/// - `Δ > 0` (credit relief short) → **CREDIT** `FX_GAIN_LOSS` of `Δ` (a realized +/// **gain**). +/// - `Δ == 0` (closed at the carried rate) → `fx_line = None`, no realized FX. +/// +/// An empty `legs` (no close) yields `fx_line = None` and an empty relief vector. +/// +/// **Carried value is read ONLY from the per-grain inputs** — never by rescanning +/// `journal_line`, never by averaging across grains (spec §3.5). +/// +/// # Errors +/// [`RealizedFxError`] on a malformed closing leg (non-positive carried balance, +/// out-of-range relieved amount, negative carried functional, or an `i64` +/// overflow). +pub fn realize(legs: &[ClosingLeg]) -> Result { + let mut leg_functional_minor = Vec::with_capacity(legs.len()); + // Σ_DR_func − Σ_CR_func over the relief legs (i128 so a wide multi-leg close + // cannot overflow the accumulator before the final i64 narrow). + let mut net: i128 = 0; + for leg in legs { + let f = relieved_functional(leg)?; + net += match leg.side { + Side::Debit => i128::from(f), + Side::Credit => -i128::from(f), + }; + leg_functional_minor.push(f); + } + // Plug the net imbalance to FX_GAIN_LOSS so DR_total == CR_total over the + // whole entry (relief legs + the FX line). + let fx_line = match net.cmp(&0) { + std::cmp::Ordering::Equal => None, + std::cmp::Ordering::Less => Some(RealizedFxLine { + side: Side::Debit, + functional_minor: checked_minor(-net).map_err(|_| RealizedFxError::Overflow(-net))?, + }), + std::cmp::Ordering::Greater => Some(RealizedFxLine { + side: Side::Credit, + functional_minor: checked_minor(net).map_err(|_| RealizedFxError::Overflow(net))?, + }), + }; + Ok(RealizedFx { + leg_functional_minor, + fx_line, + }) +} + +#[cfg(test)] +#[path = "realized_tests.rs"] +mod realized_tests; diff --git a/gears/bss/ledger/ledger/src/domain/fx/realized_tests.rs b/gears/bss/ledger/ledger/src/domain/fx/realized_tests.rs new file mode 100644 index 000000000..9037c4f26 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/fx/realized_tests.rs @@ -0,0 +1,215 @@ +//! Unit tests for the pure realized-FX poster (`domain/fx/realized.rs`): the +//! worked-example-C oracle, sign-by-role (loss DR / gain CR), the same-rate +//! no-FX case, WAC pro-rata partial relief, the blended-grain (two-rate) carry, +//! the no-cross-grain-average invariant, and the malformed-leg errors. Every +//! result is also checked against the functional-column balance invariant +//! (relief legs + FX line net to zero). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +/// A `ClosingLeg` literal. +fn leg( + side: Side, + carried_functional_minor: i64, + carried_transaction_minor: i64, + relieved_transaction_minor: i64, +) -> ClosingLeg { + ClosingLeg { + side, + carried_functional_minor, + carried_transaction_minor, + relieved_transaction_minor, + } +} + +/// Assert the close entry's functional column balances: Σ DR (relief legs + FX +/// line) == Σ CR. This is the invariant `realize` must guarantee by construction. +fn assert_functional_balances(legs: &[ClosingLeg], r: &RealizedFx) { + let mut dr: i128 = 0; + let mut cr: i128 = 0; + for (leg, &f) in legs.iter().zip(&r.leg_functional_minor) { + match leg.side { + Side::Debit => dr += i128::from(f), + Side::Credit => cr += i128::from(f), + } + } + if let Some(fx) = r.fx_line { + match fx.side { + Side::Debit => dr += i128::from(fx.functional_minor), + Side::Credit => cr += i128::from(fx.functional_minor), + } + } + assert_eq!(dr, cr, "functional column must balance (DR == CR)"); +} + +#[test] +fn example_c_full_close_nets_240_usd_loss() { + // Spec worked example C: USD functional, EUR invoice. Allocate closes both: + // DR Unallocated 129.60 (carried) / CR AR 132.00 (carried), 120 EUR each. + // Functional short 2.40 on the DR side → DR FX loss 2.40 (240 minor). + let legs = [ + leg(Side::Debit, 12_960, 12_000, 12_000), // Unallocated, carried 129.60 + leg(Side::Credit, 13_200, 12_000, 12_000), // AR, carried 132.00 + ]; + let r = realize(&legs).unwrap(); + // Full close → each leg relieves its whole carried functional. + assert_eq!(r.leg_functional_minor, vec![12_960, 13_200]); + assert_eq!( + r.fx_line, + Some(RealizedFxLine { + side: Side::Debit, + functional_minor: 240, + }), + "net 2.40 USD realized LOSS on the DR side" + ); + assert_functional_balances(&legs, &r); +} + +#[test] +fn gain_direction_credits_short_emits_credit_fx() { + // Mirror of example C with the carried values swapped → credits short → a + // realized GAIN on the CR side. + let legs = [ + leg(Side::Debit, 13_200, 12_000, 12_000), + leg(Side::Credit, 12_960, 12_000, 12_000), + ]; + let r = realize(&legs).unwrap(); + assert_eq!( + r.fx_line, + Some(RealizedFxLine { + side: Side::Credit, + functional_minor: 240, + }), + "credits short → realized GAIN on the CR side" + ); + assert_functional_balances(&legs, &r); +} + +#[test] +fn same_rate_close_emits_no_fx_line() { + // Both legs carried at the same rate → the relief nets to zero → no realized FX. + let legs = [ + leg(Side::Debit, 12_000, 12_000, 12_000), + leg(Side::Credit, 12_000, 12_000, 12_000), + ]; + let r = realize(&legs).unwrap(); + assert_eq!(r.fx_line, None, "a same-rate close posts no FX line"); + assert_functional_balances(&legs, &r); +} + +#[test] +fn partial_close_relieves_wac_prorata_half() { + // Relieve HALF of example C's position → half the relief + half the FX (1.20). + let legs = [ + leg(Side::Debit, 12_960, 12_000, 6_000), // 12960 * 6000/12000 = 6480 + leg(Side::Credit, 13_200, 12_000, 6_000), // 13200 * 6000/12000 = 6600 + ]; + let r = realize(&legs).unwrap(); + assert_eq!(r.leg_functional_minor, vec![6_480, 6_600]); + assert_eq!( + r.fx_line, + Some(RealizedFxLine { + side: Side::Debit, + functional_minor: 120, + }), + "half close → half the realized loss (1.20)" + ); + assert_functional_balances(&legs, &r); +} + +#[test] +fn blended_grain_two_rates_relieves_at_wac() { + // A grain that took two settlements at different rates (132.00 + 129.60 over + // 24000 EUR) carries the BLEND (261.60 / 24000 = WAC 1.09). Relieving 12000 + // relieves 130.80 (13080 minor) — the WAC, not either original rate. + let legs = [leg(Side::Debit, 26_160, 24_000, 12_000)]; + let r = realize(&legs).unwrap(); + assert_eq!( + r.leg_functional_minor, + vec![13_080], + "relief is the grain's blended WAC, not a per-settlement rate" + ); + // One unbalanced leg → the whole relief is the realized FX (DR relief short on + // the CR side → CR FX gain of 13080). + assert_eq!( + r.fx_line, + Some(RealizedFxLine { + side: Side::Credit, + functional_minor: 13_080, + }) + ); + assert_functional_balances(&legs, &r); +} + +#[test] +fn full_close_relieves_exact_carried_no_drift() { + // relieved == carried_transaction → relieved functional == carried functional + // exactly (f * t / t = f), regardless of the carried values. + for (cf, ct) in [(13_201, 12_000), (1, 7), (999_983, 100_001)] { + let l = leg(Side::Debit, cf, ct, ct); + let r = realize(std::slice::from_ref(&l)).unwrap(); + assert_eq!( + r.leg_functional_minor, + vec![cf], + "a full close relieves the exact carried functional ({cf}/{ct})" + ); + } +} + +#[test] +fn each_leg_uses_its_own_carried_no_cross_grain_average() { + // Two grains carried at DIFFERENT rates closed in one entry: each leg relieves + // ITS OWN carried functional — NEVER a cross-grain average (spec §3.5). + let legs = [ + leg(Side::Credit, 13_200, 12_000, 12_000), // AR @1.10 + leg(Side::Debit, 12_960, 12_000, 12_000), // Unallocated @1.08 + ]; + let r = realize(&legs).unwrap(); + assert_eq!(r.leg_functional_minor[0], 13_200, "AR keeps its own carry"); + assert_eq!( + r.leg_functional_minor[1], 12_960, + "Unallocated keeps its own carry (not (13200+12960)/2 = 13080)" + ); +} + +#[test] +fn empty_close_is_a_no_op() { + let r = realize(&[]).unwrap(); + assert!(r.leg_functional_minor.is_empty()); + assert_eq!(r.fx_line, None); +} + +#[test] +fn non_positive_carried_transaction_rejected() { + assert_eq!( + realize(&[leg(Side::Debit, 100, 0, 0)]), + Err(RealizedFxError::NonPositiveCarriedTransaction) + ); + assert_eq!( + realize(&[leg(Side::Debit, 100, -5, 1)]), + Err(RealizedFxError::NonPositiveCarriedTransaction) + ); +} + +#[test] +fn relieved_out_of_range_rejected() { + // relieved > carried_transaction. + assert_eq!( + realize(&[leg(Side::Debit, 100, 12_000, 12_001)]), + Err(RealizedFxError::RelievedOutOfRange) + ); + // relieved == 0 (nothing relieved is not a close leg). + assert_eq!( + realize(&[leg(Side::Debit, 100, 12_000, 0)]), + Err(RealizedFxError::RelievedOutOfRange) + ); +} + +#[test] +fn negative_carried_functional_rejected() { + assert_eq!( + realize(&[leg(Side::Debit, -1, 12_000, 12_000)]), + Err(RealizedFxError::NegativeCarriedFunctional) + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/fx/revaluation.rs b/gears/bss/ledger/ledger/src/domain/fx/revaluation.rs new file mode 100644 index 000000000..4d5a1b89d --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/fx/revaluation.rs @@ -0,0 +1,239 @@ +//! Pure unrealized-revaluation (design §3.6 / §4.5): at period end a Mode-B +//! ledger (= ledger of record, decision 4) remeasures every foreign-currency +//! **monetary** grain `{AR, UNALLOCATED, REUSABLE_CREDIT}` at the period-end rate +//! against its **carried** functional value and books the difference to a contra +//! `FX_UNREALIZED` line so the entry's functional column balances by +//! construction. The whole entry is **functional-only** (`amount_minor = 0` on +//! every line) — it adjusts the functional column only and passes the +//! transaction-balance check trivially (zero in-scope lines, design §4.5). +//! +//! `CONTRACT_LIABILITY` is deliberately **excluded** — it is non-monetary (a +//! deferred performance obligation), which ASC 830 / IAS 21 does not remeasure. +//! +//! This is the unrealized sibling of [`crate::domain::fx::realized`] and shares +//! its shape: the per-grain remeasure legs move each grain's functional carrying +//! value, and the net imbalance plugs to a **single** `FX_UNREALIZED` line +//! (sign-by-role, like `FX_GAIN_LOSS`). Unlike realized FX — which is permanent, +//! booked at a cash in/out point — a revaluation is a **temporary** remeasurement +//! that is **reversed** on the first day of the next OPEN period (decision 7); a +//! foreign position's true gain/loss is realized only when it closes (§4.4). The +//! reversal is a fresh `FX_REVAL_REVERSAL` JE built by the run layer (Group H3) +//! by negating this entry's posted lines — it does not use this module. +//! +//! Pure: no infra. The run (Group H2) enumerates the carried grain values, +//! resolves the period-end rate, translates each grain's transaction balance to +//! its remeasured functional value, then feeds the positions here. + +use bss_ledger_sdk::Side; +use toolkit_macros::domain_model; + +use crate::domain::money_math::checked_minor; + +/// Which monetary grain class a revaluation run covers. The non-monetary +/// `CONTRACT_LIABILITY` is deliberately absent (ASC 830 / IAS 21 — design §4.5). +/// One scope = one run + one entry + one idempotency family (`business_id = +/// period_id:scope`), so the three monetary classes revalue independently. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RevaluationScope { + /// Open AR (`ar_invoice_balance`) — a monetary **asset** (debit-normal). + Ar, + /// Unapplied prepayment (`unallocated_balance`) — a monetary **liability** + /// owed back to the customer (credit-normal). + Unallocated, + /// Customer wallet (`reusable_credit_subbalance`) — a monetary **liability** + /// held for the customer (credit-normal). + ReusableCredit, +} + +impl RevaluationScope { + /// The grain's normal balance side: AR is an asset (debit-normal); the + /// customer-owed monetary liabilities are credit-normal. Drives the sign of + /// the per-grain adjusting leg. + #[must_use] + pub const fn normal_side(self) -> Side { + match self { + RevaluationScope::Ar => Side::Debit, + RevaluationScope::Unallocated | RevaluationScope::ReusableCredit => Side::Credit, + } + } + + /// The scope token used in the idempotency `business_id` (`period_id:scope`) + /// for both the `FX_REVALUATION` run and the `FX_REVAL_REVERSAL` reversal. + #[must_use] + pub const fn as_token(self) -> &'static str { + match self { + RevaluationScope::Ar => "AR", + RevaluationScope::Unallocated => "UNALLOCATED", + RevaluationScope::ReusableCredit => "REUSABLE_CREDIT", + } + } + + /// The three covered scopes, in a stable order (the run iterates these). + #[must_use] + pub const fn all() -> [RevaluationScope; 3] { + [ + RevaluationScope::Ar, + RevaluationScope::Unallocated, + RevaluationScope::ReusableCredit, + ] + } +} + +/// One monetary grain to remeasure at period end. The run reads the grain's +/// carried functional value and computes its period-end remeasured functional +/// value (the grain's transaction balance valued at the period-end rate), then +/// asks this module for the adjusting leg. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RevaluationPosition { + /// The grain's normal balance side (asset = debit, liability = credit), from + /// the run's [`RevaluationScope::normal_side`]. + pub normal_side: Side, + /// The grain's carried functional balance (minor) before remeasurement + /// (`functional_balance_minor`). A cross-currency grain always has it + /// populated (P1 decision 8) — a real carried value. MUST be `>= 0`. + pub carried_functional_minor: i64, + /// The grain's period-end remeasured functional value (minor): the grain's + /// transaction balance valued at the period-end rate. MUST be `>= 0`. + pub remeasured_functional_minor: i64, +} + +/// A single functional-only remeasurement line: a `side` + a positive +/// `functional_minor`. Used both for a per-grain adjusting leg and for the net +/// `FX_UNREALIZED` contra line. The transaction `amount_minor` of the +/// materialized line is `0` (functional-only) — the caller sets that. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RevaluationLine { + pub side: Side, + pub functional_minor: i64, +} + +/// The result of remeasuring a set of same-scope positions: the per-position +/// adjusting leg (input order; `None` where the grain is already at the +/// period-end rate, Δ == 0) plus the single net `FX_UNREALIZED` contra line +/// (`None` when every Δ == 0, i.e. nothing to post). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Revaluation { + /// Per-position adjusting leg in input order; `None` for a grain at the + /// period-end rate (no movement). + pub grain_lines: Vec>, + /// The balancing `FX_UNREALIZED` contra line, or `None` when the whole run + /// nets to zero (no entry to post). + pub fx_unrealized: Option, +} + +impl Revaluation { + /// True when there is nothing to post (every grain at the period-end rate). + #[must_use] + pub fn is_empty(&self) -> bool { + self.fx_unrealized.is_none() + } +} + +/// A revaluation computation failure — every variant is a caller **misuse** (a +/// malformed position), not a business condition; the run maps it to +/// [`crate::domain::error::DomainError::Internal`] (a 500 whose diagnostic stays +/// server-side), exactly like [`crate::domain::fx::realized::RealizedFxError`]. +#[domain_model] +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RevaluationError { + #[error("revaluation position carried functional balance must be >= 0")] + NegativeCarriedFunctional, + #[error("revaluation position remeasured functional value must be >= 0")] + NegativeRemeasured, + #[error("revaluation amount out of range: {0}")] + Overflow(i128), +} + +/// The opposite posting side. +const fn opposite(side: Side) -> Side { + match side { + Side::Debit => Side::Credit, + Side::Credit => Side::Debit, + } +} + +/// Compute the per-grain adjusting leg for one position. The carrying value moves +/// from `carried` to `remeasured`; the magnitude `|Δ|` posts on the grain's +/// **normal** side when the carrying value RISES (Δ > 0, the grain is worth more +/// in functional terms) and on the **opposite** side when it FALLS (Δ < 0). +/// `Δ == 0` → no leg. +fn adjust_leg(pos: &RevaluationPosition) -> Result, RevaluationError> { + if pos.carried_functional_minor < 0 { + return Err(RevaluationError::NegativeCarriedFunctional); + } + if pos.remeasured_functional_minor < 0 { + return Err(RevaluationError::NegativeRemeasured); + } + let delta = + i128::from(pos.remeasured_functional_minor) - i128::from(pos.carried_functional_minor); + Ok(match delta.cmp(&0) { + std::cmp::Ordering::Equal => None, + std::cmp::Ordering::Greater => Some(RevaluationLine { + side: pos.normal_side, + functional_minor: checked_minor(delta) + .map_err(|_| RevaluationError::Overflow(delta))?, + }), + std::cmp::Ordering::Less => Some(RevaluationLine { + side: opposite(pos.normal_side), + functional_minor: checked_minor(-delta) + .map_err(|_| RevaluationError::Overflow(-delta))?, + }), + }) +} + +/// Remeasure a set of **same-scope** positions at the period-end rate. Each +/// grain's carrying value is adjusted from its carried functional to its +/// remeasured functional; the net imbalance across the grain legs plugs to a +/// single `FX_UNREALIZED` line so `DR_total == CR_total` over the whole entry. +/// +/// Sign-by-role — the grain legs sum `Δ = Σ_DR_func − Σ_CR_func`: +/// - `Δ < 0` (debit side short) → **DEBIT** `FX_UNREALIZED` of `|Δ|` (a net +/// unrealized **loss** raises `DR_total` to meet `CR_total`). +/// - `Δ > 0` (credit side short) → **CREDIT** `FX_UNREALIZED` of `Δ` (a net +/// unrealized **gain**). +/// - `Δ == 0` (no movement) → `fx_unrealized = None`, nothing to post. +/// +/// An empty `positions` yields an empty result (`fx_unrealized = None`). +/// +/// # Errors +/// [`RevaluationError`] on a malformed position (negative carried/remeasured +/// functional, or an `i64` overflow on the magnitude). +pub fn remeasure(positions: &[RevaluationPosition]) -> Result { + let mut grain_lines = Vec::with_capacity(positions.len()); + // Σ_DR_func − Σ_CR_func over the grain legs (i128 so a wide multi-grain run + // cannot overflow the accumulator before the final i64 narrow). + let mut net: i128 = 0; + for pos in positions { + let leg = adjust_leg(pos)?; + if let Some(l) = leg { + net += match l.side { + Side::Debit => i128::from(l.functional_minor), + Side::Credit => -i128::from(l.functional_minor), + }; + } + grain_lines.push(leg); + } + let fx_unrealized = match net.cmp(&0) { + std::cmp::Ordering::Equal => None, + std::cmp::Ordering::Less => Some(RevaluationLine { + side: Side::Debit, + functional_minor: checked_minor(-net).map_err(|_| RevaluationError::Overflow(-net))?, + }), + std::cmp::Ordering::Greater => Some(RevaluationLine { + side: Side::Credit, + functional_minor: checked_minor(net).map_err(|_| RevaluationError::Overflow(net))?, + }), + }; + Ok(Revaluation { + grain_lines, + fx_unrealized, + }) +} + +#[cfg(test)] +#[path = "revaluation_tests.rs"] +mod revaluation_tests; diff --git a/gears/bss/ledger/ledger/src/domain/fx/revaluation_mode.rs b/gears/bss/ledger/ledger/src/domain/fx/revaluation_mode.rs new file mode 100644 index 000000000..47c2764e1 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/fx/revaluation_mode.rs @@ -0,0 +1,86 @@ +//! Tenant-configurable FX **revaluation mode** (VHP-1986, design 06 §4.5 / +//! §13 F2): whether BSS runs the period-end **unrealized revaluation** for a +//! tenant, or defers to the tenant's ERP. Pure domain — the literal, the parse, +//! and the fail-safe default; the effective row is loaded by the repo (`infra`) +//! and this value type is its shape. No infra / DB imports (dylint DE0301). + +use toolkit_macros::domain_model; + +/// Revaluation-mode parse failure (a corrupt stored value, or a bad write). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum RevaluationModeError { + /// The stored / wire value was neither `MODE_A` nor `MODE_B`. + #[error("unknown revaluation mode: {0}")] + UnknownMode(String), +} + +/// Who is the ledger of record for period-end FX remeasurement (design 06 §4.5 / +/// §13 F2 — ASC 830 / IAS 21 requires remeasurement in whatever ledger produces +/// the reporting balances). +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum RevaluationMode { + /// The tenant's **ERP / GL** is the ledger of record and performs the + /// period-end revaluation; BSS must **not** revalue (it would double-count). + /// The default + fail-safe: an un-configured tenant is never revalued by BSS. + #[default] + ModeA, + /// **BSS is the ledger of record** with open multi-currency monetary + /// positions; BSS runs the period-end unrealized revaluation (`FX_UNREALIZED`). + ModeB, +} + +impl RevaluationMode { + /// Stored / wire literal for [`Self::ModeA`]. + pub const MODE_A: &'static str = "MODE_A"; + /// Stored / wire literal for [`Self::ModeB`]. + pub const MODE_B: &'static str = "MODE_B"; + + /// The stored / wire literal. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ModeA => Self::MODE_A, + Self::ModeB => Self::MODE_B, + } + } + + /// Parse the stored / wire literal. + /// + /// # Errors + /// [`RevaluationModeError::UnknownMode`] for any other string. + pub fn parse(s: &str) -> Result { + match s { + Self::MODE_A => Ok(Self::ModeA), + Self::MODE_B => Ok(Self::ModeB), + other => Err(RevaluationModeError::UnknownMode(other.to_owned())), + } + } + + /// Whether BSS runs the period-end unrealized revaluation for a tenant in this + /// mode. Only [`Self::ModeB`] (BSS = ledger of record) revalues; [`Self::ModeA`] + /// defers to the tenant's ERP (design 06 §4.5 / §13 F2). + #[must_use] + pub const fn revalues(self) -> bool { + matches!(self, Self::ModeB) + } + + /// The fleet default applied to a tenant with NO explicit mode row (VHP-1986): + /// the global `fx.revaluation_enabled` flag — `true` ⇒ [`Self::ModeB`] (the + /// design-06 §13 F2 "Mode B default-on" fleet behaviour), `false` ⇒ + /// [`Self::ModeA`] (fail-safe off, the v1 prod default). An explicit per-tenant + /// row OVERRIDES this (so a tenant can opt out even when the fleet is on). + #[must_use] + pub const fn fleet_default(global_revaluation_enabled: bool) -> Self { + if global_revaluation_enabled { + Self::ModeB + } else { + Self::ModeA + } + } +} + +#[cfg(test)] +#[path = "revaluation_mode_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/fx/revaluation_mode_tests.rs b/gears/bss/ledger/ledger/src/domain/fx/revaluation_mode_tests.rs new file mode 100644 index 000000000..60d9a0e5a --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/fx/revaluation_mode_tests.rs @@ -0,0 +1,42 @@ +//! Tests for the tenant FX revaluation-mode value type ([`super`]). + +use super::*; + +#[test] +fn revaluation_mode_round_trips() { + assert_eq!(RevaluationMode::parse("MODE_A"), Ok(RevaluationMode::ModeA)); + assert_eq!(RevaluationMode::parse("MODE_B"), Ok(RevaluationMode::ModeB)); + assert_eq!(RevaluationMode::ModeA.as_str(), "MODE_A"); + assert_eq!(RevaluationMode::ModeB.as_str(), "MODE_B"); + assert!(RevaluationMode::parse("nope").is_err()); + assert!(RevaluationMode::parse("mode_a").is_err(), "case-sensitive"); +} + +#[test] +fn revaluation_mode_default_is_mode_a_fail_safe() { + assert_eq!(RevaluationMode::default(), RevaluationMode::ModeA); + assert!( + !RevaluationMode::default().revalues(), + "the un-configured default must never revalue (fail-safe vs ERP double-count)" + ); +} + +#[test] +fn only_mode_b_revalues() { + assert!(!RevaluationMode::ModeA.revalues()); + assert!(RevaluationMode::ModeB.revalues()); +} + +#[test] +fn fleet_default_follows_the_global_flag() { + assert_eq!( + RevaluationMode::fleet_default(false), + RevaluationMode::ModeA, + "global off ⇒ unconfigured tenants default to fail-safe ModeA" + ); + assert_eq!( + RevaluationMode::fleet_default(true), + RevaluationMode::ModeB, + "global on ⇒ unconfigured tenants default to ModeB (fleet default-on)" + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/fx/revaluation_tests.rs b/gears/bss/ledger/ledger/src/domain/fx/revaluation_tests.rs new file mode 100644 index 000000000..14e1c9b8f --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/fx/revaluation_tests.rs @@ -0,0 +1,266 @@ +//! Unit tests for the pure unrealized-revaluation remeasure (`revaluation.rs`): +//! sign-by-role across asset (AR) vs liability (UNALLOCATED / `REUSABLE_CREDIT`) +//! grains in both rate directions (gain / loss), the no-movement (Δ == 0) case, +//! a multi-grain same-scope net, the empty run, and the malformed-position +//! errors. Every result is also checked against the functional-column balance +//! invariant (grain legs + the `FX_UNREALIZED` line net to zero). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +/// A `RevaluationPosition` literal. +fn pos( + normal_side: Side, + carried_functional_minor: i64, + remeasured_functional_minor: i64, +) -> RevaluationPosition { + RevaluationPosition { + normal_side, + carried_functional_minor, + remeasured_functional_minor, + } +} + +/// Assert the revaluation entry's functional column balances: Σ DR (grain legs + +/// the `FX_UNREALIZED` line) == Σ CR. This is the invariant `remeasure` must +/// guarantee by construction (the whole entry is functional-only). +fn assert_functional_balances(r: &Revaluation) { + let mut dr: i128 = 0; + let mut cr: i128 = 0; + for line in r.grain_lines.iter().flatten() { + match line.side { + Side::Debit => dr += i128::from(line.functional_minor), + Side::Credit => cr += i128::from(line.functional_minor), + } + } + if let Some(fx) = r.fx_unrealized { + match fx.side { + Side::Debit => dr += i128::from(fx.functional_minor), + Side::Credit => cr += i128::from(fx.functional_minor), + } + } + assert_eq!(dr, cr, "functional column must balance (DR == CR)"); +} + +#[test] +fn asset_ar_rate_fall_books_unrealized_loss() { + // AR (debit-normal asset) carried 132.00 USD, period-end remeasure 126.00: + // the asset is worth LESS in functional → CR AR 6.00 (carrying down), + // DR FX_UNREALIZED 6.00 (unrealized loss). + let positions = [pos(Side::Debit, 13_200, 12_600)]; + let r = remeasure(&positions).unwrap(); + assert_eq!( + r.grain_lines, + vec![Some(RevaluationLine { + side: Side::Credit, + functional_minor: 600, + })] + ); + assert_eq!( + r.fx_unrealized, + Some(RevaluationLine { + side: Side::Debit, + functional_minor: 600, + }), + "net 6.00 USD unrealized LOSS on the DR side" + ); + assert_functional_balances(&r); +} + +#[test] +fn asset_ar_rate_rise_books_unrealized_gain() { + // AR carried 132.00, remeasure 138.00: asset worth MORE → DR AR 6.00, + // CR FX_UNREALIZED 6.00 (unrealized gain). + let positions = [pos(Side::Debit, 13_200, 13_800)]; + let r = remeasure(&positions).unwrap(); + assert_eq!( + r.grain_lines, + vec![Some(RevaluationLine { + side: Side::Debit, + functional_minor: 600, + })] + ); + assert_eq!( + r.fx_unrealized, + Some(RevaluationLine { + side: Side::Credit, + functional_minor: 600, + }), + "net 6.00 USD unrealized GAIN on the CR side" + ); + assert_functional_balances(&r); +} + +#[test] +fn liability_unallocated_rate_rise_books_unrealized_loss() { + // UNALLOCATED (credit-normal liability) carried 129.60, remeasure 135.00: + // the liability owed back is worth MORE in functional → CR UNALLOCATED 5.40 + // (carrying up), DR FX_UNREALIZED 5.40 (unrealized loss). + let positions = [pos(Side::Credit, 12_960, 13_500)]; + let r = remeasure(&positions).unwrap(); + assert_eq!( + r.grain_lines, + vec![Some(RevaluationLine { + side: Side::Credit, + functional_minor: 540, + })] + ); + assert_eq!( + r.fx_unrealized, + Some(RevaluationLine { + side: Side::Debit, + functional_minor: 540, + }), + "net 5.40 USD unrealized LOSS (liability grew)" + ); + assert_functional_balances(&r); +} + +#[test] +fn liability_reusable_credit_rate_fall_books_unrealized_gain() { + // REUSABLE_CREDIT (credit-normal liability) carried 200.00, remeasure 190.00: + // the wallet held is worth LESS in functional → DR REUSABLE_CREDIT 10.00 + // (carrying down), CR FX_UNREALIZED 10.00 (unrealized gain). + let positions = [pos(Side::Credit, 20_000, 19_000)]; + let r = remeasure(&positions).unwrap(); + assert_eq!( + r.grain_lines, + vec![Some(RevaluationLine { + side: Side::Debit, + functional_minor: 1_000, + })] + ); + assert_eq!( + r.fx_unrealized, + Some(RevaluationLine { + side: Side::Credit, + functional_minor: 1_000, + }), + "net 10.00 USD unrealized GAIN (liability shrank)" + ); + assert_functional_balances(&r); +} + +#[test] +fn no_movement_emits_no_line() { + // Carried == remeasured (closed at the carried rate): no leg, nothing to post. + let positions = [pos(Side::Debit, 13_200, 13_200)]; + let r = remeasure(&positions).unwrap(); + assert_eq!(r.grain_lines, vec![None]); + assert_eq!(r.fx_unrealized, None); + assert!(r.is_empty()); + assert_functional_balances(&r); +} + +#[test] +fn multi_grain_same_scope_nets_to_one_fx_line() { + // Scope = AR (two invoices, both debit-normal), opposite rate moves: + // inv1 carried 100.00 → 90.00 : CR AR 10.00 (down) + // inv2 carried 200.00 → 230.00 : DR AR 30.00 (up) + // Net Δ = +30 − 10 = +20 (DR side wins) → CR FX_UNREALIZED 20.00. + let positions = [ + pos(Side::Debit, 10_000, 9_000), + pos(Side::Debit, 20_000, 23_000), + ]; + let r = remeasure(&positions).unwrap(); + assert_eq!( + r.grain_lines, + vec![ + Some(RevaluationLine { + side: Side::Credit, + functional_minor: 1_000, + }), + Some(RevaluationLine { + side: Side::Debit, + functional_minor: 3_000, + }), + ] + ); + assert_eq!( + r.fx_unrealized, + Some(RevaluationLine { + side: Side::Credit, + functional_minor: 2_000, + }), + "net 20.00 USD unrealized GAIN" + ); + assert_functional_balances(&r); +} + +#[test] +fn mixed_grains_that_cancel_emit_no_fx_line() { + // Two AR grains whose moves cancel exactly: +5.00 and −5.00 → net 0, but the + // per-grain legs still post (carrying values do move) and the entry still + // balances without an FX line. + let positions = [ + pos(Side::Debit, 10_000, 10_500), + pos(Side::Debit, 8_000, 7_500), + ]; + let r = remeasure(&positions).unwrap(); + assert_eq!( + r.grain_lines, + vec![ + Some(RevaluationLine { + side: Side::Debit, + functional_minor: 500, + }), + Some(RevaluationLine { + side: Side::Credit, + functional_minor: 500, + }), + ] + ); + assert_eq!(r.fx_unrealized, None, "legs cancel → no FX line"); + assert!( + r.is_empty(), + "is_empty keys off the FX line (nothing net to post)" + ); + assert_functional_balances(&r); +} + +#[test] +fn empty_positions_yield_empty_result() { + let r = remeasure(&[]).unwrap(); + assert!(r.grain_lines.is_empty()); + assert_eq!(r.fx_unrealized, None); + assert!(r.is_empty()); +} + +#[test] +fn negative_carried_is_rejected() { + let positions = [pos(Side::Debit, -1, 100)]; + assert_eq!( + remeasure(&positions), + Err(RevaluationError::NegativeCarriedFunctional) + ); +} + +#[test] +fn negative_remeasured_is_rejected() { + let positions = [pos(Side::Debit, 100, -1)]; + assert_eq!( + remeasure(&positions), + Err(RevaluationError::NegativeRemeasured) + ); +} + +#[test] +fn scope_normal_side_and_token() { + assert_eq!(RevaluationScope::Ar.normal_side(), Side::Debit); + assert_eq!(RevaluationScope::Unallocated.normal_side(), Side::Credit); + assert_eq!(RevaluationScope::ReusableCredit.normal_side(), Side::Credit); + assert_eq!(RevaluationScope::Ar.as_token(), "AR"); + assert_eq!(RevaluationScope::Unallocated.as_token(), "UNALLOCATED"); + assert_eq!( + RevaluationScope::ReusableCredit.as_token(), + "REUSABLE_CREDIT" + ); + assert_eq!( + RevaluationScope::all(), + [ + RevaluationScope::Ar, + RevaluationScope::Unallocated, + RevaluationScope::ReusableCredit + ] + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/fx/translate.rs b/gears/bss/ledger/ledger/src/domain/fx/translate.rs new file mode 100644 index 000000000..997356fdf --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/fx/translate.rs @@ -0,0 +1,105 @@ +//! Pure FX translation (design §4.2): convert a balanced set of transaction +//! amounts to the functional currency at a single locked rate (banker's +//! rounding), then close the per-entry functional rounding **residual** +//! deterministically onto an anchor line so the functional column balances by +//! construction. Per-line `amount × rate` rounding can leave a residual +//! (`|residual| ≤ lines − 1` minor units) even when the transaction column is +//! exact; a single deterministic plug onto a real anchor line (e.g. the AR leg, +//! whose functional dwarfs the residual) closes it. No infra; the `RateLocker` +//! feeds it the locked rate and picks the anchor. + +use bss_ledger_sdk::Side; +use toolkit_macros::domain_model; + +use crate::domain::money_math::{checked_minor, round_half_even}; + +/// Micro scale: `rate_micro` is the functional-per-unit-transaction rate × 1e6. +const MICRO: i128 = 1_000_000; + +/// A line to translate: its transaction amount (minor units) and DR/CR side. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FxLine { + pub amount_minor: i64, + pub side: Side, +} + +/// A functional-translation failure. +#[domain_model] +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum FxTranslateError { + #[error("FX rate must be positive (rate_micro > 0)")] + RateNonPositive, + #[error("anchor line index is out of bounds")] + AnchorOutOfBounds, + #[error("functional residual would drive the anchor line non-positive")] + ResidualExceedsAnchor, + #[error("translated functional amount out of range: {0}")] + Overflow(i128), +} + +/// Translate a single transaction amount to functional at `rate_micro`, banker's +/// rounding (`amount × rate_micro / 1e6`, ties to even). Public so the +/// unrealized-revaluation run (Group H2) can remeasure one grain's transaction +/// balance at the period-end rate without going through [`translate_entry`] +/// (which closes a multi-line residual it does not need). +/// +/// # Errors +/// [`FxTranslateError::Overflow`] if the translated amount exceeds `i64`. +pub fn translate_amount(amount_minor: i64, rate_micro: i64) -> Result { + let raw = round_half_even(i128::from(amount_minor) * i128::from(rate_micro), MICRO); + checked_minor(raw).map_err(|_| FxTranslateError::Overflow(raw)) +} + +/// Translate every line at `rate_micro` and close the per-entry functional +/// residual onto `anchor` (the index of a real line whose functional absorbs the +/// small residual), so `SUM(DR.functional) == SUM(CR.functional)` exactly. Returns +/// the functional amount per line, in the input order, each `> 0`. +/// +/// # Errors +/// - [`FxTranslateError::RateNonPositive`] if `rate_micro <= 0`. +/// - [`FxTranslateError::AnchorOutOfBounds`] if `anchor >= lines.len()`. +/// - [`FxTranslateError::ResidualExceedsAnchor`] if the residual would drive the +/// anchor's functional `<= 0` (a misuse — the anchor must be a substantial line). +/// - [`FxTranslateError::Overflow`] if a translated amount exceeds `i64`. +pub fn translate_entry( + lines: &[FxLine], + rate_micro: i64, + anchor: usize, +) -> Result, FxTranslateError> { + if rate_micro <= 0 { + return Err(FxTranslateError::RateNonPositive); + } + if anchor >= lines.len() { + return Err(FxTranslateError::AnchorOutOfBounds); + } + let mut func: Vec = Vec::with_capacity(lines.len()); + for l in lines { + func.push(translate_amount(l.amount_minor, rate_micro)?); + } + // net = SUM(DR.functional) − SUM(CR.functional); the rounding residual. + let net: i128 = lines + .iter() + .zip(&func) + .map(|(l, &f)| match l.side { + Side::Debit => i128::from(f), + Side::Credit => -i128::from(f), + }) + .sum(); + // Close the residual onto the anchor so the new net is exactly zero: + // a DR anchor moves net by +Δ (want Δ = −net); a CR anchor by −Δ (want Δ = net). + let adj = match lines[anchor].side { + Side::Debit => -net, + Side::Credit => net, + }; + let new_anchor = i128::from(func[anchor]) + adj; + if new_anchor <= 0 { + return Err(FxTranslateError::ResidualExceedsAnchor); + } + func[anchor] = checked_minor(new_anchor).map_err(|_| FxTranslateError::Overflow(new_anchor))?; + Ok(func) +} + +#[cfg(test)] +#[path = "translate_tests.rs"] +mod translate_tests; diff --git a/gears/bss/ledger/ledger/src/domain/fx/translate_tests.rs b/gears/bss/ledger/ledger/src/domain/fx/translate_tests.rs new file mode 100644 index 000000000..91ebf2ba1 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/fx/translate_tests.rs @@ -0,0 +1,100 @@ +//! Tests for the pure FX translation + residual plug. + +use super::*; + +fn dr(amount: i64) -> FxLine { + FxLine { + amount_minor: amount, + side: Side::Debit, + } +} + +fn cr(amount: i64) -> FxLine { + FxLine { + amount_minor: amount, + side: Side::Credit, + } +} + +/// Asserts the functional column nets to zero (DR == CR) for `lines`/`func`. +fn func_net(lines: &[FxLine], func: &[i64]) -> i128 { + lines + .iter() + .zip(func) + .map(|(l, &f)| match l.side { + Side::Debit => i128::from(f), + Side::Credit => -i128::from(f), + }) + .sum() +} + +#[test] +fn identity_rate_mirrors_transaction() { + // rate 1.0 → functional == transaction, no residual. + let lines = [dr(1000), cr(1000)]; + let func = translate_entry(&lines, 1_000_000, 0).unwrap(); + assert_eq!(func, vec![1000, 1000]); + assert_eq!(func_net(&lines, &func), 0); +} + +#[test] +fn scaled_rate_balances_both_columns() { + // rate 1.1 → DR 1100 / CR 1100, balances. + let lines = [dr(1000), cr(1000)]; + let func = translate_entry(&lines, 1_100_000, 0).unwrap(); + assert_eq!(func, vec![1100, 1100]); + assert_eq!(func_net(&lines, &func), 0); +} + +#[test] +fn rounding_residual_is_plugged_onto_the_anchor() { + // rate 1.5: DR 1 → 1.5 → 2 (ties to even), DR 1 → 2, CR 2 → 3.0 → 3. + // Functional DR 4 vs CR 3 — a residual of 1; the CR anchor (idx 2) absorbs it + // → 4, so both columns net to zero. The transaction column is exact (DR 2 = CR 2). + let lines = [dr(1), dr(1), cr(2)]; + let func = translate_entry(&lines, 1_500_000, 2).unwrap(); + assert_eq!(func, vec![2, 2, 4]); + assert_eq!( + func_net(&lines, &func), + 0, + "functional column must net to zero" + ); +} + +#[test] +fn residual_plug_is_deterministic() { + // Same inputs → byte-identical output (no Date/random; banker's rounding). + let lines = [dr(1), dr(1), cr(2)]; + let a = translate_entry(&lines, 1_500_000, 2).unwrap(); + let b = translate_entry(&lines, 1_500_000, 2).unwrap(); + assert_eq!(a, b); +} + +#[test] +fn dr_anchor_absorbs_residual() { + // Mirror of the CR-anchor case with the residual pushed onto a DR anchor. + // rate 1.5: CR 1 → 2, CR 1 → 2, DR 2 → 3. Functional DR 3 vs CR 4 → net −1; + // the DR anchor (idx 2) absorbs +1 → 4, both columns net to zero. + let lines = [cr(1), cr(1), dr(2)]; + let func = translate_entry(&lines, 1_500_000, 2).unwrap(); + assert_eq!(func, vec![2, 2, 4]); + assert_eq!(func_net(&lines, &func), 0); +} + +#[test] +fn non_positive_rate_is_rejected() { + let lines = [dr(1000), cr(1000)]; + assert_eq!( + translate_entry(&lines, 0, 0), + Err(FxTranslateError::RateNonPositive) + ); +} + +#[test] +fn anchor_out_of_bounds_is_rejected() { + let lines = [dr(1000), cr(1000)]; + assert_eq!( + translate_entry(&lines, 1_000_000, 2), + Err(FxTranslateError::AnchorOutOfBounds) + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/invoice.rs b/gears/bss/ledger/ledger/src/domain/invoice.rs new file mode 100644 index 000000000..07648ca7f --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice.rs @@ -0,0 +1,16 @@ +//! Invoice-post domain (architecture §5). Pure, backend-agnostic logic for the +//! first business posting: the balanced direct-split line builder (Variant A — +//! no Contract-liability line), account-mapping with route-to-suspense, the +//! line-negation reversal + `MAPPING_CORRECTION` flow, and AR-aging buckets. +//! +//! Every module here is pure (no infra / DB imports — dylint DE0301): it +//! computes over SDK value types and produces SDK `PostEntry`/`PostLine`. The +//! orchestrator that resolves chart account ids and drives the foundation engine +//! lives in `crate::infra::invoice_post` (an infra service, like +//! `period_close`), because it needs repo + posting access. + +pub mod aging; +pub mod builder; +pub mod mapping; +pub mod policy; +pub mod reversal; diff --git a/gears/bss/ledger/ledger/src/domain/invoice/aging.rs b/gears/bss/ledger/ledger/src/domain/invoice/aging.rs new file mode 100644 index 000000000..137209f41 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/aging.rs @@ -0,0 +1,115 @@ +//! AR-aging bucket derivation (architecture §5.5). Folds the open per-invoice +//! AR balances into days-past-due buckets per `(payer, currency)`. +//! +//! Days past due = `today − due_date`; an invoice with no `due_date` is treated +//! as not-yet-due (`current`). The bucket boundaries are the tenant's configured +//! [`AgingThresholds`] (VHP-1853); the default `[30, 60, 90]` reproduces the +//! classic `current`, `1-30`, `31-60`, `61-90`, `90+`. Only rows with +//! `balance_minor > 0` age — a settled (`0`) or credit (`< 0`) row carries +//! nothing to chase. Pure `i64` summation; no rounding. + +use std::collections::BTreeMap; + +use bss_ledger_sdk::ArInvoiceBalanceView; +use chrono::NaiveDate; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::invoice::policy::AgingThresholds; + +/// `current` bucket label — not yet due (≤ 0 days past due) or no due date. The +/// one fixed label; the past-due labels are derived from the tenant thresholds. +pub const BUCKET_CURRENT: &str = "current"; + +/// One aged grain: the outstanding AR for a `(payer, currency, bucket)`. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgingBucket { + /// Payer whose receivable this is. + pub payer_tenant_id: Uuid, + /// Currency of the grain. + pub currency: String, + /// The bucket label, derived from the tenant's [`AgingThresholds`]: + /// [`BUCKET_CURRENT`], then `"{lo}-{hi}"` per boundary, then `"{last}+"` + /// (e.g. with `[30,60,90]`: `current` / `1-30` / `31-60` / `61-90` / `90+`). + pub bucket: String, + /// Summed outstanding minor units in this bucket (always `> 0` — empty + /// grains are omitted). + pub amount_minor: i64, +} + +/// Bucket the open AR-invoice balances `rows` as of `today` under `thresholds`, +/// grouped per `(payer, currency)`. Rows with `balance_minor <= 0` are skipped. +/// The result is ordered by `(payer, currency, bucket-age)` for stable output. +#[must_use] +pub fn ar_aging( + rows: &[ArInvoiceBalanceView], + today: NaiveDate, + thresholds: &AgingThresholds, +) -> Vec { + let bounds = thresholds.bounds(); + let labels = bucket_labels(bounds); + // (payer, currency, bucket-rank) -> summed outstanding (i128 while folding); + // the numeric rank sorts buckets oldest-first within a payer/currency. + let mut acc: BTreeMap<(Uuid, String, usize), i128> = BTreeMap::new(); + for row in rows { + if row.balance_minor <= 0 { + continue; + } + let rank = bucket_rank(days_past_due(row.due_date, today), bounds); + *acc.entry((row.payer_tenant_id, row.currency.clone(), rank)) + .or_insert(0) += i128::from(row.balance_minor); + } + acc.into_iter() + .map(|((payer_tenant_id, currency, rank), amount)| AgingBucket { + payer_tenant_id, + currency, + bucket: labels[rank].clone(), + amount_minor: i64::try_from(amount).unwrap_or(i64::MAX), + }) + .collect() +} + +/// Days past due: `today − due_date`, or `0` (not yet due) when there is no due +/// date. A future due date yields a negative count (→ `current`). +fn days_past_due(due_date: Option, today: NaiveDate) -> i64 { + match due_date { + Some(due) => (today - due).num_days(), + None => 0, + } +} + +/// Map a days-past-due count to its bucket rank: `0` (current) for `≤ 0`, then +/// `i + 1` for the first boundary with `days <= bounds[i]`, else the open-ended +/// overflow rank `bounds.len() + 1`. +fn bucket_rank(days: i64, bounds: &[i64]) -> usize { + if days <= 0 { + return 0; + } + for (i, &b) in bounds.iter().enumerate() { + if days <= b { + return i + 1; + } + } + bounds.len() + 1 +} + +/// Derive the labels for `bounds` (strictly increasing, all `> 0`, non-empty): +/// `["current", "1-{b0}", "{b0+1}-{b1}", …, "{last}+"]` — length `bounds.len() + +/// 2`, indexed by [`bucket_rank`]. +fn bucket_labels(bounds: &[i64]) -> Vec { + let mut labels = Vec::with_capacity(bounds.len() + 2); + labels.push(BUCKET_CURRENT.to_owned()); + let mut lower = 1_i64; + for &b in bounds { + labels.push(format!("{lower}-{b}")); + lower = b + 1; + } + // The open-ended last bucket, labelled by the final boundary (e.g. "90+"). + labels.push(format!("{}+", bounds.last().copied().unwrap_or(0))); + labels +} + +#[cfg(test)] +#[path = "aging_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/invoice/aging_tests.rs b/gears/bss/ledger/ledger/src/domain/invoice/aging_tests.rs new file mode 100644 index 000000000..139ab4b9f --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/aging_tests.rs @@ -0,0 +1,170 @@ +//! Tests for the AR-aging bucket derivation ([`super::ar_aging`]). + +use super::*; +use crate::domain::invoice::policy::AgingThresholds; + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +fn row(payer: Uuid, currency: &str, balance: i64, due: Option) -> ArInvoiceBalanceView { + ArInvoiceBalanceView { + payer_tenant_id: payer, + account_id: Uuid::now_v7(), + invoice_id: format!("INV-{balance}"), + currency: currency.to_owned(), + balance_minor: balance, + due_date: due, + } +} + +/// Find the bucket label for a single-row aging over `due` as of `today`, under +/// the default thresholds (`[30, 60, 90]`). +fn bucket_for(due: Option, today: NaiveDate) -> Option { + let payer = Uuid::now_v7(); + let out = ar_aging( + &[row(payer, "USD", 1000, due)], + today, + &AgingThresholds::default(), + ); + out.first().map(|b| b.bucket.clone()) +} + +#[test] +fn bucket_boundaries_are_inclusive_at_the_documented_days() { + let today = naive(2026, 6, 30); + // 0 days past due (due == today) ⇒ current. + assert_eq!( + bucket_for(Some(today), today).as_deref(), + Some(BUCKET_CURRENT) + ); + // Future due date ⇒ current. + assert_eq!( + bucket_for(Some(naive(2026, 7, 15)), today).as_deref(), + Some(BUCKET_CURRENT) + ); + // No due date ⇒ current. + assert_eq!(bucket_for(None, today).as_deref(), Some(BUCKET_CURRENT)); + // 1 day past due ⇒ 1-30; 30 days ⇒ 1-30. + assert_eq!( + bucket_for(Some(naive(2026, 6, 29)), today).as_deref(), + Some("1-30") + ); + assert_eq!( + bucket_for(Some(naive(2026, 5, 31)), today).as_deref(), + Some("1-30") + ); + // 31 days ⇒ 31-60; 60 days ⇒ 31-60. + assert_eq!( + bucket_for(Some(naive(2026, 5, 30)), today).as_deref(), + Some("31-60") + ); + assert_eq!( + bucket_for(Some(naive(2026, 5, 1)), today).as_deref(), + Some("31-60") + ); + // 61 days ⇒ 61-90; 90 days ⇒ 61-90. + assert_eq!( + bucket_for(Some(naive(2026, 4, 30)), today).as_deref(), + Some("61-90") + ); + assert_eq!( + bucket_for(Some(naive(2026, 4, 1)), today).as_deref(), + Some("61-90") + ); + // 91 days ⇒ 90+. + assert_eq!( + bucket_for(Some(naive(2026, 3, 31)), today).as_deref(), + Some("90+") + ); +} + +#[test] +fn buckets_separate_per_payer_and_currency() { + let today = naive(2026, 6, 30); + let payer_a = Uuid::now_v7(); + let payer_b = Uuid::now_v7(); + let rows = vec![ + // A: two USD invoices in the same bucket (sum), one EUR in another. + row(payer_a, "USD", 1000, Some(naive(2026, 6, 29))), // 1 day → 1-30 + row(payer_a, "USD", 500, Some(naive(2026, 6, 20))), // 10 days → 1-30 + row(payer_a, "EUR", 700, Some(naive(2026, 3, 1))), // 90+ + // B: one USD in current. + row(payer_b, "USD", 250, None), + ]; + let out = ar_aging(&rows, today, &AgingThresholds::default()); + + // A/USD/1-30 sums the two same-bucket invoices. + let a_usd_1_30 = out + .iter() + .find(|b| b.payer_tenant_id == payer_a && b.currency == "USD" && b.bucket == "1-30") + .expect("A USD 1-30 bucket present"); + assert_eq!( + a_usd_1_30.amount_minor, 1500, + "same payer+currency+bucket sums" + ); + + // A/EUR is a separate currency grain. + assert!( + out.iter() + .any(|b| b.payer_tenant_id == payer_a && b.currency == "EUR" && b.bucket == "90+"), + "EUR ages independently of USD" + ); + + // B/USD/current is a separate payer grain. + assert!( + out.iter().any(|b| b.payer_tenant_id == payer_b + && b.currency == "USD" + && b.bucket == BUCKET_CURRENT), + "payer B is a separate grain" + ); +} + +#[test] +fn zero_and_negative_balances_are_excluded() { + let today = naive(2026, 6, 30); + let payer = Uuid::now_v7(); + let rows = vec![ + row(payer, "USD", 0, Some(naive(2026, 5, 1))), // settled + row(payer, "USD", -300, Some(naive(2026, 5, 1))), // credit + row(payer, "USD", 800, Some(naive(2026, 5, 1))), // open 60-day + ]; + let out = ar_aging(&rows, today, &AgingThresholds::default()); + assert_eq!(out.len(), 1, "only the positive-balance row ages"); + assert_eq!(out[0].amount_minor, 800); + assert_eq!(out[0].bucket, "31-60"); +} + +#[test] +fn empty_input_yields_no_buckets() { + assert!(ar_aging(&[], naive(2026, 6, 30), &AgingThresholds::default()).is_empty()); +} + +/// VHP-1853: custom tenant thresholds reshape the buckets AND their labels. +#[test] +fn custom_thresholds_reshape_buckets_and_labels() { + let today = naive(2026, 6, 30); + let payer = Uuid::now_v7(); + let thresholds = AgingThresholds::new(vec![15, 45]).expect("valid thresholds"); + let rows = vec![ + row(payer, "USD", 100, Some(naive(2026, 6, 20))), // 10 days → 1-15 + row(payer, "USD", 200, Some(naive(2026, 6, 10))), // 20 days → 16-45 + row(payer, "USD", 300, Some(naive(2026, 5, 1))), // 60 days → 45+ + ]; + let out = ar_aging(&rows, today, &thresholds); + assert!( + out.iter() + .any(|b| b.bucket == "1-15" && b.amount_minor == 100), + "10 days falls in the 1-15 bucket" + ); + assert!( + out.iter() + .any(|b| b.bucket == "16-45" && b.amount_minor == 200), + "20 days falls in the 16-45 bucket" + ); + assert!( + out.iter() + .any(|b| b.bucket == "45+" && b.amount_minor == 300), + "60 days falls in the open-ended 45+ bucket" + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/invoice/builder.rs b/gears/bss/ledger/ledger/src/domain/invoice/builder.rs new file mode 100644 index 000000000..ea5f7444c --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/builder.rs @@ -0,0 +1,485 @@ +//! Direct-split invoice-entry builder (architecture §5.1, **Variant A** + +//! Slice 4 deferral split). Turns an invoice into a balanced [`PostEntry`]: +//! +//! - **DR AR** — one line for the gross receivable (`Σ items ex-tax + Σ tax`). +//! - **CR Revenue** — one line per `revenue_stream` (grouped sum of the +//! *recognized-now* portion of the ex-tax item amounts in that stream — +//! `amount − deferred`), carrying the resolved [`MappedLine`] class/status. +//! - **CR Contract-liability** — one line per `revenue_stream` whose items +//! defer a non-zero amount (the grouped `Σ deferred` in that stream), same +//! `revenue_stream` as the Revenue line (per-stream disaggregation, §3.5). +//! - **CR Tax** — one line per [`TaxBreakdown`], carrying its tax dims. +//! +//! **The deferral split (Slice 4, design §3.1 / Variant A) is driven entirely by +//! [`InvoiceItem::deferred_minor`]** — the per-item deferred amount the +//! recognition derivation ([`crate::domain::recognition`]) computes *before* the +//! builder and threads in on each item. `deferred_minor == 0` for **every** item +//! (the default, and the only case before Slice 4) emits NO Contract-liability +//! line and is **byte-identical** to the prior Variant-A output — the public +//! invoice-post contract is unchanged for non-deferred invoices. +//! +//! Money is pure `i64` summation: `Σ DR == Σ CR` exactly, no proportional split +//! and no residual rounding (the segment residual is the recognition builder's +//! concern; here `deferred` is already an exact per-item i64). Scale is NOT set +//! here — the foundation `CurrencyScaleResolver` fills each line's +//! `currency_scale` at post time, so every amount stays in the invoice's own +//! minor units. The emitted lines carry a placeholder nil `account_id`; the +//! posting glue binds the real chart row from +//! `(account_class, currency, revenue_stream)` before posting. + +use std::collections::BTreeMap; + +use bss_ledger_sdk::{AccountClass, MappingStatus, PostEntry, PostLine, Side, SourceDocType}; +use chrono::NaiveDate; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::invoice::mapping::MappedLine; + +/// One billable line of an invoice, ex-tax. Carries the revenue dimensions the +/// ledger posts on (`revenue_stream`, the optional Catalog/Contract mapping +/// inputs, and the source refs threaded onto the journal line for audit). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +// `invoice_item_ref` / `sku_or_plan_ref` etc. mirror the `journal_line` / `PostLine` +// column names verbatim; renaming to satisfy `struct_field_names` would diverge +// from the storage + SDK contract. +#[allow(clippy::struct_field_names)] +pub struct InvoiceItem { + /// Ex-tax amount in the invoice's minor units. Must be `>= 0`. + pub amount_minor_ex_tax: i64, + /// The portion of [`Self::amount_minor_ex_tax`] deferred to + /// `CONTRACT_LIABILITY` (Slice 4). The recognition derivation + /// ([`crate::domain::recognition::builder::ScheduleBuilder`]) computes this + /// *before* the builder and threads it onto the item; the builder credits + /// `amount − deferred` to Revenue and `deferred` to Contract-liability on the + /// SAME `revenue_stream`. `0` (the default, and absence-of-recognition) ⇒ the + /// whole amount recognizes now and NO Contract-liability line is emitted + /// (byte-identical to the pre-Slice-4 Variant-A output). Invariant: + /// `0 <= deferred_minor <= amount_minor_ex_tax`. + pub deferred_minor: i64, + /// ISO currency of the item (every item + tax shares the invoice currency). + pub currency: String, + /// Revenue stream this item books to — the grouping key for the CR Revenue + /// lines, and (with the class) the chart-resolution key. + pub revenue_stream: String, + /// Catalog-supplied GL class (the default mapping). `None` ⇒ no Catalog + /// mapping for this item. + pub catalog_class: Option, + /// Contract-supplied GL class override. Wins over [`Self::catalog_class`] + /// when present. + pub contract_class: Option, + /// Catalog GL code carried onto the posted line (audit / downstream GL). + pub gl_code: Option, + /// The optional per-item ASC 606 recognition spec (Slice 4). `None` ⇒ the + /// item is fully recognized now (`deferred_minor` stays `0`, today's + /// Variant-A behaviour). When present, the orchestrator + /// ([`crate::infra::invoice_post`]) derives [`Self::deferred_minor`] + the + /// schedule plan from it via the recognition + /// [`ScheduleBuilder`](crate::domain::recognition::builder::ScheduleBuilder) + /// *before* the builder runs. Carried on the domain item (not consumed by the + /// pure builder, which reads only the already-derived `deferred_minor`) so + /// the orchestrator has the per-item context the derivation needs. + pub recognition: Option, + /// Source-document refs threaded onto the journal line for lineage. + pub invoice_item_ref: Option, + pub sku_or_plan_ref: Option, + pub price_id: Option, + pub pricing_snapshot_ref: Option, +} + +/// One tax component of an invoice, already computed by the tax engine. Each +/// breakdown posts as its own CR Tax line carrying the filing dimensions. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaxBreakdown { + /// Tax amount in the invoice's minor units. Must be `>= 0`. + pub amount_minor: i64, + /// ISO currency (matches the invoice currency). + pub currency: String, + /// Filing jurisdiction (e.g. `"US-CA"`) — a `TAX_PAYABLE` sub-balance dim. + pub tax_jurisdiction: String, + /// Filing period (e.g. `"2026Q2"`) — the second `TAX_PAYABLE` sub-balance dim. + pub tax_filing_period: String, + /// Reference to the applied tax rate (audit), if any. + pub tax_rate_ref: Option, +} + +/// A fully-recognized invoice to post (Variant A input). The whole amount is +/// recognized now — no deferral schedule. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PostedInvoice { + /// External invoice identity — the `INVOICE_POST` idempotency business id and + /// the `invoice_id` dim on the AR line. + pub invoice_id: String, + /// The tenant that pays this invoice (the single payer of the entry). + pub payer_tenant_id: Uuid, + /// The tenant whose resources were consumed, if distinct from the payer + /// (threaded onto each line for cost attribution); `None` ⇒ payer == resource. + pub resource_tenant_id: Option, + /// The seller tenant whose ledger this posts into (`= entry.tenant_id`). + pub seller_tenant_id: Uuid, + /// GL effective date of the entry. + pub effective_at: NaiveDate, + /// AR due date stamped on the AR line (drives AR-aging); `None` ⇒ due on + /// posting. + pub due_date: Option, + /// The fiscal `period_id` (`YYYYMM`) the entry posts into. + pub period_id: String, + /// Ex-tax billable lines. + pub items: Vec, + /// Tax components (may be empty ⇒ no CR Tax line). + pub tax: Vec, + /// Actor recorded as the poster (audit who). + pub posted_by_actor_id: Uuid, + /// Correlation id propagated onto the entry. + pub correlation_id: Uuid, +} + +impl PostedInvoice { + /// Entry currency = the invoice currency. Taken from the first item, else + /// the first tax breakdown; `None` for a degenerate empty invoice (rejected + /// downstream by the foundation empty-entry invariant). + #[must_use] + pub fn currency(&self) -> Option<&str> { + self.items + .first() + .map(|i| i.currency.as_str()) + .or_else(|| self.tax.first().map(|t| t.currency.as_str())) + } + + /// Gross receivable in minor units: `Σ items ex-tax + Σ tax`. Pure `i64` + /// summation (widened to `i128` while folding to avoid an intermediate + /// overflow), the exact total the single DR AR line carries. + #[must_use] + pub fn gross_minor(&self) -> i64 { + let items: i128 = self + .items + .iter() + .map(|i| i128::from(i.amount_minor_ex_tax)) + .sum(); + let tax: i128 = self.tax.iter().map(|t| i128::from(t.amount_minor)).sum(); + // The foundation headroom guard keeps a single invoice within i64; a + // pathological overflow saturates rather than panicking (the unbalanced + // / amount guards then reject the entry). + i64::try_from(items + tax).unwrap_or(i64::MAX) + } +} + +/// Build the balanced direct-split entry for `inv`, using `mapped[i]` as the +/// resolved GL target of `inv.items[i]` (positional; lengths must match). +/// +/// Lines: one DR AR (gross), one CR Revenue per distinct +/// `(account_class, gl_code, mapping_status, revenue_stream)` group (summing the +/// *recognized-now* `amount − deferred` of each item), one CR Contract-liability +/// per `revenue_stream` whose items defer a non-zero amount (summing the per-item +/// `deferred_minor`), one CR Tax per [`TaxBreakdown`]. The AR carries +/// `invoice_id` + `due_date`; every Revenue / Contract-liability line carries its +/// `revenue_stream`; every Tax line carries its dims. `source_doc_type = +/// INVOICE_POST`, `source_business_id = invoice_id`, `reverses_* = None`. +/// +/// **Deferral (Slice 4):** each item's [`InvoiceItem::deferred_minor`] (computed +/// upstream by the recognition derivation) splits its stream's credit into +/// Revenue (`amount − deferred`) + Contract-liability (`deferred`), same stream +/// on both. When every item defers `0` (the default) NO Contract-liability line +/// is emitted and the output is byte-identical to the pre-Slice-4 Variant-A +/// entry. `Σ DR == Σ CR` stays exact (`i64`): the split only re-labels part of an +/// already-balanced credit. +/// +/// # Panics +/// Debug-asserts `mapped.len() == inv.items.len()`; in release a length +/// mismatch silently maps only the overlapping prefix (the glue always passes a +/// 1:1 vector). +#[must_use] +pub fn build_invoice_entry(inv: &PostedInvoice, mapped: &[MappedLine]) -> PostEntry { + debug_assert_eq!( + mapped.len(), + inv.items.len(), + "one MappedLine per invoice item" + ); + let entry_id = Uuid::now_v7(); + let currency = inv.currency().unwrap_or_default().to_owned(); + + // Worst case: 1 AR + one Revenue + one Contract-liability per item + one Tax + // per breakdown. + let mut lines: Vec = Vec::with_capacity(1 + 2 * inv.items.len() + inv.tax.len()); + + // DR AR — the gross receivable (incl. tax). Single payer per entry. + lines.push(PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: inv.payer_tenant_id, + seller_tenant_id: Some(inv.seller_tenant_id), + resource_tenant_id: inv.resource_tenant_id, + account_id: Uuid::nil(), + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: inv.gross_minor(), + currency: currency.clone(), + invoice_id: Some(inv.invoice_id.clone()), + due_date: inv.due_date, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }); + + // CR Revenue — grouped by (class, gl_code, status, stream) so a SUSPENSE / + // PENDING item never merges into a resolved revenue stream. Each item + // contributes its *recognized-now* amount (`amount − deferred`); the deferred + // remainder is folded into the per-stream Contract-liability map below. A + // BTreeMap keys the groups deterministically (stable line order across + // recomputes — the same financial intent always builds identically). + let mut revenue: BTreeMap = BTreeMap::new(); + // CR Contract-liability — the deferred portion, grouped by `revenue_stream` + // only (the class is fixed `CONTRACT_LIABILITY`; the chart resolves it per + // stream). Empty when every item defers `0`, so NO Contract-liability line is + // emitted and the entry is byte-identical to the pre-Slice-4 output. + let mut deferred: BTreeMap = BTreeMap::new(); + for (item, m) in inv.items.iter().zip(mapped.iter()) { + // Clamp defensively: the recognition derivation guarantees + // `0 <= deferred <= amount`, but a malformed input must never produce a + // negative recognized-now credit (which would unbalance the entry) — the + // orchestrator validates the invariant before calling, and the + // foundation unbalanced guard is the backstop. + let deferred_minor = item.deferred_minor.clamp(0, item.amount_minor_ex_tax); + let recognized_now = item.amount_minor_ex_tax - deferred_minor; + + // Key on the stored string forms (the SDK enums are not `Ord`, and a + // BTreeMap key must be — the strings give a deterministic, stable line + // order). The typed class/status are carried on the agg for emit. + let key = RevenueKey { + account_class: m.account_class.as_str().to_owned(), + gl_code: m.gl_code.clone().unwrap_or_default(), + mapping_status: m.mapping_status.as_str().to_owned(), + revenue_stream: item.revenue_stream.clone(), + }; + let agg = revenue.entry(key).or_insert_with(|| RevenueAgg { + amount_minor: 0, + account_class: m.account_class, + gl_code: m.gl_code.clone(), + mapping_status: m.mapping_status, + // First item in the group seeds the line-level source refs. + refs: ItemRefs::from(item), + }); + agg.amount_minor += i128::from(recognized_now); + + // Fold the deferred remainder into its stream's Contract-liability line. + if deferred_minor > 0 { + let cl = deferred + .entry(item.revenue_stream.clone()) + .or_insert_with(|| DeferredAgg { + amount_minor: 0, + // FORWARD-DEPENDENCY: the per-stream merge + // seeds refs from the FIRST deferring item, but `derive_recognition` + // mints one schedule PER item. With ≥2 deferring items in one + // revenue_stream, the second item's schedule (its own + // `source_invoice_item_ref`) matches no journal line — only the + // first item's ref lands on this merged CONTRACT_LIABILITY line. + // AUDIT-ONLY today: nothing dereferences `source_invoice_item_ref` + // at runtime (the runner posts recognition with + // `invoice_item_ref: None`; the tie-out joins by `entry_id`), and + // the amounts reconcile (per-stream sum). This ARMS in Slice 7 if + // reconciliation starts joining schedules → CL lines by item-ref — + // fix then (per-item CL refs, or a schedule↔CL map). A + // multi-item-per-stream test is the pending coverage. + refs: ItemRefs::from(item), + }); + cl.amount_minor += i128::from(deferred_minor); + } + } + for (key, agg) in revenue { + // A stream whose entire recognized-now amount deferred (Slice 4) sums to + // 0 — emit NO Revenue line: the engine rejects a zero-amount line, and the + // deferred amount is carried by the CONTRACT_LIABILITY line below. (CL is + // already only emitted for `deferred > 0`, so a fully-deferred item yields + // a lone CONTRACT_LIABILITY credit, balanced against the AR/tax debit.) + if agg.amount_minor == 0 { + continue; + } + lines.push(PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: inv.payer_tenant_id, + seller_tenant_id: Some(inv.seller_tenant_id), + resource_tenant_id: inv.resource_tenant_id, + account_id: Uuid::nil(), + account_class: agg.account_class, + gl_code: agg.gl_code, + side: Side::Credit, + amount_minor: i64::try_from(agg.amount_minor).unwrap_or(i64::MAX), + currency: currency.clone(), + invoice_id: Some(inv.invoice_id.clone()), + due_date: None, + // Every Revenue line carries its stream (the DB CHECK requires it). + revenue_stream: Some(key.revenue_stream), + mapping_status: agg.mapping_status, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: agg.refs.invoice_item_ref, + sku_or_plan_ref: agg.refs.sku_or_plan_ref, + price_id: agg.refs.price_id, + pricing_snapshot_ref: agg.refs.pricing_snapshot_ref, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }); + } + + // CR Contract-liability — one line per stream with a deferred amount (Slice + // 4). Emitted AFTER the Revenue lines (stable, deterministic order); the + // schedule materialization is the orchestrator's sidecar, not the builder's. + // The class is fixed `CONTRACT_LIABILITY` and resolved per stream by the + // chart. `mapping_status` is `Resolved`: a deferral books to a real + // Contract-liability obligation (the recognition derivation only defers a + // genuine obligation line), independent of the revenue side's mapping — + // unlike the Revenue/SUSPENSE split, a deferred liability is never PENDING. + for (revenue_stream, agg) in deferred { + lines.push(PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: inv.payer_tenant_id, + seller_tenant_id: Some(inv.seller_tenant_id), + resource_tenant_id: inv.resource_tenant_id, + account_id: Uuid::nil(), + account_class: AccountClass::ContractLiability, + gl_code: None, + side: Side::Credit, + amount_minor: i64::try_from(agg.amount_minor).unwrap_or(i64::MAX), + currency: currency.clone(), + invoice_id: Some(inv.invoice_id.clone()), + due_date: None, + revenue_stream: Some(revenue_stream), + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: agg.refs.invoice_item_ref, + sku_or_plan_ref: agg.refs.sku_or_plan_ref, + price_id: agg.refs.price_id, + pricing_snapshot_ref: agg.refs.pricing_snapshot_ref, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }); + } + + // CR Tax — one line per breakdown, carrying the filing dims. + for t in &inv.tax { + lines.push(PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: inv.payer_tenant_id, + seller_tenant_id: Some(inv.seller_tenant_id), + resource_tenant_id: inv.resource_tenant_id, + account_id: Uuid::nil(), + account_class: AccountClass::TaxPayable, + gl_code: None, + side: Side::Credit, + amount_minor: t.amount_minor, + currency: currency.clone(), + invoice_id: Some(inv.invoice_id.clone()), + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: Some(t.tax_jurisdiction.clone()), + tax_filing_period: Some(t.tax_filing_period.clone()), + tax_rate_ref: t.tax_rate_ref.clone(), + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }); + } + + PostEntry { + entry_id, + tenant_id: inv.seller_tenant_id, + period_id: inv.period_id.clone(), + entry_currency: currency, + source_doc_type: SourceDocType::InvoicePost, + source_business_id: inv.invoice_id.clone(), + effective_at: inv.effective_at, + posted_by_actor_id: inv.posted_by_actor_id, + correlation_id: inv.correlation_id, + reverses_entry_id: None, + reverses_period_id: None, + lines, + } +} + +/// Grouping key for the CR Revenue lines — the stored *string* forms of the +/// dims (the SDK enums are not `Ord`, but a `BTreeMap` key must be). Ordering is +/// derived so the built line order is deterministic across recomputes. +#[domain_model] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +struct RevenueKey { + account_class: String, + gl_code: String, + mapping_status: String, + revenue_stream: String, +} + +/// Running fold of one revenue group: summed ex-tax amount (`i128` to avoid an +/// intermediate overflow), the typed dims to emit on the line, and the first +/// item's source refs. +#[domain_model] +struct RevenueAgg { + amount_minor: i128, + account_class: AccountClass, + gl_code: Option, + mapping_status: MappingStatus, + refs: ItemRefs, +} + +/// Running fold of one stream's deferred (Contract-liability) credit: the summed +/// deferred amount (`i128` to avoid an intermediate overflow) and the first +/// deferring item's source refs. The class is fixed (`CONTRACT_LIABILITY`) and +/// the stream is the map key, so neither is stored here. +#[domain_model] +struct DeferredAgg { + amount_minor: i128, + refs: ItemRefs, +} + +/// The per-line source refs carried from the (first) item of a revenue group. +#[domain_model] +struct ItemRefs { + invoice_item_ref: Option, + sku_or_plan_ref: Option, + price_id: Option, + pricing_snapshot_ref: Option, +} + +impl From<&InvoiceItem> for ItemRefs { + fn from(item: &InvoiceItem) -> Self { + Self { + invoice_item_ref: item.invoice_item_ref.clone(), + sku_or_plan_ref: item.sku_or_plan_ref.clone(), + price_id: item.price_id.clone(), + pricing_snapshot_ref: item.pricing_snapshot_ref.clone(), + } + } +} + +#[cfg(test)] +#[path = "builder_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/invoice/builder_tests.rs b/gears/bss/ledger/ledger/src/domain/invoice/builder_tests.rs new file mode 100644 index 000000000..2ec2419cf --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/builder_tests.rs @@ -0,0 +1,464 @@ +//! Tests for the direct-split invoice-entry builder ([`super::build_invoice_entry`]). +//! +//! Variant A: the only lines are DR AR / CR Revenue (per stream) / CR Tax (per +//! breakdown) — NEVER a Contract-liability line. Money is pure `i64` summation: +//! `Σ DR == Σ CR` exactly. + +use super::*; +use crate::domain::invoice::mapping::resolve; + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// An ex-tax item in `stream`, mapped to `REVENUE` via the Catalog class. +fn revenue_item(amount: i64, stream: &str) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: stream.to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn tax(amount: i64, juris: &str, filing: &str) -> TaxBreakdown { + TaxBreakdown { + amount_minor: amount, + currency: "USD".to_owned(), + tax_jurisdiction: juris.to_owned(), + tax_filing_period: filing.to_owned(), + tax_rate_ref: None, + } +} + +/// An invoice over `items` + `tax`, with fixed identity fields. +fn invoice(items: Vec, tax: Vec) -> PostedInvoice { + PostedInvoice { + invoice_id: "INV-1".to_owned(), + payer_tenant_id: Uuid::now_v7(), + resource_tenant_id: None, + seller_tenant_id: Uuid::now_v7(), + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: "202606".to_owned(), + items, + tax, + posted_by_actor_id: Uuid::now_v7(), + correlation_id: Uuid::now_v7(), + } +} + +/// Build the entry for `inv`, mapping each item through the real resolver. +fn build(inv: &PostedInvoice) -> PostEntry { + let mapped: Vec<_> = inv.items.iter().map(resolve).collect(); + build_invoice_entry(inv, &mapped) +} + +/// `Σ DR == Σ CR` (the balance invariant), computed over the built lines. +fn nets_to_zero(entry: &PostEntry) -> bool { + let net: i128 = entry + .lines + .iter() + .map(|l| match l.side { + Side::Debit => i128::from(l.amount_minor), + Side::Credit => -i128::from(l.amount_minor), + }) + .sum(); + net == 0 +} + +fn line_of(entry: &PostEntry, class: AccountClass, side: Side) -> Vec<&PostLine> { + entry + .lines + .iter() + .filter(|l| l.account_class == class && l.side == side) + .collect() +} + +#[test] +fn one_item_one_tax_builds_three_balanced_lines() { + let inv = invoice( + vec![revenue_item(1000, "subscription")], + vec![tax(200, "US-CA", "2026Q2")], + ); + let entry = build(&inv); + + assert_eq!(entry.lines.len(), 3, "DR AR + CR Revenue + CR Tax"); + assert_eq!(entry.source_doc_type, SourceDocType::InvoicePost); + assert_eq!(entry.source_business_id, "INV-1"); + assert!( + entry.reverses_entry_id.is_none(), + "an invoice-post reverses nothing" + ); + assert!(nets_to_zero(&entry), "Σ DR must equal Σ CR exactly"); + + // DR AR = gross = item + tax = 1200, carries invoice_id + due_date. + let ar = line_of(&entry, AccountClass::Ar, Side::Debit); + assert_eq!(ar.len(), 1); + assert_eq!(ar[0].amount_minor, 1200, "AR gross = 1000 + 200"); + assert_eq!(ar[0].invoice_id.as_deref(), Some("INV-1")); + assert_eq!(ar[0].due_date, Some(naive(2026, 7, 1))); + assert!( + ar[0].revenue_stream.is_none(), + "the AR line carries no revenue_stream" + ); + + // CR Revenue = 1000, stream set. + let rev = line_of(&entry, AccountClass::Revenue, Side::Credit); + assert_eq!(rev.len(), 1); + assert_eq!(rev[0].amount_minor, 1000, "Revenue = ex-tax item"); + assert_eq!( + rev[0].revenue_stream.as_deref(), + Some("subscription"), + "every Revenue line must carry its stream" + ); + + // CR Tax = 200, dims set. + let tax_lines = line_of(&entry, AccountClass::TaxPayable, Side::Credit); + assert_eq!(tax_lines.len(), 1); + assert_eq!(tax_lines[0].amount_minor, 200); + assert_eq!(tax_lines[0].tax_jurisdiction.as_deref(), Some("US-CA")); + assert_eq!(tax_lines[0].tax_filing_period.as_deref(), Some("2026Q2")); +} + +#[test] +fn two_items_different_streams_group_into_two_revenue_lines() { + let inv = invoice( + vec![ + revenue_item(1000, "subscription"), + revenue_item(500, "usage"), + ], + vec![], + ); + let entry = build(&inv); + + // No tax ⇒ 1 AR + 2 Revenue = 3 lines. + assert_eq!(entry.lines.len(), 3); + let rev = line_of(&entry, AccountClass::Revenue, Side::Credit); + assert_eq!(rev.len(), 2, "one Revenue line per distinct stream"); + let mut by_stream: Vec<(String, i64)> = rev + .iter() + .map(|l| (l.revenue_stream.clone().unwrap(), l.amount_minor)) + .collect(); + by_stream.sort(); + assert_eq!( + by_stream, + vec![("subscription".to_owned(), 1000), ("usage".to_owned(), 500)] + ); + + // AR = 1500 (no tax), balanced. + let ar = line_of(&entry, AccountClass::Ar, Side::Debit); + assert_eq!(ar[0].amount_minor, 1500); + assert!(nets_to_zero(&entry)); +} + +#[test] +fn two_items_same_stream_sum_into_one_revenue_line() { + let inv = invoice( + vec![ + revenue_item(1000, "subscription"), + revenue_item(250, "subscription"), + ], + vec![], + ); + let entry = build(&inv); + + let rev = line_of(&entry, AccountClass::Revenue, Side::Credit); + assert_eq!(rev.len(), 1, "same stream ⇒ one grouped Revenue line"); + assert_eq!(rev[0].amount_minor, 1250, "grouped sum of the stream"); + assert!(nets_to_zero(&entry)); +} + +#[test] +fn zero_tax_omits_the_tax_line() { + let inv = invoice(vec![revenue_item(1000, "subscription")], vec![]); + let entry = build(&inv); + + assert_eq!(entry.lines.len(), 2, "no tax ⇒ DR AR + CR Revenue only"); + assert!( + line_of(&entry, AccountClass::TaxPayable, Side::Credit).is_empty(), + "no Tax line when there is no tax" + ); + let ar = line_of(&entry, AccountClass::Ar, Side::Debit); + assert_eq!(ar[0].amount_minor, 1000, "AR = item only (no tax)"); + assert!(nets_to_zero(&entry)); +} + +#[test] +fn variant_a_never_posts_a_contract_liability_line() { + // The defining Variant-A invariant: the whole amount is recognized now, so + // there is NO deferred / Contract-liability leg, regardless of item count + // or tax. + let inv = invoice( + vec![ + revenue_item(1000, "subscription"), + revenue_item(500, "usage"), + ], + vec![tax(150, "US-CA", "2026Q2")], + ); + let entry = build(&inv); + + assert!( + entry + .lines + .iter() + .all(|l| l.account_class != AccountClass::ContractLiability), + "Variant A must NEVER post a Contract-liability line" + ); + assert!(nets_to_zero(&entry)); +} + +#[test] +fn multiple_tax_breakdowns_each_post_their_own_line() { + let inv = invoice( + vec![revenue_item(1000, "subscription")], + vec![tax(120, "US-CA", "2026Q2"), tax(80, "US-NY", "2026Q2")], + ); + let entry = build(&inv); + + let tax_lines = line_of(&entry, AccountClass::TaxPayable, Side::Credit); + assert_eq!(tax_lines.len(), 2, "one CR Tax line per breakdown"); + // AR gross = 1000 + 120 + 80 = 1200. + let ar = line_of(&entry, AccountClass::Ar, Side::Debit); + assert_eq!(ar[0].amount_minor, 1200); + assert!(nets_to_zero(&entry)); +} + +#[test] +fn unmapped_item_routes_to_a_suspense_credit_line() { + // An item with no Catalog/Contract class maps to SUSPENSE/PENDING; the CR + // leg books to SUSPENSE (not a guessed revenue account) and the entry still + // balances against the AR debit. + let mut unmapped = revenue_item(1000, "subscription"); + unmapped.catalog_class = None; + unmapped.contract_class = None; + let inv = invoice(vec![unmapped], vec![]); + let entry = build(&inv); + + let suspense = line_of(&entry, AccountClass::Suspense, Side::Credit); + assert_eq!(suspense.len(), 1, "the unmapped CR leg routes to SUSPENSE"); + assert_eq!(suspense[0].mapping_status, MappingStatus::Pending); + assert!( + line_of(&entry, AccountClass::Revenue, Side::Credit).is_empty(), + "an unmapped item must NOT silently book to Revenue" + ); + assert!(nets_to_zero(&entry)); +} + +#[test] +fn entry_currency_follows_the_invoice() { + let inv = invoice(vec![revenue_item(1000, "subscription")], vec![]); + let entry = build(&inv); + assert_eq!(entry.entry_currency, "USD"); + assert!(entry.lines.iter().all(|l| l.currency == "USD")); +} + +// ── Slice 4: the deferred split (CR REVENUE + CR CONTRACT_LIABILITY) ────────── + +/// A revenue item that defers `deferred` of its `amount` to Contract-liability. +fn deferred_item(amount: i64, deferred: i64, stream: &str) -> InvoiceItem { + let mut item = revenue_item(amount, stream); + item.deferred_minor = deferred; + item +} + +#[test] +fn deferred_item_splits_credit_into_revenue_and_contract_liability() { + // 1200 ex-tax, 900 deferred ⇒ CR Revenue 300 + CR Contract-liability 900, + // same stream on both; DR AR still the full 1200 (no tax). Σ DR == Σ CR. + let inv = invoice(vec![deferred_item(1200, 900, "subscription")], vec![]); + let entry = build(&inv); + + let ar = line_of(&entry, AccountClass::Ar, Side::Debit); + assert_eq!(ar[0].amount_minor, 1200, "AR is the full ex-tax amount"); + + let rev = line_of(&entry, AccountClass::Revenue, Side::Credit); + assert_eq!( + rev.len(), + 1, + "one Revenue line for the recognized-now portion" + ); + assert_eq!(rev[0].amount_minor, 300, "Revenue = amount − deferred"); + assert_eq!(rev[0].revenue_stream.as_deref(), Some("subscription")); + + let cl = line_of(&entry, AccountClass::ContractLiability, Side::Credit); + assert_eq!( + cl.len(), + 1, + "one Contract-liability line for the deferred portion" + ); + assert_eq!(cl[0].amount_minor, 900, "Contract-liability = deferred"); + assert_eq!( + cl[0].revenue_stream.as_deref(), + Some("subscription"), + "the Contract-liability line carries the SAME stream as Revenue" + ); + + assert!(nets_to_zero(&entry), "the split keeps Σ DR == Σ CR exact"); +} + +#[test] +fn fully_deferred_item_emits_no_revenue_line() { + // 1000 ex-tax, all deferred ⇒ recognized-now 0. NO Revenue line is emitted + // (the engine rejects a zero-amount line); the Contract-liability carries the + // whole credit, balanced against the AR/tax debit. + let inv = invoice(vec![deferred_item(1000, 1000, "subscription")], vec![]); + let entry = build(&inv); + + let cl = line_of(&entry, AccountClass::ContractLiability, Side::Credit); + assert_eq!(cl[0].amount_minor, 1000, "the whole amount defers"); + let rev = line_of(&entry, AccountClass::Revenue, Side::Credit); + assert!( + rev.is_empty(), + "a fully-deferred stream emits no Revenue line" + ); + assert!(nets_to_zero(&entry)); +} + +#[test] +fn per_stream_deferral_groups_one_contract_liability_line_per_stream() { + // Two streams, each partly deferred ⇒ one CR Revenue + one CR + // Contract-liability PER stream (per-stream disaggregation, §3.5). + let inv = invoice( + vec![ + deferred_item(1000, 600, "subscription"), + deferred_item(500, 200, "usage"), + ], + vec![], + ); + let entry = build(&inv); + + let cl = line_of(&entry, AccountClass::ContractLiability, Side::Credit); + assert_eq!( + cl.len(), + 2, + "one Contract-liability line per deferring stream" + ); + let mut by_stream: Vec<(String, i64)> = cl + .iter() + .map(|l| (l.revenue_stream.clone().unwrap(), l.amount_minor)) + .collect(); + by_stream.sort(); + assert_eq!( + by_stream, + vec![("subscription".to_owned(), 600), ("usage".to_owned(), 200)] + ); + // Recognized-now Revenue: 400 + 300. + let rev = line_of(&entry, AccountClass::Revenue, Side::Credit); + let rev_total: i64 = rev.iter().map(|l| l.amount_minor).sum(); + assert_eq!(rev_total, 700, "Σ recognized-now = (1000−600)+(500−200)"); + assert!(nets_to_zero(&entry)); +} + +#[test] +fn two_deferring_items_one_stream_merge_into_one_cl_line() { + // Z4-1: TWO deferring items in the SAME revenue_stream + // merge into ONE Contract-liability line whose amount is the Σ of both deferred + // portions. The merged line seeds its source refs from the FIRST deferring item + // — recognition mints one schedule PER item, so the second item's schedule + // `source_invoice_item_ref` resolves to no journal line. AUDIT-ONLY today + // (nothing dereferences it at recognition runtime; the runner posts + // `invoice_item_ref: None` and tie-out joins by `entry_id`). This test locks the + // current per-stream merge amount + balance; revisit the per-item linkage if + // Slice-7 reconciliation starts joining schedule→CL by item-ref. + let inv = invoice( + vec![ + deferred_item(1000, 600, "subscription"), + deferred_item(500, 200, "subscription"), + ], + vec![], + ); + let entry = build(&inv); + + let cl = line_of(&entry, AccountClass::ContractLiability, Side::Credit); + assert_eq!( + cl.len(), + 1, + "two deferring items in one stream → one merged Contract-liability line" + ); + assert_eq!( + cl[0].amount_minor, 800, + "merged Contract-liability = 600 + 200 (Σ deferred for the stream)" + ); + assert_eq!(cl[0].revenue_stream.as_deref(), Some("subscription")); + + let rev = line_of(&entry, AccountClass::Revenue, Side::Credit); + assert_eq!( + rev.len(), + 1, + "one grouped Revenue line for the merged recognized-now amount" + ); + assert_eq!( + rev[0].amount_minor, 700, + "recognized-now = (1000−600) + (500−200)" + ); + + assert!( + nets_to_zero(&entry), + "the per-stream merge keeps Σ DR == Σ CR exact" + ); +} + +#[test] +fn mixed_deferred_and_undeferred_same_stream_sum_correctly() { + // Two items in one stream: one defers, one does not. Revenue = + // recognized-now sum; Contract-liability = the single deferred amount. + let inv = invoice( + vec![ + deferred_item(1000, 600, "subscription"), + revenue_item(400, "subscription"), + ], + vec![], + ); + let entry = build(&inv); + + let rev = line_of(&entry, AccountClass::Revenue, Side::Credit); + assert_eq!(rev.len(), 1, "same stream ⇒ one grouped Revenue line"); + assert_eq!(rev[0].amount_minor, 800, "(1000−600) + 400 recognized now"); + let cl = line_of(&entry, AccountClass::ContractLiability, Side::Credit); + assert_eq!(cl[0].amount_minor, 600, "only the deferred item's portion"); + assert!(nets_to_zero(&entry)); +} + +#[test] +fn deferred_zero_is_byte_identical_to_no_deferral() { + // The byte-identical guarantee: an item with `deferred_minor = 0` builds the + // EXACT same lines as one whose field is left at the default — no + // Contract-liability line, same Revenue/AR/Tax. We compare against the + // unchanged `revenue_item` helper (deferred defaults to 0). + let with_zero = invoice( + vec![{ + let mut i = revenue_item(1000, "subscription"); + i.deferred_minor = 0; + i + }], + vec![tax(200, "US-CA", "2026Q2")], + ); + let entry = build(&with_zero); + + assert!( + entry + .lines + .iter() + .all(|l| l.account_class != AccountClass::ContractLiability), + "deferred = 0 must emit NO Contract-liability line" + ); + assert_eq!( + entry.lines.len(), + 3, + "DR AR + CR Revenue + CR Tax, as before" + ); + let rev = line_of(&entry, AccountClass::Revenue, Side::Credit); + assert_eq!(rev[0].amount_minor, 1000, "the whole amount recognizes now"); + let ar = line_of(&entry, AccountClass::Ar, Side::Debit); + assert_eq!(ar[0].amount_minor, 1200); + assert!(nets_to_zero(&entry)); +} diff --git a/gears/bss/ledger/ledger/src/domain/invoice/mapping.rs b/gears/bss/ledger/ledger/src/domain/invoice/mapping.rs new file mode 100644 index 000000000..07f3d0cd6 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/mapping.rs @@ -0,0 +1,57 @@ +//! Account-mapping resolver (architecture §5.2). Resolves the GL target of one +//! invoice item to a `(account_class, gl_code, mapping_status)` triple, in a +//! fixed precedence: a Catalog-supplied class, overridden by a Contract-supplied +//! class when present; on a miss the line routes to `SUSPENSE` with status +//! `PENDING` (never a silent wrong-revenue mapping — an unmapped item must be +//! visibly parked, not booked to an arbitrary revenue account). +//! +//! Pure: a `match` on the two optional inputs carried by the item. No chart / +//! DB access here — the real `account_id` for the resolved class+stream+currency +//! is bound later by the posting glue from the provisioned chart of accounts. + +use bss_ledger_sdk::{AccountClass, MappingStatus}; +use toolkit_macros::domain_model; + +use crate::domain::invoice::builder::InvoiceItem; + +/// The resolved GL target of one invoice item. Carries no `account_id`: the +/// concrete chart row is resolved by the posting glue from +/// `(account_class, currency, revenue_stream)` against the provisioned chart. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MappedLine { + /// The resolved revenue (or `SUSPENSE`) account class. + pub account_class: AccountClass, + /// The Catalog GL code carried through to the posted line, if any. + pub gl_code: Option, + /// `RESOLVED` when a class was found; `PENDING` when the item routed to + /// `SUSPENSE` (an operator must reclassify before the period can close). + pub mapping_status: MappingStatus, +} + +/// Resolve one item's GL target. +/// +/// Precedence: `contract_class` (Contract override) wins when present, else +/// `catalog_class` (Catalog default). On a miss (neither present) the line +/// routes to [`AccountClass::Suspense`] with [`MappingStatus::Pending`]. +#[must_use] +pub fn resolve(item: &InvoiceItem) -> MappedLine { + // Contract override beats the Catalog default; either resolves RESOLVED. + match item.contract_class.or(item.catalog_class) { + Some(account_class) => MappedLine { + account_class, + gl_code: item.gl_code.clone(), + mapping_status: MappingStatus::Resolved, + }, + // Miss: park on SUSPENSE/PENDING rather than guess a revenue account. + None => MappedLine { + account_class: AccountClass::Suspense, + gl_code: item.gl_code.clone(), + mapping_status: MappingStatus::Pending, + }, + } +} + +#[cfg(test)] +#[path = "mapping_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/invoice/mapping_tests.rs b/gears/bss/ledger/ledger/src/domain/invoice/mapping_tests.rs new file mode 100644 index 000000000..98c24c1a8 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/mapping_tests.rs @@ -0,0 +1,63 @@ +//! Tests for the account-mapping resolver ([`super::resolve`]). + +use super::*; +use crate::domain::invoice::builder::InvoiceItem; + +/// An item with the two mapping inputs set; everything else fixed. +fn item(catalog: Option, contract: Option) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: 1000, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: catalog, + contract_class: contract, + gl_code: Some("4000".to_owned()), + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +#[test] +fn catalog_class_is_used_when_present() { + let m = resolve(&item(Some(AccountClass::Revenue), None)); + assert_eq!(m.account_class, AccountClass::Revenue); + assert_eq!(m.mapping_status, MappingStatus::Resolved); + assert_eq!(m.gl_code.as_deref(), Some("4000")); +} + +#[test] +fn contract_override_wins_over_catalog() { + // Both present: the Contract override beats the Catalog default. + let m = resolve(&item( + Some(AccountClass::Revenue), + Some(AccountClass::ContraRevenue), + )); + assert_eq!( + m.account_class, + AccountClass::ContraRevenue, + "the contract override must win over the catalog class" + ); + assert_eq!(m.mapping_status, MappingStatus::Resolved); +} + +#[test] +fn contract_class_is_used_when_only_it_is_present() { + let m = resolve(&item(None, Some(AccountClass::Revenue))); + assert_eq!(m.account_class, AccountClass::Revenue); + assert_eq!(m.mapping_status, MappingStatus::Resolved); +} + +#[test] +fn miss_routes_to_suspense_pending() { + // Neither mapping present: route to SUSPENSE/PENDING, NEVER a silent + // wrong-revenue mapping. + let m = resolve(&item(None, None)); + assert_eq!(m.account_class, AccountClass::Suspense); + assert_eq!(m.mapping_status, MappingStatus::Pending); + // The Catalog gl_code is still carried through for the operator's context. + assert_eq!(m.gl_code.as_deref(), Some("4000")); +} diff --git a/gears/bss/ledger/ledger/src/domain/invoice/policy.rs b/gears/bss/ledger/ledger/src/domain/invoice/policy.rs new file mode 100644 index 000000000..c4481b552 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/policy.rs @@ -0,0 +1,162 @@ +//! Tenant-configurable invoice-posting policies (VHP-1853, design 01a §4.2 / +//! §4.4): the missing-mapping mode and the AR-aging bucket thresholds. Pure +//! domain — the parsing, validation, and defaults; the effective row is loaded +//! by the repo (`infra`), and these value types are its shape. No infra / DB +//! imports (dylint DE0301). + +use toolkit_macros::domain_model; + +/// Policy parse / validation failure (a corrupt stored value, or a bad write). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PolicyError { + /// `missing_mapping_mode` was neither `SUSPENSE` nor `HARD_BLOCK`. + #[error("unknown missing-mapping mode: {0}")] + UnknownMissingMappingMode(String), + /// `ar_aging_thresholds` was empty, non-numeric, non-positive, non-increasing, + /// or exceeded the bucket cap. + #[error("invalid AR-aging thresholds: {0}")] + InvalidAgingThresholds(String), +} + +/// What to do with an invoice item whose GL target cannot be resolved. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum MissingMappingMode { + /// Route the unmapped item to `SUSPENSE` / `PENDING` (an operator reclassifies + /// before the period can close). The default + the prior hardcoded behaviour. + #[default] + Suspense, + /// Reject the whole post with `ACCOUNT_MAPPING_MISSING` — the tenant requires + /// every line to map up-front. + HardBlock, +} + +impl MissingMappingMode { + /// Stored / wire literal for [`Self::Suspense`]. + pub const SUSPENSE: &'static str = "SUSPENSE"; + /// Stored / wire literal for [`Self::HardBlock`]. + pub const HARD_BLOCK: &'static str = "HARD_BLOCK"; + + /// The stored / wire literal. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Suspense => Self::SUSPENSE, + Self::HardBlock => Self::HARD_BLOCK, + } + } + + /// Parse the stored / wire literal. + /// + /// # Errors + /// [`PolicyError::UnknownMissingMappingMode`] for any other string. + pub fn parse(s: &str) -> Result { + match s { + Self::SUSPENSE => Ok(Self::Suspense), + Self::HARD_BLOCK => Ok(Self::HardBlock), + other => Err(PolicyError::UnknownMissingMappingMode(other.to_owned())), + } + } +} + +/// Strict-increasing positive day-count upper bounds for the AR-aging buckets. +/// `[30, 60, 90]` (the default) yields `current / 1-30 / 31-60 / 61-90 / 90+`. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgingThresholds(Vec); + +impl Default for AgingThresholds { + fn default() -> Self { + Self(vec![30, 60, 90]) + } +} + +impl AgingThresholds { + /// The most bucket boundaries a tenant may configure — a sanity cap, well + /// above any realistic aging schedule. + pub const MAX_BOUNDS: usize = 12; + + /// Build from upper bounds, validating non-empty, all `> 0`, strictly + /// increasing, and within [`Self::MAX_BOUNDS`]. + /// + /// # Errors + /// [`PolicyError::InvalidAgingThresholds`] when any rule is violated. + pub fn new(bounds: Vec) -> Result { + if bounds.is_empty() { + return Err(PolicyError::InvalidAgingThresholds( + "must have at least one boundary".to_owned(), + )); + } + if bounds.len() > Self::MAX_BOUNDS { + return Err(PolicyError::InvalidAgingThresholds(format!( + "at most {} boundaries (got {})", + Self::MAX_BOUNDS, + bounds.len() + ))); + } + if bounds[0] <= 0 { + return Err(PolicyError::InvalidAgingThresholds( + "boundaries must be positive day counts".to_owned(), + )); + } + if bounds.windows(2).any(|w| w[1] <= w[0]) { + return Err(PolicyError::InvalidAgingThresholds( + "boundaries must be strictly increasing".to_owned(), + )); + } + Ok(Self(bounds)) + } + + /// Parse a CSV of bounds (e.g. `"30,60,90"`). + /// + /// # Errors + /// [`PolicyError::InvalidAgingThresholds`] on a non-numeric token or a failed + /// [`Self::new`] invariant. + pub fn parse_csv(s: &str) -> Result { + let bounds = s + .split(',') + .map(|t| { + t.trim().parse::().map_err(|e| { + PolicyError::InvalidAgingThresholds(format!( + "non-numeric boundary '{}': {e}", + t.trim() + )) + }) + }) + .collect::, _>>()?; + Self::new(bounds) + } + + /// Render to the stored CSV form (e.g. `"30,60,90"`). + #[must_use] + pub fn to_csv(&self) -> String { + self.0 + .iter() + .map(i64::to_string) + .collect::>() + .join(",") + } + + /// The upper bounds, strictly increasing. + #[must_use] + pub fn bounds(&self) -> &[i64] { + &self.0 + } +} + +/// The effective tenant invoice-posting policy. [`Default`] reproduces the prior +/// hardcoded behaviour (`Suspense` + `[30, 60, 90]`), applied when a tenant has +/// no policy row. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct PostingPolicy { + /// What to do with an unmapped invoice item. + pub missing_mapping_mode: MissingMappingMode, + /// AR-aging bucket boundaries. + pub aging_thresholds: AgingThresholds, +} + +#[cfg(test)] +#[path = "policy_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/invoice/policy_tests.rs b/gears/bss/ledger/ledger/src/domain/invoice/policy_tests.rs new file mode 100644 index 000000000..7b313bc95 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/policy_tests.rs @@ -0,0 +1,77 @@ +//! Tests for the tenant posting-policy value types ([`super`]). + +use super::*; + +#[test] +fn missing_mapping_mode_round_trips() { + assert_eq!( + MissingMappingMode::parse("SUSPENSE"), + Ok(MissingMappingMode::Suspense) + ); + assert_eq!( + MissingMappingMode::parse("HARD_BLOCK"), + Ok(MissingMappingMode::HardBlock) + ); + assert_eq!(MissingMappingMode::Suspense.as_str(), "SUSPENSE"); + assert_eq!(MissingMappingMode::HardBlock.as_str(), "HARD_BLOCK"); + assert!(MissingMappingMode::parse("nope").is_err()); + assert!( + MissingMappingMode::parse("suspense").is_err(), + "case-sensitive" + ); +} + +#[test] +fn missing_mapping_mode_default_is_suspense() { + assert_eq!(MissingMappingMode::default(), MissingMappingMode::Suspense); +} + +#[test] +fn aging_thresholds_default_is_the_classic_buckets() { + assert_eq!(AgingThresholds::default().bounds(), &[30, 60, 90]); +} + +#[test] +fn aging_thresholds_csv_round_trips_and_tolerates_whitespace() { + let t = AgingThresholds::parse_csv("30,60,90").expect("valid"); + assert_eq!(t.bounds(), &[30, 60, 90]); + assert_eq!(t.to_csv(), "30,60,90"); + assert_eq!( + AgingThresholds::parse_csv(" 15 , 45 ") + .expect("valid") + .bounds(), + &[15, 45] + ); +} + +#[test] +fn aging_thresholds_reject_invalid() { + assert!(AgingThresholds::new(vec![]).is_err(), "empty"); + assert!( + AgingThresholds::new(vec![0, 30]).is_err(), + "non-positive first" + ); + assert!(AgingThresholds::new(vec![-5]).is_err(), "negative"); + assert!( + AgingThresholds::new(vec![60, 30]).is_err(), + "non-increasing" + ); + assert!(AgingThresholds::new(vec![30, 30]).is_err(), "duplicate"); + assert!( + AgingThresholds::parse_csv("30,x,90").is_err(), + "non-numeric" + ); + let max_bounds = i64::try_from(AgingThresholds::MAX_BOUNDS).expect("MAX_BOUNDS fits i64"); + let over_cap: Vec = (1..=(max_bounds + 1)).collect(); + assert!( + AgingThresholds::new(over_cap).is_err(), + "over the bucket cap" + ); +} + +#[test] +fn posting_policy_default_is_suspense_and_classic_buckets() { + let p = PostingPolicy::default(); + assert_eq!(p.missing_mapping_mode, MissingMappingMode::Suspense); + assert_eq!(p.aging_thresholds.bounds(), &[30, 60, 90]); +} diff --git a/gears/bss/ledger/ledger/src/domain/invoice/reversal.rs b/gears/bss/ledger/ledger/src/domain/invoice/reversal.rs new file mode 100644 index 000000000..4edd4df91 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/reversal.rs @@ -0,0 +1,213 @@ +//! Strict line-negation reversal + the `MAPPING_CORRECTION` flow +//! (architecture §5.3–5.4). +//! +//! A reversal posts the **same accounts** as the original with each line's +//! **side flipped** and its **amount kept positive** (the foundation's amount +//! guard rejects negatives, so a reversal is a positive entry on the opposite +//! side, not a negative one). `source_doc_type = REVERSAL`, the `reverses_*` +//! header points back at the original, and `source_business_id = "reverses="` +//! keys the `REVERSAL` idempotency. A reversal of a reversal is rejected +//! ([`ReversalError::CannotReverseReversal`]) — you correct by re-posting, not +//! by stacking reversals. +//! +//! A `MAPPING_CORRECTION` is a reversal of the mis-mapped original immediately +//! followed by a corrected re-post; its idempotency business id is +//! `":"`, where `correction_id` is a stable hash of +//! `(original_entry_id, reversal_entry_id)` so the same correction always keys +//! identically (retries replay, never double-post). +//! +//! `correction_id` reuses the foundation's hashing primitive — the FIPS- +//! validated `aws-lc-rs` SHA-256 that already fingerprints the idempotency +//! payload — rather than adding a `sha2` dependency. + +use aws_lc_rs::digest::{SHA256, digest as sha256}; +use bss_ledger_sdk::{AccountClass, EntryView, PostEntry, PostLine, Side, SourceDocType}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +/// Why a reversal could not be built. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ReversalError { + /// The supplied original is itself a `REVERSAL` — reversing a reversal is + /// forbidden (correct forward by re-posting, never stack reversals). + #[error("cannot reverse an entry that is itself a reversal")] + CannotReverseReversal, + /// The original carries a `REUSABLE_CREDIT` line whose + /// `credit_grant_event_type` the read-back `LineView` does not expose, so a + /// faithful reversal cannot be reconstructed (it would violate the DB + /// `chk_journal_line_credit_grant`). Slice 1 never emits this class; the + /// payments slice that does will carry the field on `LineView`. Fail fast + /// here rather than abort opaquely at the DB CHECK. + #[error( + "cannot reverse an entry with a REUSABLE_CREDIT line (credit-grant dim not reconstructible)" + )] + CreditGrantNotReconstructible, +} + +/// The `source_business_id` of a reversal: `"reverses="`. +/// Keys the `(tenant, REVERSAL, business_id)` idempotency so a re-issued +/// reversal of the same entry replays. +#[must_use] +pub fn reversal_business_id(original_entry_id: Uuid) -> String { + format!("reverses={original_entry_id}") +} + +/// Build the reversing entry for `original`: same accounts, every line's side +/// flipped, amounts unchanged (positive), header pointing back at the original. +/// +/// The reversal posts into `into_period_id` (a reversal may land in a later, +/// still-OPEN period than the original) with `effective_at = effective_on`. +/// +/// # Errors +/// [`ReversalError::CannotReverseReversal`] when `original.source_doc_type` is +/// already [`SourceDocType::Reversal`]; [`ReversalError::CreditGrantNotReconstructible`] +/// when the original carries a `REUSABLE_CREDIT` line. +pub fn build_reversal( + original: &EntryView, + into_period_id: String, + effective_on: chrono::NaiveDate, + posted_by_actor_id: Uuid, + correlation_id: Uuid, +) -> Result { + if original.source_doc_type == SourceDocType::Reversal { + return Err(ReversalError::CannotReverseReversal); + } + // A `REUSABLE_CREDIT` line requires `credit_grant_event_type` + // (`chk_journal_line_credit_grant`), which the read-back `LineView` does not + // expose — `flip_line` cannot reconstruct it. Reject before posting rather + // than abort opaquely at the DB CHECK. (Slice 1 never emits this class.) + if original + .lines + .iter() + .any(|l| l.account_class == AccountClass::ReusableCredit) + { + return Err(ReversalError::CreditGrantNotReconstructible); + } + let entry_id = Uuid::now_v7(); + let lines = original.lines.iter().map(flip_line).collect(); + + Ok(PostEntry { + entry_id, + tenant_id: original.tenant_id, + period_id: into_period_id, + entry_currency: original.entry_currency.clone(), + source_doc_type: SourceDocType::Reversal, + source_business_id: reversal_business_id(original.entry_id), + effective_at: effective_on, + posted_by_actor_id, + correlation_id, + reverses_entry_id: Some(original.entry_id), + reverses_period_id: Some(original.period_id.clone()), + lines, + }) +} + +/// Build the corrected re-post half of a `MAPPING_CORRECTION`: the +/// caller-supplied `corrected_lines` (already re-mapped to the right accounts) +/// posted under `source_doc_type = MAPPING_CORRECTION`, keyed +/// `":"`, with the header still pointing back at the +/// original (the reversal cleared it; this re-books it correctly). +/// +/// `correction_id` is [`correction_id`]`(original.entry_id, reversal_entry_id)`. +#[must_use] +#[allow(clippy::too_many_arguments)] // each is a distinct, non-confusable field +pub fn build_mapping_correction( + original: &EntryView, + reversal_entry_id: Uuid, + invoice_id: &str, + into_period_id: String, + effective_on: chrono::NaiveDate, + posted_by_actor_id: Uuid, + correlation_id: Uuid, + corrected_lines: Vec, +) -> PostEntry { + let correction = correction_id(original.entry_id, reversal_entry_id); + PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: original.tenant_id, + period_id: into_period_id, + entry_currency: original.entry_currency.clone(), + source_doc_type: SourceDocType::MappingCorrection, + source_business_id: format!("{invoice_id}:{correction}"), + effective_at: effective_on, + posted_by_actor_id, + correlation_id, + // The correction re-books the original invoice; it points back at the + // entry it corrects (the reversal that cleared the bad mapping). + reverses_entry_id: Some(reversal_entry_id), + reverses_period_id: Some(original.period_id.clone()), + lines: corrected_lines, + } +} + +/// Stable correction id for a `(original, reversal)` entry pair: the hex SHA-256 +/// of `":"`. Deterministic (the same pair +/// always yields the same id — retries replay) and order-sensitive (swapping the +/// two ids yields a different id). Uses the foundation's FIPS SHA-256. +#[must_use] +pub fn correction_id(original_entry_id: Uuid, reversal_entry_id: Uuid) -> String { + let canon = format!("{original_entry_id}:{reversal_entry_id}"); + let digest = sha256(&SHA256, canon.as_bytes()); + let mut hex = String::with_capacity(64); + for byte in digest.as_ref() { + use std::fmt::Write as _; + let _ = write!(hex, "{byte:02x}"); + } + hex +} + +/// Negate one read-back line into its reversing [`PostLine`]: flip the side, +/// keep the (positive) amount, preserve the account + invoice/tax dims so the +/// reversal lands on exactly the grains the original moved. The read DTO does +/// not carry seller/resource/source-ref columns, so those are left `None` on the +/// reversal (it nets the original; lineage lives on the original line). +fn flip_line(line: &bss_ledger_sdk::LineView) -> PostLine { + PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: line.payer_tenant_id, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: line.account_id, + account_class: line.account_class, + gl_code: line.gl_code.clone(), + side: flip(line.side), + amount_minor: line.amount_minor, + currency: line.currency.clone(), + invoice_id: line.invoice_id.clone(), + due_date: line.due_date, + revenue_stream: line.revenue_stream.clone(), + mapping_status: line.mapping_status, + // Reverse at the ORIGINAL locked rate: copy the original line's functional + // translation (kept positive, like `amount_minor`); the flipped DR/CR side + // makes the functional delta net the original to zero. NO re-lock (spec + // §4.2 F-8c) — a reversal must not synthesize FX gain/loss. Cross-currency + // lines carry a functional value; single-currency lines carry `None`. + functional_amount_minor: line.functional_amount_minor, + functional_currency: line.functional_currency.clone(), + tax_jurisdiction: line.tax_jurisdiction.clone(), + tax_filing_period: line.tax_filing_period.clone(), + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + // Preserve the AR sub-class so a reversal nets the original disputed + // delta on the same `ar_invoice_balance` sub-grain (`disputed_minor`). + ar_status: line.ar_status.clone(), + } +} + +/// The opposite posting side. +const fn flip(side: Side) -> Side { + match side { + Side::Debit => Side::Credit, + Side::Credit => Side::Debit, + } +} + +#[cfg(test)] +#[path = "reversal_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/invoice/reversal_tests.rs b/gears/bss/ledger/ledger/src/domain/invoice/reversal_tests.rs new file mode 100644 index 000000000..2aed1cef2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/invoice/reversal_tests.rs @@ -0,0 +1,300 @@ +//! Tests for the reversal + `MAPPING_CORRECTION` flow. + +use bss_ledger_sdk::{AccountClass, LineView, MappingStatus}; +use chrono::{DateTime, NaiveDate, Utc}; + +use super::*; + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +fn now() -> DateTime { + Utc::now() +} + +fn line(account: Uuid, class: AccountClass, side: Side, amount: i64) -> LineView { + LineView { + line_id: Uuid::now_v7(), + entry_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some("INV-1".to_owned()), + due_date: Some(naive(2026, 7, 1)), + revenue_stream: if class == AccountClass::Revenue { + Some("subscription".to_owned()) + } else { + None + }, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + ar_status: None, + } +} + +/// Like [`line`] but cross-currency: carries a functional (EUR) translation, so a +/// reversal must copy it onto the flipped leg (the carry-forward fix). +fn fx_line( + account: Uuid, + class: AccountClass, + side: Side, + amount: i64, + functional: i64, +) -> LineView { + LineView { + functional_amount_minor: Some(functional), + functional_currency: Some("EUR".to_owned()), + ..line(account, class, side, amount) + } +} + +/// An original `INVOICE_POST` entry: DR AR 1200 / CR Revenue 1000 / CR Tax 200. +fn original_invoice() -> EntryView { + let ar = Uuid::now_v7(); + let rev = Uuid::now_v7(); + let tax = Uuid::now_v7(); + EntryView { + entry_id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + period_id: "202606".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "INV-1".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: now(), + effective_at: naive(2026, 6, 1), + posted_by_actor_id: Uuid::now_v7(), + origin: "SYSTEM".to_owned(), + correlation_id: Uuid::now_v7(), + created_seq: 1, + lines: vec![ + line(ar, AccountClass::Ar, Side::Debit, 1200), + line(rev, AccountClass::Revenue, Side::Credit, 1000), + line(tax, AccountClass::TaxPayable, Side::Credit, 200), + ], + } +} + +#[test] +fn reversal_flips_sides_keeps_amounts_positive_and_sets_reverses() { + let original = original_invoice(); + let actor = Uuid::now_v7(); + let corr = Uuid::now_v7(); + let reversal = build_reversal( + &original, + "202607".to_owned(), + naive(2026, 7, 2), + actor, + corr, + ) + .expect("reversal of an invoice-post must build"); + + assert_eq!(reversal.source_doc_type, SourceDocType::Reversal); + assert_eq!( + reversal.reverses_entry_id, + Some(original.entry_id), + "reverses_entry_id must point at the original" + ); + assert_eq!( + reversal.reverses_period_id.as_deref(), + Some("202606"), + "reverses_period_id must carry the original's period" + ); + assert_eq!( + reversal.period_id, "202607", + "the reversal posts into the supplied period" + ); + assert_eq!( + reversal.source_business_id, + format!("reverses={}", original.entry_id) + ); + + // Same accounts, flipped sides, positive amounts. + assert_eq!(reversal.lines.len(), original.lines.len()); + for (orig, rev) in original.lines.iter().zip(reversal.lines.iter()) { + assert_eq!(rev.account_id, orig.account_id, "same account"); + assert_eq!(rev.amount_minor, orig.amount_minor, "amount unchanged"); + assert!(rev.amount_minor > 0, "reversal amount stays positive"); + let flipped = match orig.side { + Side::Debit => Side::Credit, + Side::Credit => Side::Debit, + }; + assert_eq!(rev.side, flipped, "side flipped"); + } + + // The reversal nets to zero on its own (DR 1000 + DR 200 / CR 1200). + let net: i128 = reversal + .lines + .iter() + .map(|l| match l.side { + Side::Debit => i128::from(l.amount_minor), + Side::Credit => -i128::from(l.amount_minor), + }) + .sum(); + assert_eq!(net, 0, "the reversal is itself balanced"); +} + +#[test] +fn reversal_carries_functional_forward_and_nets_to_zero() { + // A cross-currency original (USD transaction, EUR functional at 0.9): DR AR + // 1200/1080 / CR Revenue 1000/900 / CR Tax 200/180. The reversal must copy each + // leg's functional (positive) onto the flipped side so the functional column + // nets to zero — the fix for the silent transaction-vs-functional drift on a + // cross-currency reversal (it must NOT post functional-NULL). + let ar = Uuid::now_v7(); + let rev_acct = Uuid::now_v7(); + let tax = Uuid::now_v7(); + let mut original = original_invoice(); + original.lines = vec![ + fx_line(ar, AccountClass::Ar, Side::Debit, 1200, 1080), + fx_line(rev_acct, AccountClass::Revenue, Side::Credit, 1000, 900), + fx_line(tax, AccountClass::TaxPayable, Side::Credit, 200, 180), + ]; + + let reversal = build_reversal( + &original, + "202607".to_owned(), + naive(2026, 7, 2), + Uuid::now_v7(), + Uuid::now_v7(), + ) + .expect("cross-currency reversal must build"); + + // Every leg carries the ORIGINAL functional (positive) + currency, side flipped. + for (orig, rev) in original.lines.iter().zip(reversal.lines.iter()) { + assert_eq!( + rev.functional_amount_minor, orig.functional_amount_minor, + "functional carried at the original rate (positive, unchanged)" + ); + assert_eq!( + rev.functional_currency.as_deref(), + Some("EUR"), + "functional currency carried" + ); + } + + // Functional column nets to zero (DR 900 + DR 180 / CR 1080) — no drift, no + // synthesized FX gain/loss. + let func_net: i128 = reversal + .lines + .iter() + .map(|l| { + let f = i128::from( + l.functional_amount_minor + .expect("cross-ccy leg carries functional"), + ); + match l.side { + Side::Debit => f, + Side::Credit => -f, + } + }) + .sum(); + assert_eq!(func_net, 0, "the reversal's functional column is balanced"); +} + +#[test] +fn reverse_of_a_reversal_is_rejected() { + let mut already_a_reversal = original_invoice(); + already_a_reversal.source_doc_type = SourceDocType::Reversal; + let err = build_reversal( + &already_a_reversal, + "202607".to_owned(), + naive(2026, 7, 2), + Uuid::now_v7(), + Uuid::now_v7(), + ) + .expect_err("reversing a reversal must be rejected"); + assert_eq!(err, ReversalError::CannotReverseReversal); +} + +#[test] +fn reverse_of_an_entry_with_a_reusable_credit_line_is_rejected() { + // The read-back `LineView` does not carry `credit_grant_event_type`, so a + // faithful reversal of a REUSABLE_CREDIT line cannot be reconstructed — the + // guard must fail fast rather than abort at the DB CHECK. + let mut with_credit = original_invoice(); + with_credit.lines.push(line( + Uuid::now_v7(), + AccountClass::ReusableCredit, + Side::Credit, + 500, + )); + let err = build_reversal( + &with_credit, + "202607".to_owned(), + naive(2026, 7, 2), + Uuid::now_v7(), + Uuid::now_v7(), + ) + .expect_err("reversing an entry with a REUSABLE_CREDIT line must be rejected"); + assert_eq!(err, ReversalError::CreditGrantNotReconstructible); +} + +#[test] +fn correction_id_is_deterministic_for_the_same_pair() { + let original = Uuid::now_v7(); + let reversal = Uuid::now_v7(); + assert_eq!( + correction_id(original, reversal), + correction_id(original, reversal), + "the same (original, reversal) pair must hash identically" + ); + // 64 hex chars (SHA-256). + assert_eq!(correction_id(original, reversal).len(), 64); +} + +#[test] +fn correction_id_differs_for_different_inputs() { + let a = Uuid::now_v7(); + let b = Uuid::now_v7(); + let c = Uuid::now_v7(); + assert_ne!( + correction_id(a, b), + correction_id(a, c), + "a different reversal id must yield a different correction id" + ); + // Order-sensitive: swapping the pair changes the id. + assert_ne!( + correction_id(a, b), + correction_id(b, a), + "correction_id must be order-sensitive" + ); +} + +#[test] +fn mapping_correction_keys_on_invoice_and_correction_id() { + let original = original_invoice(); + let reversal_entry_id = Uuid::now_v7(); + let correction = correction_id(original.entry_id, reversal_entry_id); + let corrected = build_mapping_correction( + &original, + reversal_entry_id, + "INV-1", + "202607".to_owned(), + naive(2026, 7, 2), + Uuid::now_v7(), + Uuid::now_v7(), + Vec::new(), + ); + assert_eq!(corrected.source_doc_type, SourceDocType::MappingCorrection); + assert_eq!( + corrected.source_business_id, + format!("INV-1:{correction}"), + "MAPPING_CORRECTION keys on invoice_id:correction_id" + ); + assert_eq!( + corrected.reverses_entry_id, + Some(reversal_entry_id), + "the correction points back at the reversal it follows" + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/model.rs b/gears/bss/ledger/ledger/src/domain/model.rs new file mode 100644 index 000000000..9db7d66c9 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/model.rs @@ -0,0 +1,243 @@ +//! Repo-facing domain types: posting inputs, read-back records, and the +//! repository error. `i64` minor units throughout (decision C); SDK enums +//! carry the typed literals, stored as their canonical strings. + +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, NaiveDate, Utc}; +use serde_json::Value as JsonValue; +use toolkit_macros::domain_model; +use uuid::Uuid; + +/// Identifying triple for a journal entry within a tenant period. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +#[allow(clippy::struct_field_names)] // all parts are *_id by nature of a key +pub struct EntryKey { + pub tenant_id: Uuid, + pub period_id: String, + pub entry_id: Uuid, +} + +/// Handle returned after a successful insert. `created_seq` is +/// DB-generated and read back from the inserted row. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EntryRef { + pub entry_id: Uuid, + pub created_seq: i64, +} + +/// A journal entry header to insert (truth-table input). Mirrors the +/// `journal_entry` columns minus DB-generated ones (`created_seq`, +/// `row_hash`, `prev_hash`). +#[domain_model] +#[derive(Clone, Debug)] +pub struct NewEntry { + pub entry_id: Uuid, + pub tenant_id: Uuid, + pub legal_entity_id: Uuid, + pub period_id: String, + pub entry_currency: String, + pub source_doc_type: SourceDocType, + pub source_business_id: String, + pub reverses_entry_id: Option, + pub reverses_period_id: Option, + pub posted_at_utc: DateTime, + pub effective_at: NaiveDate, + pub origin: String, + pub posted_by_actor_id: Uuid, + pub correlation_id: Uuid, + pub rounding_evidence: JsonValue, + /// The locked FX rate snapshot for this entry — **one rate per entry** (§4.3), + /// stamped onto every line's `rate_snapshot_ref` by the journal repo. `None` + /// for a single-currency entry (the `RateLocker` short-circuits and leaves + /// functional NULL). Set by the S1/S2/S3 lock hook on a cross-currency post. + pub rate_snapshot_ref: Option, +} + +/// A journal line to insert (truth-table detail). Mirrors the +/// `journal_line` columns; `amount_minor`/`functional_amount_minor` are +/// integer minor units. +#[domain_model] +#[derive(Clone, Debug)] +pub struct NewLine { + pub line_id: Uuid, + pub payer_tenant_id: Uuid, + pub seller_tenant_id: Option, + pub resource_tenant_id: Option, + pub account_id: Uuid, + pub account_class: AccountClass, + pub gl_code: Option, + pub side: Side, + pub amount_minor: i64, + pub currency: String, + /// Minor-unit scale (digits after the decimal). `u8`: a small non-negative + /// count by construction; persisted as `smallint` at the entity boundary. + pub currency_scale: u8, + pub invoice_id: Option, + pub due_date: Option, + pub revenue_stream: Option, + pub mapping_status: MappingStatus, + pub functional_amount_minor: Option, + pub functional_currency: Option, + pub tax_jurisdiction: Option, + pub tax_filing_period: Option, + pub tax_rate_ref: Option, + pub legal_entity_id: Option, + pub invoice_item_ref: Option, + pub sku_or_plan_ref: Option, + pub price_id: Option, + pub pricing_snapshot_ref: Option, + pub po_allocation_group: Option, + pub credit_grant_event_type: Option, + /// AR dispute sub-class (`ACTIVE`/`DISPUTED`), set on AR lines of a + /// chargeback reclass; `None` on every other line. + pub ar_status: Option, +} + +/// A read-back entry: header plus its lines. Strings carry the stored +/// literals; later phases parse them back into SDK enums at use sites. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EntryRecord { + pub entry_id: Uuid, + pub tenant_id: Uuid, + pub legal_entity_id: Uuid, + pub period_id: String, + pub entry_currency: String, + pub source_doc_type: String, + pub source_business_id: String, + pub reverses_entry_id: Option, + pub reverses_period_id: Option, + pub posted_at_utc: DateTime, + pub effective_at: NaiveDate, + pub origin: String, + pub posted_by_actor_id: Uuid, + pub correlation_id: Uuid, + pub rounding_evidence: JsonValue, + pub created_seq: i64, + pub lines: Vec, +} + +/// A read-back journal line. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineRecord { + pub line_id: Uuid, + pub entry_id: Uuid, + pub tenant_id: Uuid, + pub period_id: String, + pub payer_tenant_id: Uuid, + pub seller_tenant_id: Option, + pub resource_tenant_id: Option, + pub account_id: Uuid, + pub account_class: String, + pub gl_code: Option, + pub side: String, + pub amount_minor: i64, + pub currency: String, + pub currency_scale: i16, + pub invoice_id: Option, + pub due_date: Option, + pub revenue_stream: Option, + pub mapping_status: String, + pub functional_amount_minor: Option, + pub functional_currency: Option, + pub tax_jurisdiction: Option, + pub tax_filing_period: Option, + pub tax_rate_ref: Option, + pub legal_entity_id: Option, + pub invoice_item_ref: Option, + pub sku_or_plan_ref: Option, + pub price_id: Option, + pub pricing_snapshot_ref: Option, + pub po_allocation_group: Option, + pub credit_grant_event_type: Option, + /// AR dispute sub-class (`ACTIVE`/`DISPUTED`); `None` on non-dispute lines. + pub ar_status: Option, +} + +/// A currency-scale registry row. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CurrencyScaleRow { + pub tenant_id: Uuid, + pub currency: String, + pub minor_units: i16, + /// Per-currency plausible maximum in MAJOR units (resolved; the default + /// 10^12 stands in when the request omits it). Governs the i64 headroom + /// guard at registration. + pub plausible_max_major: i64, + pub source: String, +} + +/// A chart-of-accounts row. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AccountRow { + pub account_id: Uuid, + pub tenant_id: Uuid, + pub legal_entity_id: Uuid, + pub account_class: String, + pub currency: String, + pub revenue_stream: Option, + pub normal_side: String, + pub may_go_negative: bool, + pub lifecycle_state: String, +} + +/// A fiscal-calendar config row (per legal entity). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FiscalCalendarRow { + pub tenant_id: Uuid, + pub legal_entity_id: Uuid, + pub fiscal_tz: String, + pub granularity: String, + pub fy_start_month: i16, + /// The legal entity's functional (books) currency, ISO-4217 (S5-F3). `None` → + /// single-currency tenant (no FX translation). + pub functional_currency: Option, +} + +/// A fiscal-period row (per legal entity + period). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FiscalPeriodRow { + pub tenant_id: Uuid, + pub legal_entity_id: Uuid, + pub period_id: String, + pub fiscal_tz: String, + pub status: String, +} + +/// Errors surfaced by the foundation repositories. +#[domain_model] +#[derive(Debug, thiserror::Error)] +pub enum RepoError { + /// Underlying database / scope failure. + #[error("ledger repo db error: {0}")] + Db(String), + /// A just-inserted row could not be read back (invariant breach). + #[error("ledger repo: row vanished after insert: {0}")] + RowVanished(String), + /// Currency scale would overflow i64 headroom at registration (A4/I-10). + #[error("ledger repo: currency scale out of range: {0}")] + ScaleOutOfRange(String), + /// Scale change rejected: postings exist for the currency (maps to + /// RFC-9457 `CURRENCY_SCALE_LOCKED`). + #[error("ledger repo: currency scale locked: {0}")] + CurrencyScaleLocked(String), + /// A payment money-out counter cap CHECK was violated — an allocation / + /// refund increment would push the per-payment running total past + /// `settled_minor` (or a refund past its allocated amount). The sidecar + /// maps this to `ALLOCATION_EXCEEDS_SETTLED`. + #[error("ledger repo: payment money-out cap exceeded: {0}")] + MoneyOutCapExceeded(String), + /// A dispute-outcome advance matched no row that was still `OPENED` at the + /// requested cycle — the dispute was concurrently resolved (a `won`/`lost` + /// race) or the outcome targets a stale cycle. The sidecar maps this to + /// `INVALID_DISPUTE_PHASE` (a non-retryable precondition failure), NOT a 500. + #[error("ledger repo: dispute not OPENED at the requested cycle: {0}")] + DisputeNotOpen(String), +} diff --git a/gears/bss/ledger/ledger/src/domain/money.rs b/gears/bss/ledger/ledger/src/domain/money.rs new file mode 100644 index 000000000..53638593e --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/money.rs @@ -0,0 +1,107 @@ +//! Money-side domain logic for the gear: the integer minor-unit `Money` value +//! type, the registration headroom check, and the scale-resolution error type. +//! Banker's rounding and proportional allocation live in the sibling `domain` +//! modules `money_math` and `allocate`; the registry-backed resolver lives in +//! `infra::currency_scale` (it depends on a repo). + +use serde::{Deserialize, Serialize}; +use toolkit_macros::domain_model; + +/// A monetary amount in integer minor units at a per-currency scale (decision +/// C — never float, never Decimal). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Money { + pub amount_minor: i64, + pub currency: String, + pub scale: u8, +} + +impl Money { + #[must_use] + pub fn new(amount_minor: i64, currency: impl Into, scale: u8) -> Self { + Self { + amount_minor, + currency: currency.into(), + scale, + } + } +} + +/// Default plausible maximum in MAJOR units when a currency does not declare +/// its own (`NUMERIC(38,0)` is deferred). At this bound the maximum acceptable +/// scale is 6; a higher-precision currency (e.g. 8-decimal crypto) registers a +/// smaller per-currency max so its scale still fits the headroom. +pub const PLAUSIBLE_MAX_MAJOR_UNITS: i128 = 1_000_000_000_000; // 10^12 + +/// `i64` form of [`PLAUSIBLE_MAX_MAJOR_UNITS`] — the per-currency default the +/// gear resolves when `ProvisionCurrencyScale::plausible_max_major` is `None`. +pub const DEFAULT_PLAUSIBLE_MAX_MAJOR: i64 = 1_000_000_000_000; // 10^12 + +/// True if a currency with `minor_units` scale keeps its per-currency +/// `plausible_max_major` within `i64` minor-unit headroom — i.e. +/// `plausible_max_major * 10^minor_units <= i64::MAX` (A4/I-10). With the +/// default max (10^12) the cutoff is scale 6; a smaller max admits a larger +/// scale. +#[must_use] +pub fn scale_fits_headroom(minor_units: i16, plausible_max_major: i64) -> bool { + let Ok(exp) = u32::try_from(minor_units) else { + return false; // negative scale is invalid + }; + let Some(factor) = 10_i128.checked_pow(exp) else { + return false; + }; + i128::from(plausible_max_major) + .checked_mul(factor) + .is_some_and(|m| m <= i128::from(i64::MAX)) +} + +/// Currency-scale resolution failure. +#[domain_model] +#[derive(Debug, thiserror::Error)] +pub enum ScaleError { + /// Underlying repository/scope failure. + #[error("scale resolve repo error: {0}")] + Repo(String), + /// Non-ISO currency with no registry row (no implicit scale). + #[error("no scale for currency: {0}")] + UnknownCurrencyScale(String), + /// A registry row exists but its stored `minor_units` is out of the valid + /// scale range (negative, or larger than a `u8`) — distinct from "no row": + /// the row must be repaired, not added. + #[error("corrupt stored scale for currency {currency}: minor_units={minor_units}")] + CorruptStoredScale { currency: String, minor_units: i16 }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn money_round_trips_through_json() { + let m = Money::new(12345, "USD", 2); + let json = serde_json::to_string(&m).unwrap(); + let back: Money = serde_json::from_str(&json).unwrap(); + assert_eq!(m, back); + } + + #[test] + fn headroom_cutoff_is_scale_six_at_default_max() { + let max = DEFAULT_PLAUSIBLE_MAX_MAJOR; + assert!(scale_fits_headroom(0, max)); + assert!(scale_fits_headroom(2, max)); + assert!(scale_fits_headroom(6, max)); + // 10^12 * 10^7 = 10^19 > i64::MAX (~9.22e18) + assert!(!scale_fits_headroom(7, max)); + assert!(!scale_fits_headroom(20, max)); + assert!(!scale_fits_headroom(-1, max)); + } + + #[test] + fn smaller_max_admits_higher_scale() { + // BTC: 21_000_000 major units at scale 8 -> 2.1e15 <= i64::MAX. + assert!(scale_fits_headroom(8, 21_000_000)); + // The same scale 8 overflows under the default 10^12 max. + assert!(!scale_fits_headroom(8, DEFAULT_PLAUSIBLE_MAX_MAJOR)); + } +} diff --git a/gears/bss/ledger/ledger/src/domain/money_math.rs b/gears/bss/ledger/ledger/src/domain/money_math.rs new file mode 100644 index 000000000..492a959be --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/money_math.rs @@ -0,0 +1,77 @@ +//! Centralized integer money math: half-to-even rounding and the i128→i64 +//! over-range guard. All amounts are minor units. Pure domain — no infra. + +use toolkit_macros::domain_model; + +/// Money-math failure. `Overflow` maps to RFC-9457 `AMOUNT_OUT_OF_RANGE`. +#[domain_model] +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum MoneyError { + #[error("amount out of range: {0}")] + Overflow(i128), + #[error("division by zero")] + DivByZero, + #[error("allocation weights must be non-empty")] + EmptyWeights, + #[error("allocation weights must be non-negative")] + NegativeWeight, +} + +/// Round `numer/denom` to the nearest integer, ties to even (banker's). +/// `denom` MUST be positive — guaranteed by the only caller (`allocate`, which +/// rejects a zero weight-sum first). Used for proportional allocation shares. +#[must_use] +pub(crate) fn round_half_even(numer: i128, denom: i128) -> i128 { + assert!(denom > 0, "denominator must be positive"); + let q = numer.div_euclid(denom); + let r = numer.rem_euclid(denom); // 0 <= r < denom + // Compare `r` to `denom - r` rather than `2 * r` to `denom`, so a large + // remainder cannot overflow i128. + let complement = denom - r; + if r < complement { + q + } else if r > complement { + q + 1 + } else if q % 2 == 0 { + // exact half: round to even + q + } else { + q + 1 + } +} + +/// Narrow an i128 minor-unit value to i64, erroring on over-range. +/// +/// # Errors +/// Returns [`MoneyError::Overflow`] if `value` does not fit in an `i64`. +pub fn checked_minor(value: i128) -> Result { + i64::try_from(value).map_err(|_| MoneyError::Overflow(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn half_to_even_rounds_ties_to_even() { + assert_eq!(round_half_even(5, 2), 2); // 2.5 -> 2 + assert_eq!(round_half_even(7, 2), 4); // 3.5 -> 4 + assert_eq!(round_half_even(1, 3), 0); // 0.333 -> 0 + assert_eq!(round_half_even(2, 3), 1); // 0.666 -> 1 + } + + #[test] + fn half_to_even_handles_negatives() { + assert_eq!(round_half_even(-5, 2), -2); // -2.5 -> -2 (even) + assert_eq!(round_half_even(-7, 2), -4); // -3.5 -> -4 (even) + } + + #[test] + fn checked_minor_guards_range() { + assert_eq!(checked_minor(1234), Ok(1234)); + assert_eq!( + checked_minor(i128::from(i64::MAX) + 1), + Err(MoneyError::Overflow(i128::from(i64::MAX) + 1)) + ); + } +} diff --git a/gears/bss/ledger/ledger/src/domain/payment.rs b/gears/bss/ledger/ledger/src/domain/payment.rs new file mode 100644 index 000000000..570a47473 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment.rs @@ -0,0 +1,38 @@ +//! Payment domain (architecture §5.2 — Pattern A settle + oldest-first +//! allocation). Pure, backend-agnostic builders for the two payment postings and +//! the precedence policy that splits a lump across open invoices: +//! +//! - [`settlement`] — `build_settlement_entry`: the cash-landing entry +//! (`PAYMENT_SETTLE`). DR `CASH_CLEARING` + optional DR `PSP_FEE_EXPENSE`, +//! CR `UNALLOCATED` for the gross. Money lands in the unallocated pool. +//! - [`settlement_return`] — `build_settlement_return_entry`: the clawback entry +//! (`SETTLEMENT_RETURN`). DR `UNALLOCATED` / CR `CASH_CLEARING`, removing a +//! returned receipt from the pool. +//! - [`chargeback`] — `build_chargeback_entry`: the dispute entry (`CHARGEBACK`). +//! Group B builds the `opened` phase in both variants — cash-hold +//! (`DR DISPUTE_HOLD / CR CASH_CLEARING`) and AR-reclass (two AR legs that +//! reclass `ACTIVE → DISPUTED`, AR-class-neutral). `won`/`lost` are Group C. +//! - [`precedence`] — `oldest_first`: the `oldest-first.v1` policy that +//! sequentially fills open invoices from a lump (oldest first, optional hint to +//! the front), pure `i64`, no proportional split. +//! - [`allocation`] — `build_allocation_entry`: the apply entry +//! (`PAYMENT_ALLOCATE`). DR `UNALLOCATED` (Σ splits) + CR `AR` per invoice, +//! draining the pool into the receivables it pays. +//! - [`credit`] — reusable-credit (wallet) money-shapes: `build_grant_entry` +//! (`CREDIT_APPLY`; DR `UNALLOCATED` / CR `REUSABLE_CREDIT`, parking pool cash +//! into the wallet), `build_apply_entry` (`CREDIT_APPLY`; N×DR `REUSABLE_CREDIT` +//! / M×CR `AR`, spending the wallet against receivables), the +//! `plan_wallet_debit` sub-grain fill planner, and the `validate_credit_targets` +//! AR-target validator. +//! +//! Every module here is pure (no infra / DB imports — dylint DE0301): it computes +//! over SDK value types and produces SDK `PostEntry`/`PostLine` with placeholder +//! header + `account_id` fields the `crate::infra` orchestrator binds before +//! posting (exactly like the invoice builder). + +pub mod allocation; +pub mod chargeback; +pub mod credit; +pub mod precedence; +pub mod settlement; +pub mod settlement_return; diff --git a/gears/bss/ledger/ledger/src/domain/payment/allocation.rs b/gears/bss/ledger/ledger/src/domain/payment/allocation.rs new file mode 100644 index 000000000..21eda3d06 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/allocation.rs @@ -0,0 +1,260 @@ +//! Allocation-entry builder (architecture §5.2, **Pattern A apply**). Turns a +//! decided split of the unallocated pool into a balanced [`PostEntry`] that +//! drains the pool into the receivables it pays: +//! +//! - **DR `UNALLOCATED`** — one line for the sum of the splits (the amount leaving +//! the pool). +//! - **CR `AR`** — one line per split, each carrying its `invoice_id` (the +//! receivable that share pays down). +//! +//! `Σ DR (= Σ splits) == Σ CR (= Σ splits)` exactly — pure `i64`, summed via +//! `i128`. The split amounts come from +//! [`crate::domain::payment::precedence::oldest_first`]. Lines carry a placeholder +//! nil `account_id` (bound by the `crate::infra` orchestrator from +//! `(account_class, currency)` before posting) and placeholder header fields it +//! likewise overwrites (`period_id`, `posted_by_actor_id`, `correlation_id`, and +//! — when `effective_at` is `None` — `effective_at`). + +use bss_ledger_sdk::{AccountClass, MappingStatus, PostEntry, PostLine, Side, SourceDocType}; +use chrono::{DateTime, Utc}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::payment::precedence::{Allocated, Candidate}; + +/// A decided allocation to post (Pattern A apply input): which invoices the pool +/// pays and by how much. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AllocationInput { + /// The seller tenant whose ledger this posts into (`= entry.tenant_id`), and + /// the `seller_tenant_id` stamped on each line. + pub tenant_id: Uuid, + /// The tenant whose receivables are being paid (the single payer of the + /// entry). + pub payer_tenant_id: Uuid, + /// External payment identity (lineage — the payment whose pool this drains). + pub payment_id: String, + /// Allocation identity — the `PAYMENT_ALLOCATE` idempotency business id + /// (`source_business_id = allocation_id.to_string()`). + pub allocation_id: Uuid, + /// ISO currency of the allocation (every line shares it). + pub currency: String, + /// The per-invoice shares to apply (from the precedence policy). Must be + /// non-empty and every `amount_minor` must be `> 0`. + pub splits: Vec, + /// Allocation instant. `None` ⇒ a placeholder effective date the orchestrator + /// overwrites before posting (see module docs). + pub effective_at: Option>, +} + +/// Build the balanced Pattern-A allocation entry for `input`. +/// +/// Lines: one DR `UNALLOCATED` for `Σ splits` FIRST, then one CR `AR` per split +/// (in `splits` order), each carrying `invoice_id = Some(split.invoice_id)`. +/// `source_doc_type = PAYMENT_ALLOCATE`, `source_business_id = +/// allocation_id.to_string()`, `reverses_* = None`. Every line carries the payer, +/// the currency, and `seller_tenant_id = Some(tenant_id)`; the UNALLOCATED line +/// has `invoice_id = None`. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `splits` is empty, or any split has +/// `amount_minor <= 0` (an empty / unrepresentable allocation). +pub fn build_allocation_entry(input: &AllocationInput) -> Result { + if input.splits.is_empty() { + return Err(DomainError::InvalidRequest( + "allocation has no splits".to_owned(), + )); + } + for split in &input.splits { + if split.amount_minor <= 0 { + return Err(DomainError::InvalidRequest(format!( + "allocation split for invoice {} must be > 0, got {}", + split.invoice_id, split.amount_minor + ))); + } + } + + // Σ splits = the amount leaving the pool (the single DR UNALLOCATED). Widened + // to i128 while folding to dodge an intermediate overflow (mirrors the invoice + // builder); the per-entry headroom guard keeps the total within i64. + let sum_minor: i128 = input + .splits + .iter() + .map(|s| i128::from(s.amount_minor)) + .sum(); + // Σ splits is bounded by `lump_minor` (an i64) on both the decided and the + // caller-split paths, so this never overflows in practice; still, guard it as + // an explicit `AmountOutOfRange` rather than silently clamping to `i64::MAX` + // — a clamp would emit a `DR UNALLOCATED` that no longer equals `Σ CR AR`, + // which the engine then rejects as "unbalanced", a misleading code for what + // is really an over-range total. + let sum_minor = i64::try_from(sum_minor).map_err(|_| { + DomainError::AmountOutOfRange(format!( + "allocation total {sum_minor} exceeds the representable i64 range" + )) + })?; + + // A nil account_id / Resolved status line carrying the entry-wide payer + + // currency; only the class / side / amount / invoice_id differ per line. + let line = |account_class: AccountClass, + side: Side, + amount_minor: i64, + invoice_id: Option| PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: input.payer_tenant_id, + seller_tenant_id: Some(input.tenant_id), + resource_tenant_id: None, + account_id: Uuid::nil(), + account_class, + gl_code: None, + side, + amount_minor, + currency: input.currency.clone(), + invoice_id, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }; + + // DR UNALLOCATED (Σ) first, then one CR AR per split (invoice_id carried). Σ + // DR = Σ splits = Σ CR. + let mut lines: Vec = Vec::with_capacity(1 + input.splits.len()); + lines.push(line( + AccountClass::Unallocated, + Side::Debit, + sum_minor, + None, + )); + for split in &input.splits { + lines.push(line( + AccountClass::Ar, + Side::Credit, + split.amount_minor, + Some(split.invoice_id.clone()), + )); + } + + Ok(PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: input.tenant_id, + // Placeholder header fields the infra orchestrator overwrites before + // posting (period, actor/correlation, and a real effective date for the + // `None` case) — mirrors the nil account_id. + period_id: String::new(), + entry_currency: input.currency.clone(), + source_doc_type: SourceDocType::PaymentAllocate, + source_business_id: input.allocation_id.to_string(), + effective_at: input + .effective_at + .unwrap_or(DateTime::UNIX_EPOCH) + .date_naive(), + posted_by_actor_id: Uuid::nil(), + correlation_id: Uuid::nil(), + reverses_entry_id: None, + reverses_period_id: None, + lines, + }) +} + +/// Validate a **caller-computed** allocation split against the open candidate +/// set (architecture §4.4 F-5, Mode B). The escape hatch where the caller +/// supplies the per-invoice shares instead of letting a precedence policy decide +/// them; this subjects that split to the SAME invariants the decided path is +/// implicitly subject to, so the two paths post under identical guarantees. +/// +/// Each caller split must name a present candidate with `open_minor > 0`, carry +/// `0 < amount_minor <= that candidate's open_minor`, and appear at most once; +/// the splits together must not exceed `lump_minor`. On success the validated +/// splits are returned in the caller's order (the order the resulting CR AR +/// lines are built in) — never reordered or coalesced. +/// +/// # Errors +/// [`DomainError::AllocationSplitInvalid`] when any split names an unknown or +/// closed (`open_minor <= 0`) candidate, exceeds that candidate's open balance, +/// is non-positive, repeats an invoice, or the splits sum past `lump_minor`. +pub fn validate_caller_split( + candidates: &[Candidate], + caller: &[Allocated], + lump_minor: i64, +) -> Result, DomainError> { + let mut seen: Vec<&str> = Vec::with_capacity(caller.len()); + let mut sum: i128 = 0; + for split in caller { + // Reject a duplicate invoice_id: two splits for the same receivable are + // ambiguous (which CR AR line wins?) and the precedence path never emits + // one, so the caller path must not either. + if seen.contains(&split.invoice_id.as_str()) { + return Err(DomainError::AllocationSplitInvalid(format!( + "duplicate invoice {} in caller split", + split.invoice_id + ))); + } + seen.push(split.invoice_id.as_str()); + + // Each share must be representable and positive — a zero/negative + // allocation is meaningless (mirrors the decided path, which never emits + // a non-positive share). + if split.amount_minor <= 0 { + return Err(DomainError::AllocationSplitInvalid(format!( + "caller split for invoice {} must be > 0, got {}", + split.invoice_id, split.amount_minor + ))); + } + + // The invoice must be a present, still-open candidate, and the share may + // not exceed its open balance (the per-invoice cap the decided fill is + // bounded by via `min(remaining, open_minor)`). + let candidate = candidates + .iter() + .find(|c| c.invoice_id == split.invoice_id) + .ok_or_else(|| { + DomainError::AllocationSplitInvalid(format!( + "caller split names invoice {} which is not an open candidate", + split.invoice_id + )) + })?; + if candidate.open_minor <= 0 { + return Err(DomainError::AllocationSplitInvalid(format!( + "caller split names invoice {} which is closed (open {})", + split.invoice_id, candidate.open_minor + ))); + } + if split.amount_minor > candidate.open_minor { + return Err(DomainError::AllocationSplitInvalid(format!( + "caller split for invoice {} ({}) exceeds its open balance ({})", + split.invoice_id, split.amount_minor, candidate.open_minor + ))); + } + + sum += i128::from(split.amount_minor); + } + + // The splits together may not exceed the lump (the decided path can only + // give out what `remaining` allows; the caller path is bounded the same). + // Widened to i128 so the fold can't overflow before the comparison. + if sum > i128::from(lump_minor) { + return Err(DomainError::AllocationSplitInvalid(format!( + "caller split total {sum} exceeds lump {lump_minor}" + ))); + } + + Ok(caller.to_vec()) +} + +#[cfg(test)] +#[path = "allocation_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/payment/allocation_tests.rs b/gears/bss/ledger/ledger/src/domain/payment/allocation_tests.rs new file mode 100644 index 000000000..a72b8d50a --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/allocation_tests.rs @@ -0,0 +1,209 @@ +use super::*; + +fn split(id: &str, amount: i64) -> Allocated { + Allocated { + invoice_id: id.to_owned(), + amount_minor: amount, + } +} + +fn input(splits: Vec) -> AllocationInput { + AllocationInput { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + payment_id: "PAY-1".to_owned(), + allocation_id: Uuid::now_v7(), + currency: "USD".to_owned(), + splits, + effective_at: None, + } +} + +fn sum_dr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +fn sum_cr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +#[test] +fn two_splits_dr_unallocated_plus_cr_ar_per_invoice() { + let inp = input(vec![split("A", 300), split("B", 200)]); + let entry = build_allocation_entry(&inp).unwrap(); + + assert_eq!(entry.source_doc_type, SourceDocType::PaymentAllocate); + assert_eq!(entry.source_business_id, inp.allocation_id.to_string()); + assert_eq!(entry.lines.len(), 3); + + // DR UNALLOCATED for the sum (500) FIRST, no invoice_id. + let unalloc = &entry.lines[0]; + assert_eq!(unalloc.account_class, AccountClass::Unallocated); + assert_eq!(unalloc.side, Side::Debit); + assert_eq!(unalloc.amount_minor, 500); + assert_eq!(unalloc.invoice_id, None); + + // CR AR per split, in splits order, each carrying its invoice_id. + let ar_a = &entry.lines[1]; + assert_eq!(ar_a.account_class, AccountClass::Ar); + assert_eq!(ar_a.side, Side::Credit); + assert_eq!(ar_a.amount_minor, 300); + assert_eq!(ar_a.invoice_id, Some("A".to_owned())); + + let ar_b = &entry.lines[2]; + assert_eq!(ar_b.account_class, AccountClass::Ar); + assert_eq!(ar_b.side, Side::Credit); + assert_eq!(ar_b.amount_minor, 200); + assert_eq!(ar_b.invoice_id, Some("B".to_owned())); + + // Balanced: Σ DR (500) == Σ CR (500). + assert_eq!(sum_dr(&entry), 500); + assert_eq!(sum_cr(&entry), 500); + assert_eq!(sum_dr(&entry), sum_cr(&entry)); + + // Every line carries the payer, currency, and seller. + for l in &entry.lines { + assert_eq!(l.payer_tenant_id, inp.payer_tenant_id); + assert_eq!(l.currency, "USD"); + assert_eq!(l.seller_tenant_id, Some(inp.tenant_id)); + } +} + +#[test] +fn empty_splits_is_rejected() { + let err = build_allocation_entry(&input(vec![])).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +#[test] +fn non_positive_split_amount_is_rejected() { + let err = build_allocation_entry(&input(vec![split("A", 0)])).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); + + let err = build_allocation_entry(&input(vec![split("A", 300), split("B", -5)])).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +// ── validate_caller_split (Mode B, §4.4 F-5) ───────────────────────────── + +/// An open candidate with the given remaining balance. `original_posted_at` +/// is irrelevant to caller-split validation (no ordering), so it stays `None`. +fn candidate(id: &str, open: i64) -> Candidate { + Candidate { + invoice_id: id.to_owned(), + open_minor: open, + original_posted_at: None, + } +} + +#[test] +fn caller_split_happy_preserves_order_and_amounts() { + let candidates = vec![candidate("A", 300), candidate("B", 800)]; + // Caller order (B before A) is preserved verbatim — never reordered to + // the candidate order; under-allocating the lump is allowed. + let caller = vec![split("B", 200), split("A", 250)]; + let out = validate_caller_split(&candidates, &caller, 500).unwrap(); + assert_eq!(out, caller, "validated splits returned in caller order"); +} + +#[test] +fn caller_split_full_open_and_full_lump_is_allowed() { + // Exactly the open balance and exactly the lump are both at the boundary + // (`<=`), so they pass. + let candidates = vec![candidate("A", 300), candidate("B", 200)]; + let caller = vec![split("A", 300), split("B", 200)]; + assert!(validate_caller_split(&candidates, &caller, 500).is_ok()); +} + +#[test] +fn caller_split_over_open_is_rejected() { + let candidates = vec![candidate("A", 300)]; + // 301 > A's open 300. + let err = validate_caller_split(&candidates, &[split("A", 301)], 1000).unwrap_err(); + assert!( + matches!(err, DomainError::AllocationSplitInvalid(_)), + "{err:?}" + ); +} + +#[test] +fn caller_split_over_lump_is_rejected() { + let candidates = vec![candidate("A", 300), candidate("B", 300)]; + // Each share is within its open balance, but 300 + 300 > lump 500. + let err = + validate_caller_split(&candidates, &[split("A", 300), split("B", 300)], 500).unwrap_err(); + assert!( + matches!(err, DomainError::AllocationSplitInvalid(_)), + "{err:?}" + ); +} + +#[test] +fn caller_split_unknown_invoice_is_rejected() { + let candidates = vec![candidate("A", 300)]; + let err = validate_caller_split(&candidates, &[split("Z", 100)], 1000).unwrap_err(); + assert!( + matches!(err, DomainError::AllocationSplitInvalid(_)), + "{err:?}" + ); +} + +#[test] +fn caller_split_closed_candidate_is_rejected() { + // A present but fully-paid (open 0) candidate is not a valid target. + let candidates = vec![candidate("A", 0)]; + let err = validate_caller_split(&candidates, &[split("A", 1)], 1000).unwrap_err(); + assert!( + matches!(err, DomainError::AllocationSplitInvalid(_)), + "{err:?}" + ); +} + +#[test] +fn caller_split_duplicate_invoice_is_rejected() { + let candidates = vec![candidate("A", 300)]; + let err = + validate_caller_split(&candidates, &[split("A", 100), split("A", 50)], 1000).unwrap_err(); + assert!( + matches!(err, DomainError::AllocationSplitInvalid(_)), + "{err:?}" + ); +} + +#[test] +fn caller_split_non_positive_amount_is_rejected() { + let candidates = vec![candidate("A", 300), candidate("B", 300)]; + let err = validate_caller_split(&candidates, &[split("A", 0)], 1000).unwrap_err(); + assert!( + matches!(err, DomainError::AllocationSplitInvalid(_)), + "{err:?}" + ); + + let err = validate_caller_split(&candidates, &[split("B", -5)], 1000).unwrap_err(); + assert!( + matches!(err, DomainError::AllocationSplitInvalid(_)), + "{err:?}" + ); +} + +#[test] +fn caller_split_empty_is_ok_but_builds_nothing() { + // An empty caller split validates (vacuously) — the orchestrator's + // separate empty-splits guard is what rejects a no-op allocation, exactly + // as it does for an empty precedence result. + let candidates = vec![candidate("A", 300)]; + assert_eq!( + validate_caller_split(&candidates, &[], 500).unwrap(), + vec![] + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/payment/chargeback.rs b/gears/bss/ledger/ledger/src/domain/payment/chargeback.rs new file mode 100644 index 000000000..d3129a095 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/chargeback.rs @@ -0,0 +1,599 @@ +//! Chargeback (dispute) entry builder (architecture §4.5, design §2 / §3). A +//! dispute moves through `opened → {won, lost, partial}` phases; the LEDGER +//! chooses the **variant** at `opened` from the request's `funds_at_open` fact +//! (card rails withheld the cash ⇒ `CASH_HOLD`; invoice/ACH did not move it ⇒ +//! `AR_RECLASS`) and the `won`/`lost` outcomes branch on the recorded variant. +//! +//! Scope (Groups B + C): this builder handles `opened`, `won`, and `lost`, in +//! both variants (`partial` is behind a flag — still rejected). The legs, pinned +//! from architecture §4.5 / design §3. +//! +//! **Model N (cash legs are net-of-fee).** A `CASH_HOLD` dispute's cash legs are +//! sized at `net = settled_minor − fee_minor`, NOT the gross `disputed`. `settle` +//! posts `DR CASH_CLEARING (net) · DR PSP_FEE_EXPENSE (fee) · CR UNALLOCATED +//! (gross)`, so `CASH_CLEARING` only ever holds **net** — sizing a cash leg at +//! gross would underflow it by exactly the fee. The PSP fee is finalised at +//! `settle` (in `PSP_FEE_EXPENSE`) and is never disputed; the seller eats it on a +//! loss. `disputed_amount_minor` is the **gross** claim (what the buyer paid / the +//! bank reverses) and sizes the AR-reclass legs + the dispute row, never the cash +//! legs. The orchestrator reads the settlement pre-build and threads `net` in +//! (the builder stays pure — it does no IO; it only sizes legs on the number it +//! is handed). Worked example (gross 100, fee 3, net 97): +//! ```text +//! settle: DR CASH_CLEARING 97 · DR PSP_FEE_EXPENSE 3 · CR UNALLOCATED 100 +//! opened: DR DISPUTE_HOLD 97 · CR CASH_CLEARING 97 +//! won: DR CASH_CLEARING 97 · CR DISPUTE_HOLD 97 +//! lost: DR DISPUTE_LOSS 97 · CR DISPUTE_HOLD 97 (+ fee 3 already expensed = 100) +//! ``` +//! +//! **`opened`** (Group B): +//! - **`CASH_HOLD`** — move the settled cash into a hold: +//! `DR DISPUTE_HOLD (net) / CR CASH_CLEARING (net)`. Balanced; touches no AR. +//! - **`AR_RECLASS`** — reclass the disputed receivable `ACTIVE → DISPUTED` at +//! the `(payer, invoice)` grain, AR-class-neutral: two AR lines for the SAME +//! `(payer, invoice)` — `DR AR (ar_status = DISPUTED)` + `CR AR (ar_status = +//! ACTIVE)`, each `disputed` — that net ZERO on `ar_invoice_balance.balance_minor` +//! while the Group-A projector routes the signed `DISPUTED` delta into +//! `disputed_minor` (+D). No PSP fee on invoice/ACH ⇒ gross = net = receivable. +//! +//! **`won`** (Group C — the seller's favour, no clawback): +//! - **`AR_RECLASS`** — reverse the opened reclass `DISPUTED → ACTIVE`: +//! `DR AR (ar_status = ACTIVE)` + `CR AR (ar_status = DISPUTED)`, each +//! `disputed`. Nets ZERO on `balance_minor`, `−D` on `disputed_minor`. No cash. +//! - **`CASH_HOLD`** — release the hold back to clearing: +//! `DR CASH_CLEARING (net) / CR DISPUTE_HOLD (net)` (the reverse of the opened +//! cash leg). The withheld net cash is the seller's again (the fee stays lost). +//! +//! **`lost`** (Group C — against the seller, the clawback): +//! - **`CASH_HOLD`** — the cash was already withheld at `opened` (it left +//! `CASH_CLEARING` into `DISPUTE_HOLD` then), so the loss is recognised out of +//! the hold: `DR DISPUTE_LOSS_EXPENSE (net) / CR DISPUTE_HOLD (net)` (release +//! the hold, book the forfeiture). `clawed_back_minor += net`. `CASH_CLEARING` +//! is NOT touched — the funds left clearing at open. The total loss is `net` +//! (dispute-loss) + the fee already expensed at settle = gross = the buyer +//! refund. +//! - **`AR_RECLASS`** — a write-off. The receivable was never collected (funds +//! `not_moved`), so a lost dispute writes it off to loss: +//! `DR DISPUTE_LOSS_EXPENSE (disputed) / CR AR (ar_status = DISPUTED) +//! (disputed)`. The lone `CR AR DISPUTED` nets `−D` on BOTH `balance_minor` and +//! `disputed_minor` (the projector routes the signed DISPUTED delta onto both), +//! so no extra balance leg is needed. No cash leg, `clawed_back_minor` +//! unchanged (nothing was ever collected to claw back). +//! +//! `partial` is NOT built here (behind a flag); the builder returns +//! [`DomainError::InvalidDisputeTransition`] for it. +//! +//! `source_doc_type = CHARGEBACK`; `source_business_id = "dispute_id:cycle:phase"` +//! (the `snake_case` composite idempotency key). Like the settlement / settlement- +//! return builders this stays pure (dylint DE0301): each line carries a nil +//! placeholder `account_id` + placeholder header fields the `crate::infra` +//! orchestrator overwrites before posting. + +use bss_ledger_sdk::{AccountClass, MappingStatus, PostEntry, PostLine, Side, SourceDocType}; +use chrono::{DateTime, Utc}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::status::{AR_STATUS_ACTIVE, AR_STATUS_DISPUTED}; + +/// A dispute phase (the `phase` of one chargeback event). `opened` ships in +/// Group B; `won`/`lost` arrive in Group C; `partial` is behind a flag +/// (design §2). The literal is the third token of `source_business_id` +/// (`dispute_id:cycle:phase`) and is persisted via the journal, not stored +/// on the dispute row except as `last_phase`. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisputePhase { + /// The dispute was raised — cash moved to a hold (`CASH_HOLD`) or the + /// receivable reclassed to `DISPUTED` (`AR_RECLASS`). + Opened, + /// The dispute resolved in the seller's favour (Group C). + Won, + /// The dispute resolved against the seller — a clawback (Group C). + Lost, + /// A split outcome (behind a flag; Group C). + Partial, +} + +impl DisputePhase { + /// Stable uppercase wire literal (the `last_phase` column value + the third + /// `source_business_id` token). + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Opened => "OPENED", + Self::Won => "WON", + Self::Lost => "LOST", + Self::Partial => "PARTIAL", + } + } + + /// Parse a stored / wire phase literal (case-insensitive), or `None` for an + /// unknown value. + #[must_use] + pub fn parse(s: &str) -> Option { + match s.to_ascii_uppercase().as_str() { + "OPENED" => Some(Self::Opened), + "WON" => Some(Self::Won), + "LOST" => Some(Self::Lost), + "PARTIAL" => Some(Self::Partial), + _ => None, + } + } +} + +/// The dispute variant the LEDGER records at `opened` (design §2): it pins how +/// the `opened` move posts AND how `won`/`lost` branch. Chosen from +/// `funds_at_open`, NOT tenant/plugin policy. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisputeVariant { + /// Card rails withheld the cash (`funds_at_open = withheld`): the settled + /// cash is moved into a `DISPUTE_HOLD`. + CashHold, + /// Invoice / ACH did not move the cash (`funds_at_open = not_moved`): the + /// receivable is reclassed `ACTIVE → DISPUTED` (AR-class-neutral). + ArReclass, +} + +impl DisputeVariant { + /// Stable uppercase wire literal (the `variant` column value). + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::CashHold => "CASH_HOLD", + Self::ArReclass => "AR_RECLASS", + } + } + + /// Parse a stored variant literal (case-insensitive), or `None` for an + /// unknown value. + #[must_use] + pub fn parse(s: &str) -> Option { + match s.to_ascii_uppercase().as_str() { + "CASH_HOLD" => Some(Self::CashHold), + "AR_RECLASS" => Some(Self::ArReclass), + _ => None, + } + } +} + +/// The funds-movement fact the LEDGER reads at `opened` to choose the variant +/// (architecture N-pay-8 / design §2). The PSP / payments gear populates it; +/// the ledger never re-derives it. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FundsAtOpen { + /// Card rails withheld the cash ⇒ [`DisputeVariant::CashHold`]. + Withheld, + /// Invoice / ACH did not move the cash ⇒ [`DisputeVariant::ArReclass`]. + NotMoved, +} + +impl FundsAtOpen { + /// The variant this funds-fact selects at `opened`. + #[must_use] + pub const fn variant(self) -> DisputeVariant { + match self { + Self::Withheld => DisputeVariant::CashHold, + Self::NotMoved => DisputeVariant::ArReclass, + } + } + + /// Stable lower-case wire literal. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Withheld => "withheld", + Self::NotMoved => "not_moved", + } + } + + /// Parse a wire funds-fact literal (case-insensitive), or `None` for an + /// unknown value. + #[must_use] + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "withheld" => Some(Self::Withheld), + "not_moved" => Some(Self::NotMoved), + _ => None, + } + } +} + +/// One chargeback phase event to post (architecture §4.5 input). The dispute is +/// per payment; an `AR_RECLASS` reclass additionally needs the `invoice_id` +/// being disputed (the AR grain is `(payer, invoice)`). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChargebackInput { + /// The seller tenant whose ledger this posts into (`= entry.tenant_id`), and + /// the `seller_tenant_id` stamped on each line. + pub tenant_id: Uuid, + /// The tenant that paid / owes (the single payer of the entry). + pub payer_tenant_id: Uuid, + /// The disputed payment — the `ledger_payment_settlement` row this dispute + /// references. + pub payment_id: String, + /// External dispute identity — the first `source_business_id` token. + pub dispute_id: String, + /// Re-entrancy counter (`>= 1`); the second `source_business_id` token. + pub cycle: i32, + /// The phase being recorded (Group B handles only `Opened`). + pub phase: DisputePhase, + /// The variant — selected at `opened` from `funds_at_open`, then carried on + /// every subsequent phase of the same dispute cycle. + pub variant: DisputeVariant, + /// The disputed amount in minor units. Must be `> 0`. + pub disputed_amount_minor: i64, + /// The disputed `(payer, invoice)` AR grain — REQUIRED for `AR_RECLASS` + /// (the two AR legs carry it); `None` for `CASH_HOLD` (no AR leg). + pub invoice_id: Option, + /// ISO currency of the dispute (every line shares it). + pub currency: String, + /// Phase instant. `None` ⇒ a placeholder effective date the orchestrator + /// overwrites before posting. + pub effective_at: Option>, +} + +impl ChargebackInput { + /// The `source_business_id` composite for this event: `dispute_id:cycle:phase` + /// (the `snake_case` idempotency key, design §5). + #[must_use] + pub fn business_id(&self) -> String { + format!("{}:{}:{}", self.dispute_id, self.cycle, self.phase.as_str()) + } +} + +/// Build the balanced chargeback entry for `input`, given the `CASH_HOLD` cash-leg +/// size `net_minor` (`= settled_minor − fee_minor`, read out-of-txn by the +/// orchestrator before the post — the builder stays pure; `0` / ignored for the +/// AR-reclass variants, which carry no PSP fee and size on `disputed`). The +/// `(variant, phase)` legs are pinned from architecture §4.5 / design §3, Model N +/// (see the module docs for the full table): +/// +/// - `opened` `CASH_HOLD` ⇒ `DR DISPUTE_HOLD (net) / CR CASH_CLEARING (net)`. +/// - `opened` `AR_RECLASS` ⇒ `DR AR DISPUTED + CR AR ACTIVE` (nets ZERO on +/// `balance_minor`, `+D` on `disputed_minor`). +/// - `won` `CASH_HOLD` ⇒ `DR CASH_CLEARING (net) / CR DISPUTE_HOLD (net)` +/// (release the hold). +/// - `won` `AR_RECLASS` ⇒ `DR AR ACTIVE + CR AR DISPUTED` (nets ZERO on +/// `balance_minor`, `−D` on `disputed_minor`); no cash. +/// - `lost` `CASH_HOLD` ⇒ `DR DISPUTE_LOSS_EXPENSE (net) / CR DISPUTE_HOLD (net)` +/// (forfeit the already-withheld cash out of the hold; `CASH_CLEARING` +/// untouched — the fee is already expensed at settle). +/// - `lost` `AR_RECLASS` ⇒ a write-off: `DR DISPUTE_LOSS_EXPENSE (disputed) / +/// CR AR DISPUTED (disputed)` (the lone `CR AR DISPUTED` nets `−D` on both +/// `balance_minor` and `disputed_minor`); no cash, no clawback. +/// +/// `source_doc_type = CHARGEBACK`, `source_business_id = +/// "dispute_id:cycle:phase"`, `reverses_* = None`. Every line carries the +/// payer, the currency, and `seller_tenant_id = Some(tenant_id)`. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `disputed_amount_minor <= 0`, when a +/// `CASH_HOLD` variant's net cash is non-positive, or when an `AR_RECLASS` phase +/// is missing its `invoice_id`; +/// [`DomainError::InvalidDisputeTransition`] for `Partial` (behind a flag — +/// not built). +pub fn build_chargeback_entry( + input: &ChargebackInput, + net_minor: i64, +) -> Result { + // Reject a non-positive disputed amount at the boundary (zero-amount lines + // would otherwise surface deep down as the misleading `AMOUNT_OUT_OF_RANGE`). + if input.disputed_amount_minor <= 0 { + return Err(DomainError::InvalidRequest(format!( + "chargeback disputed_amount_minor must be > 0, got {}", + input.disputed_amount_minor + ))); + } + + // CASH_HOLD legs hold the disputed claim, but never more than the payment's + // net (`settled − fee`, threaded in as `net_minor`) — the cash that actually + // reached `CASH_CLEARING`. A full-payment claim caps at net (the fee was + // already expensed at settle, never in clearing); a partial claim (disputed < + // net) holds the claim itself. AR-reclass variants ignore it (no PSP fee, no + // cash leg). + let cash_hold_minor = net_minor.min(input.disputed_amount_minor); + // CASH_HOLD legs are sized on the held cash; a non-positive hold would emit + // zero-amount lines the engine rejects deep down as the misleading + // `AMOUNT_OUT_OF_RANGE`. Catch it at the boundary with a clear code. In + // normal flow this is defense-in-depth: settle enforces net > 0 and the + // open-cycle guard enforces a stored hold on `won`/`lost`. + if input.variant == DisputeVariant::CashHold && cash_hold_minor <= 0 { + return Err(DomainError::InvalidRequest(format!( + "chargeback CASH_HOLD net cash must be > 0, got {cash_hold_minor}" + ))); + } + let lines = match (input.phase, input.variant) { + (DisputePhase::Opened, DisputeVariant::CashHold) => { + opened_cash_hold_lines(input, cash_hold_minor) + } + (DisputePhase::Opened, DisputeVariant::ArReclass) => opened_ar_reclass_lines(input)?, + (DisputePhase::Won, DisputeVariant::CashHold) => { + won_cash_hold_lines(input, cash_hold_minor) + } + (DisputePhase::Won, DisputeVariant::ArReclass) => won_ar_reclass_lines(input)?, + (DisputePhase::Lost, DisputeVariant::CashHold) => { + lost_cash_hold_lines(input, cash_hold_minor) + } + (DisputePhase::Lost, DisputeVariant::ArReclass) => lost_ar_reclass_lines(input)?, + // `partial` is behind a flag (design §2 / §7) — not built in this phase. + (DisputePhase::Partial, _) => { + return Err(DomainError::InvalidDisputeTransition(format!( + "dispute phase {} is behind a flag (split chargeback) and not implemented", + input.phase.as_str() + ))); + } + }; + + Ok(PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: input.tenant_id, + // Placeholder header fields the infra orchestrator overwrites before + // posting (period, actor/correlation, real effective date for `None`). + period_id: String::new(), + entry_currency: input.currency.clone(), + source_doc_type: SourceDocType::Chargeback, + source_business_id: input.business_id(), + effective_at: input + .effective_at + .unwrap_or(DateTime::UNIX_EPOCH) + .date_naive(), + posted_by_actor_id: Uuid::nil(), + correlation_id: Uuid::nil(), + reverses_entry_id: None, + reverses_period_id: None, + lines, + }) +} + +/// A non-AR cash/expense line for `amount_minor` carrying the entry-wide payer + +/// currency + seller; only `account_class` + `side` + `amount_minor` differ. +/// Shared by the cash-hold legs (sized at `net`) + the lost cash/loss legs (the +/// settlement-return builder uses the same shape). `invoice_id`/`ar_status` are +/// `None` (these are not AR). +fn cash_line( + input: &ChargebackInput, + account_class: AccountClass, + side: Side, + amount_minor: i64, +) -> PostLine { + PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: input.payer_tenant_id, + seller_tenant_id: Some(input.tenant_id), + resource_tenant_id: None, + account_id: Uuid::nil(), + account_class, + gl_code: None, + side, + amount_minor, + currency: input.currency.clone(), + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// The two balanced AR reclass legs for `input.invoice_id`, moving +/// `disputed_amount_minor` between the AR `ACTIVE` / `DISPUTED` sub-balances at +/// the SAME `(payer, invoice)` grain. `dr_status` is the `ar_status` on the +/// debit leg, `cr_status` on the credit leg — both AR, both the disputed amount, +/// so they net ZERO on `balance_minor` (AR-class-neutral) while the projector +/// routes the `DISPUTED` leg's signed delta into `disputed_minor`: +/// - opened ⇒ `(DR DISPUTED, CR ACTIVE)` ⇒ `+D` on `disputed_minor`; +/// - won / lost re-open ⇒ `(DR ACTIVE, CR DISPUTED)` ⇒ `−D` on `disputed_minor`. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `invoice_id` is absent (an AR reclass +/// has no receivable to move). +fn ar_reclass_lines( + input: &ChargebackInput, + dr_status: &str, + cr_status: &str, +) -> Result, DomainError> { + let invoice_id = require_invoice_id(input)?; + Ok(vec![ + ar_line(input, &invoice_id, Side::Debit, dr_status), + ar_line(input, &invoice_id, Side::Credit, cr_status), + ]) +} + +/// The `invoice_id` of the disputed `(payer, invoice)` AR grain — required by +/// every AR-reclass / write-off leg. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `invoice_id` is absent (an AR reclass / +/// write-off has no receivable to move). +fn require_invoice_id(input: &ChargebackInput) -> Result { + input.invoice_id.clone().ok_or_else(|| { + DomainError::InvalidRequest( + "AR_RECLASS chargeback requires an invoice_id (the disputed receivable)".to_owned(), + ) + }) +} + +/// One AR line for the disputed `(payer, invoice)` grain, sized at +/// `disputed_amount_minor`, carrying the entry-wide payer + currency + seller and +/// the given `ar_status`; only `side` + `ar_status` differ between legs. The +/// projector routes a `DISPUTED`-tagged line's signed amount (`+D` on a debit, +/// `−D` on a credit) onto `disputed_minor` in lockstep with `balance_minor`. +fn ar_line(input: &ChargebackInput, invoice_id: &str, side: Side, ar_status: &str) -> PostLine { + PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: input.payer_tenant_id, + seller_tenant_id: Some(input.tenant_id), + resource_tenant_id: None, + account_id: Uuid::nil(), + account_class: AccountClass::Ar, + gl_code: None, + side, + amount_minor: input.disputed_amount_minor, + currency: input.currency.clone(), + invoice_id: Some(invoice_id.to_owned()), + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: Some(ar_status.to_owned()), + } +} + +/// The `CASH_HOLD` `opened` legs: move the settled cash into the hold. +/// `DR DISPUTE_HOLD (net) / CR CASH_CLEARING (net)`. Sized at `net_minor` +/// (`CASH_CLEARING` only holds net — the PSP fee never entered it; Model N). No +/// invoice / AR. +fn opened_cash_hold_lines(input: &ChargebackInput, net_minor: i64) -> Vec { + // DR DISPUTE_HOLD (cash parked in the hold) + CR CASH_CLEARING (cash leaves + // clearing). Σ DR = net = Σ CR. + vec![ + cash_line(input, AccountClass::DisputeHold, Side::Debit, net_minor), + cash_line(input, AccountClass::CashClearing, Side::Credit, net_minor), + ] +} + +/// The `CASH_HOLD` `won` legs: release the hold back to clearing — the reverse +/// of the opened cash leg. `DR CASH_CLEARING (net) / CR DISPUTE_HOLD (net)`. +/// Sized at `net_minor` (the reverse of the net-sized opened leg; Model N). The +/// withheld net cash is the seller's again. No clawback. +fn won_cash_hold_lines(input: &ChargebackInput, net_minor: i64) -> Vec { + vec![ + cash_line(input, AccountClass::CashClearing, Side::Debit, net_minor), + cash_line(input, AccountClass::DisputeHold, Side::Credit, net_minor), + ] +} + +/// The `AR_RECLASS` `won` legs: reverse the opened reclass `DISPUTED → ACTIVE` +/// (`DR AR ACTIVE + CR AR DISPUTED`, each disputed). Nets ZERO on `balance_minor` +/// and `−D` on `disputed_minor` (the projector books the CR-side DISPUTED leg as +/// a net-down). No cash leg. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `invoice_id` is absent. +fn won_ar_reclass_lines(input: &ChargebackInput) -> Result, DomainError> { + // DR AR ACTIVE (restore the active portion) + CR AR DISPUTED (clear the + // disputed portion). The DISPUTED leg is now the CREDIT, so the projector + // routes `−D` onto `disputed_minor`. + ar_reclass_lines(input, AR_STATUS_ACTIVE, AR_STATUS_DISPUTED) +} + +/// The `CASH_HOLD` `lost` legs: the disputed cash was already withheld at +/// `opened` (moved `CASH_CLEARING → DISPUTE_HOLD` then, sized at net), so the +/// loss is recognised out of the hold — `DR DISPUTE_LOSS_EXPENSE (net) / +/// CR DISPUTE_HOLD (net)` (release the hold, book the forfeiture). Sized at +/// `net_minor` (the hold only ever held net; Model N). No `CASH_CLEARING` +/// movement: the cash left clearing at open, so there is no further cash leg and +/// no negative-cash path. The total loss is `net` (this leg) + the PSP fee +/// already expensed at settle = gross. The orchestrator bumps +/// `clawed_back_minor` by `net` (the held funds clawed back). +fn lost_cash_hold_lines(input: &ChargebackInput, net_minor: i64) -> Vec { + vec![ + cash_line( + input, + AccountClass::DisputeLossExpense, + Side::Debit, + net_minor, + ), + cash_line(input, AccountClass::DisputeHold, Side::Credit, net_minor), + ] +} + +/// The `AR_RECLASS` `lost` legs: a write-off. The receivable was never collected +/// (funds `not_moved`, NO PSP fee ⇒ gross = net = the receivable), so a lost +/// dispute writes it off to loss at `disputed_amount_minor`: +/// `DR DISPUTE_LOSS_EXPENSE (disputed) / CR AR (ar_status = DISPUTED) (disputed)`. +/// +/// The lone `CR AR DISPUTED` nets `−D` on BOTH `balance_minor` and +/// `disputed_minor` (the projector routes a DISPUTED-tagged line's signed amount +/// onto `disputed_minor` in lockstep with `balance_minor`), so the receivable AND +/// its disputed sub-balance are both cleared by this single AR leg — no extra +/// balance leg is needed. No cash moves (nothing was ever collected), so +/// `clawed_back_minor` is unchanged. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `invoice_id` is absent. +fn lost_ar_reclass_lines(input: &ChargebackInput) -> Result, DomainError> { + let invoice_id = require_invoice_id(input)?; + // DR DISPUTE_LOSS_EXPENSE (book the loss) + CR AR DISPUTED (write the disputed + // receivable off — clears `−D` on both balance_minor and disputed_minor). + // Σ DR = disputed = Σ CR. + Ok(vec![ + cash_line( + input, + AccountClass::DisputeLossExpense, + Side::Debit, + input.disputed_amount_minor, + ), + ar_line(input, &invoice_id, Side::Credit, AR_STATUS_DISPUTED), + ]) +} + +/// The `AR_RECLASS` `opened` legs: reclass the disputed receivable +/// `ACTIVE → DISPUTED` at the `(payer, invoice)` grain. Two AR lines for the +/// SAME invoice — `DR AR (ar_status = DISPUTED)` + `CR AR (ar_status = +/// ACTIVE)`, each `disputed` — net ZERO on `balance_minor` (AR-class-neutral) +/// while the Group-A projector routes the signed `DISPUTED` delta into +/// `disputed_minor` (+D). +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `invoice_id` is absent (an AR reclass +/// has no receivable to move). +fn opened_ar_reclass_lines(input: &ChargebackInput) -> Result, DomainError> { + // DR AR DISPUTED (the disputed portion) + CR AR ACTIVE (removed from the + // active portion). AR is debit-normal, so the DR raises and the CR lowers — + // together they net ZERO on the full open AR; the projector books the + // DISPUTED delta (the debit leg) into `disputed_minor` (+D). + ar_reclass_lines(input, AR_STATUS_DISPUTED, AR_STATUS_ACTIVE) +} + +/// Does a `lost` outcome on this input claw cash back out (so the orchestrator +/// must bump `clawed_back_minor`)? `true` (returning the clawed amount) only when +/// cash actually leaves on the post: +/// - `CASH_HOLD` `lost` ⇒ the held amount `min(disputed, net_minor)` (the +/// withheld net hold funds are forfeited; the PSP fee was already expensed at +/// settle, never held — so the claw caps at the payment's net, mirroring the +/// builder's `cash_hold_minor`); +/// - `AR_RECLASS` `lost` ⇒ `0` (a write-off — nothing was ever collected, so +/// there is no cash to claw back; see [`lost_ar_reclass_lines`]). +/// +/// Any non-`lost` phase claws nothing back. Mirrors the builder's branch so the +/// counter the sidecar bumps matches the cash the entry actually moved. +#[must_use] +pub fn clawed_back_on_post(input: &ChargebackInput, net_minor: i64) -> i64 { + match (input.phase, input.variant) { + (DisputePhase::Lost, DisputeVariant::CashHold) => { + net_minor.min(input.disputed_amount_minor) + } + _ => 0, + } +} + +#[cfg(test)] +#[path = "chargeback_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/payment/chargeback_tests.rs b/gears/bss/ledger/ledger/src/domain/payment/chargeback_tests.rs new file mode 100644 index 000000000..b2aedebd6 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/chargeback_tests.rs @@ -0,0 +1,451 @@ +use super::*; + +/// A fee-0 net: the cash leg equals the disputed (gross) amount — `net = gross` +/// when no PSP fee. The cash-hold tests size their legs at the `net` passed as the +/// builder's 2nd arg (Model N); with fee 0 that is the same 1000 as `disputed`. +const NET_NO_FEE: i64 = 1000; + +fn base(variant: DisputeVariant, phase: DisputePhase) -> ChargebackInput { + ChargebackInput { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + payment_id: "PAY-1".to_owned(), + dispute_id: "DSP-1".to_owned(), + cycle: 1, + phase, + variant, + disputed_amount_minor: 1000, + invoice_id: None, + currency: "USD".to_owned(), + effective_at: None, + } +} + +/// Σ of the debit-side line amounts (widened, mirroring the engine's i128). +fn sum_dr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +/// Σ of the credit-side line amounts. +fn sum_cr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +/// The single line of `class` + `side` (panics if not exactly one — guards a +/// test against a leg silently appearing/vanishing). +#[allow(clippy::panic)] // a test assertion helper — a missing leg should fail loud +fn line(entry: &PostEntry, class: AccountClass, side: Side) -> &PostLine { + let mut it = entry + .lines + .iter() + .filter(|l| l.account_class == class && l.side == side); + let found = it.next().unwrap_or_else(|| { + panic!("expected a {side:?} {class:?} line"); + }); + assert!( + it.next().is_none(), + "expected exactly one {side:?} {class:?} line" + ); + found +} + +#[test] +fn cash_hold_opened_moves_cash_into_hold() { + let inp = base(DisputeVariant::CashHold, DisputePhase::Opened); + // Model N: the cash legs are sized at the 2nd arg (`net`); fee 0 ⇒ net = 1000. + let entry = build_chargeback_entry(&inp, NET_NO_FEE).unwrap(); + + assert_eq!(entry.source_doc_type, SourceDocType::Chargeback); + // business id is the snake_case composite `dispute_id:cycle:phase`. + assert_eq!(entry.source_business_id, "DSP-1:1:OPENED"); + assert_eq!(entry.reverses_entry_id, None); + assert_eq!(entry.lines.len(), 2); + + // DR DISPUTE_HOLD (cash parked in the hold), sized at net. + let hold = &entry.lines[0]; + assert_eq!(hold.account_class, AccountClass::DisputeHold); + assert_eq!(hold.side, Side::Debit); + assert_eq!(hold.amount_minor, NET_NO_FEE); + assert_eq!(hold.invoice_id, None); + assert_eq!(hold.ar_status, None); + + // CR CASH_CLEARING (cash leaves clearing), sized at net. + let cash = &entry.lines[1]; + assert_eq!(cash.account_class, AccountClass::CashClearing); + assert_eq!(cash.side, Side::Credit); + assert_eq!(cash.amount_minor, NET_NO_FEE); + + // Balanced. + assert_eq!(sum_dr(&entry), i128::from(NET_NO_FEE)); + assert_eq!(sum_cr(&entry), i128::from(NET_NO_FEE)); + + // Every line carries the payer, currency, and seller. + for l in &entry.lines { + assert_eq!(l.payer_tenant_id, inp.payer_tenant_id); + assert_eq!(l.currency, "USD"); + assert_eq!(l.seller_tenant_id, Some(inp.tenant_id)); + } +} + +#[test] +fn ar_reclass_opened_reclasses_active_to_disputed() { + let mut inp = base(DisputeVariant::ArReclass, DisputePhase::Opened); + inp.invoice_id = Some("INV-7".to_owned()); + // AR-reclass ignores the 2nd arg (no PSP fee ⇒ gross = net = the receivable). + let entry = build_chargeback_entry(&inp, NET_NO_FEE).unwrap(); + + assert_eq!(entry.source_doc_type, SourceDocType::Chargeback); + assert_eq!(entry.source_business_id, "DSP-1:1:OPENED"); + assert_eq!(entry.lines.len(), 2); + + // DR AR DISPUTED (the disputed portion). + let disputed = &entry.lines[0]; + assert_eq!(disputed.account_class, AccountClass::Ar); + assert_eq!(disputed.side, Side::Debit); + assert_eq!(disputed.amount_minor, 1000); + assert_eq!(disputed.invoice_id.as_deref(), Some("INV-7")); + assert_eq!(disputed.ar_status.as_deref(), Some(AR_STATUS_DISPUTED)); + + // CR AR ACTIVE (removed from the active portion). + let active = &entry.lines[1]; + assert_eq!(active.account_class, AccountClass::Ar); + assert_eq!(active.side, Side::Credit); + assert_eq!(active.amount_minor, 1000); + assert_eq!(active.invoice_id.as_deref(), Some("INV-7")); + assert_eq!(active.ar_status.as_deref(), Some(AR_STATUS_ACTIVE)); + + // Both AR legs share the SAME (payer, invoice) grain and net ZERO on + // balance_minor (DR raises, CR lowers AR by the same amount). + assert_eq!(sum_dr(&entry), 1000); + assert_eq!(sum_cr(&entry), 1000); + for l in &entry.lines { + assert_eq!(l.payer_tenant_id, inp.payer_tenant_id); + assert_eq!(l.currency, "USD"); + assert_eq!(l.seller_tenant_id, Some(inp.tenant_id)); + assert_eq!(l.invoice_id.as_deref(), Some("INV-7")); + } +} + +#[test] +fn ar_reclass_opened_without_invoice_is_rejected() { + // invoice_id stays None — an AR reclass has no receivable to move. + let inp = base(DisputeVariant::ArReclass, DisputePhase::Opened); + let err = build_chargeback_entry(&inp, NET_NO_FEE).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +#[test] +fn zero_amount_is_rejected() { + let mut inp = base(DisputeVariant::CashHold, DisputePhase::Opened); + inp.disputed_amount_minor = 0; + let err = build_chargeback_entry(&inp, NET_NO_FEE).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +#[test] +fn negative_amount_is_rejected() { + let mut inp = base(DisputeVariant::CashHold, DisputePhase::Opened); + inp.disputed_amount_minor = -1; + let err = build_chargeback_entry(&inp, NET_NO_FEE).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +// ── won (both variants) ────────────────────────────────────────────────────── + +#[test] +fn cash_hold_won_releases_hold_to_clearing() { + let inp = base(DisputeVariant::CashHold, DisputePhase::Won); + // Model N: the released cash legs are sized at net; fee 0 ⇒ net = 1000. + let entry = build_chargeback_entry(&inp, NET_NO_FEE).unwrap(); + assert_eq!(entry.source_business_id, "DSP-1:1:WON"); + assert_eq!(entry.lines.len(), 2); + + // DR CASH_CLEARING (cash back to clearing) + CR DISPUTE_HOLD (release hold). + let cash = line(&entry, AccountClass::CashClearing, Side::Debit); + assert_eq!(cash.amount_minor, NET_NO_FEE); + let hold = line(&entry, AccountClass::DisputeHold, Side::Credit); + assert_eq!(hold.amount_minor, NET_NO_FEE); + // No AR, no loss leg. + assert!( + entry + .lines + .iter() + .all(|l| l.account_class != AccountClass::Ar) + ); + assert!( + entry + .lines + .iter() + .all(|l| l.account_class != AccountClass::DisputeLossExpense) + ); + assert_eq!(sum_dr(&entry), i128::from(NET_NO_FEE)); + assert_eq!(sum_cr(&entry), i128::from(NET_NO_FEE)); + // No cash clawed back on a won. + assert_eq!(clawed_back_on_post(&inp, NET_NO_FEE), 0); +} + +#[test] +fn ar_reclass_won_reclasses_disputed_to_active() { + let mut inp = base(DisputeVariant::ArReclass, DisputePhase::Won); + inp.invoice_id = Some("INV-7".to_owned()); + // AR-reclass ignores the 2nd arg. + let entry = build_chargeback_entry(&inp, NET_NO_FEE).unwrap(); + assert_eq!(entry.source_business_id, "DSP-1:1:WON"); + assert_eq!(entry.lines.len(), 2); + + // DR AR ACTIVE (restore) + CR AR DISPUTED (clear the disputed slice). The + // reverse of opened: the DISPUTED leg is now the CREDIT (−D on disputed_minor). + let active = line(&entry, AccountClass::Ar, Side::Debit); + assert_eq!(active.ar_status.as_deref(), Some(AR_STATUS_ACTIVE)); + assert_eq!(active.invoice_id.as_deref(), Some("INV-7")); + let disputed = line(&entry, AccountClass::Ar, Side::Credit); + assert_eq!(disputed.ar_status.as_deref(), Some(AR_STATUS_DISPUTED)); + assert_eq!(disputed.invoice_id.as_deref(), Some("INV-7")); + // Balanced, AR-class-neutral, no cash leg. + assert_eq!(sum_dr(&entry), i128::from(NET_NO_FEE)); + assert_eq!(sum_cr(&entry), i128::from(NET_NO_FEE)); + assert_eq!(clawed_back_on_post(&inp, NET_NO_FEE), 0); +} + +#[test] +fn ar_reclass_won_without_invoice_is_rejected() { + let inp = base(DisputeVariant::ArReclass, DisputePhase::Won); + let err = build_chargeback_entry(&inp, NET_NO_FEE).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +// ── lost (both variants) ───────────────────────────────────────────────────── + +#[test] +fn cash_hold_lost_forfeits_hold_as_loss() { + let inp = base(DisputeVariant::CashHold, DisputePhase::Lost); + // Model N: the forfeiture legs are sized at net; fee 0 ⇒ net = 1000. + let entry = build_chargeback_entry(&inp, NET_NO_FEE).unwrap(); + assert_eq!(entry.source_business_id, "DSP-1:1:LOST"); + assert_eq!(entry.lines.len(), 2); + + // DR DISPUTE_LOSS_EXPENSE (forfeit) + CR DISPUTE_HOLD (release hold). The + // withheld cash left CASH_CLEARING at open, so clearing is NOT touched here. + let loss = line(&entry, AccountClass::DisputeLossExpense, Side::Debit); + assert_eq!(loss.amount_minor, NET_NO_FEE); + let hold = line(&entry, AccountClass::DisputeHold, Side::Credit); + assert_eq!(hold.amount_minor, NET_NO_FEE); + assert!( + entry + .lines + .iter() + .all(|l| l.account_class != AccountClass::CashClearing), + "cash-hold lost must not touch CASH_CLEARING (funds left at open)" + ); + assert_eq!(sum_dr(&entry), i128::from(NET_NO_FEE)); + assert_eq!(sum_cr(&entry), i128::from(NET_NO_FEE)); + // The held (net) funds are clawed back; CASH_CLEARING is never touched. + assert_eq!(clawed_back_on_post(&inp, NET_NO_FEE), NET_NO_FEE); +} + +#[test] +fn cash_hold_legs_are_sized_at_net_not_gross() { + // The spec's worked example (Model N): a CASH_HOLD dispute over a payment + // settled at gross 100 with a PSP fee of 3 ⇒ CASH_CLEARING only ever held + // `net = 97`. The disputed (gross) claim is 100, but EVERY cash leg + // (opened/won/lost) is sized at the `net` the orchestrator threads in (97), + // NOT the gross — sizing at gross would underflow CASH_CLEARING by the fee. + const GROSS: i64 = 100; + const NET: i64 = 97; // 100 − 3 fee + + // opened: DR DISPUTE_HOLD 97 / CR CASH_CLEARING 97. + let mut opened = base(DisputeVariant::CashHold, DisputePhase::Opened); + opened.disputed_amount_minor = GROSS; + let entry = build_chargeback_entry(&opened, NET).unwrap(); + let hold = line(&entry, AccountClass::DisputeHold, Side::Debit); + let cash = line(&entry, AccountClass::CashClearing, Side::Credit); + assert_eq!( + hold.amount_minor, NET, + "opened DISPUTE_HOLD sized at net, not gross" + ); + assert_eq!( + cash.amount_minor, NET, + "opened CASH_CLEARING credit sized at net" + ); + assert_eq!(sum_dr(&entry), i128::from(NET)); + assert_eq!(sum_cr(&entry), i128::from(NET)); + + // won: DR CASH_CLEARING 97 / CR DISPUTE_HOLD 97 (the reverse, also net). + let mut won = base(DisputeVariant::CashHold, DisputePhase::Won); + won.disputed_amount_minor = GROSS; + let entry = build_chargeback_entry(&won, NET).unwrap(); + assert_eq!( + line(&entry, AccountClass::CashClearing, Side::Debit).amount_minor, + NET + ); + assert_eq!( + line(&entry, AccountClass::DisputeHold, Side::Credit).amount_minor, + NET + ); + assert_eq!(sum_dr(&entry), i128::from(NET)); + assert_eq!(sum_cr(&entry), i128::from(NET)); + assert_eq!( + clawed_back_on_post(&won, NET), + 0, + "a won claws nothing back" + ); + + // lost: DR DISPUTE_LOSS_EXPENSE 97 / CR DISPUTE_HOLD 97; clawed_back = net. + let mut lost = base(DisputeVariant::CashHold, DisputePhase::Lost); + lost.disputed_amount_minor = GROSS; + let entry = build_chargeback_entry(&lost, NET).unwrap(); + assert_eq!( + line(&entry, AccountClass::DisputeLossExpense, Side::Debit).amount_minor, + NET + ); + assert_eq!( + line(&entry, AccountClass::DisputeHold, Side::Credit).amount_minor, + NET + ); + assert!( + entry + .lines + .iter() + .all(|l| l.account_class != AccountClass::CashClearing), + "cash-hold lost posts no CASH_CLEARING leg" + ); + assert_eq!(sum_dr(&entry), i128::from(NET)); + assert_eq!(sum_cr(&entry), i128::from(NET)); + // The dispute-loss leg is `net` (97); the fee (3) was already expensed at + // settle, so the total loss is net + fee = gross (100). `clawed_back` bumps by + // net, not gross. + assert_eq!( + clawed_back_on_post(&lost, NET), + NET, + "(Lost, CashHold) claws back net (97), not gross" + ); +} + +#[test] +fn ar_reclass_lost_writes_receivable_off_to_loss() { + let mut inp = base(DisputeVariant::ArReclass, DisputePhase::Lost); + inp.invoice_id = Some("INV-7".to_owned()); + // AR-reclass = funds not_moved (invoice/ACH, NO PSP fee), so the 2nd arg is + // ignored. A lost dispute writes the receivable off to loss — no cash leg. + let entry = build_chargeback_entry(&inp, NET_NO_FEE).unwrap(); + assert_eq!(entry.source_business_id, "DSP-1:1:LOST"); + // Exactly two legs: book the loss + write the disputed receivable off. + assert_eq!(entry.lines.len(), 2); + + // DR DISPUTE_LOSS_EXPENSE at the disputed amount (the loss the seller eats). + let loss = line(&entry, AccountClass::DisputeLossExpense, Side::Debit); + assert_eq!(loss.amount_minor, 1000); + // CR AR with ar_status = DISPUTED at the disputed amount: a LONE credit AR + // line that nets −D on BOTH balance_minor and disputed_minor (the projector + // routes the signed DISPUTED delta onto both), so no extra balance leg. + let ar = line(&entry, AccountClass::Ar, Side::Credit); + assert_eq!(ar.ar_status.as_deref(), Some(AR_STATUS_DISPUTED)); + assert_eq!(ar.invoice_id.as_deref(), Some("INV-7")); + assert_eq!(ar.amount_minor, 1000); + + // NO CASH_CLEARING leg (nothing was ever collected to claw back). + assert!( + entry + .lines + .iter() + .all(|l| l.account_class != AccountClass::CashClearing), + "a write-off posts no CASH_CLEARING leg" + ); + // NO DR AR ACTIVE leg — the write-off does NOT re-open the receivable to + // active; the sole AR line is the lone CR DISPUTED above. + assert!( + entry + .lines + .iter() + .all(|l| !(l.account_class == AccountClass::Ar && l.side == Side::Debit)), + "a write-off posts no DR AR ACTIVE leg" + ); + + // Balanced; a write-off claws nothing back (no cash ever moved). + assert_eq!(sum_dr(&entry), 1000); + assert_eq!(sum_cr(&entry), 1000); + assert_eq!(clawed_back_on_post(&inp, NET_NO_FEE), 0); +} + +#[test] +fn ar_reclass_lost_without_invoice_is_rejected() { + let inp = base(DisputeVariant::ArReclass, DisputePhase::Lost); + let err = build_chargeback_entry(&inp, NET_NO_FEE).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +#[test] +fn partial_is_deferred_behind_a_flag() { + for variant in [DisputeVariant::CashHold, DisputeVariant::ArReclass] { + let inp = base(variant, DisputePhase::Partial); + let err = build_chargeback_entry(&inp, NET_NO_FEE).unwrap_err(); + assert!( + matches!(err, DomainError::InvalidDisputeTransition(_)), + "partial must be InvalidDisputeTransition, got {err:?}" + ); + } +} + +#[test] +fn business_id_uses_the_cycle_and_phase() { + let mut inp = base(DisputeVariant::CashHold, DisputePhase::Opened); + inp.cycle = 2; + assert_eq!(inp.business_id(), "DSP-1:2:OPENED"); +} + +#[test] +fn funds_at_open_selects_the_variant() { + assert_eq!(FundsAtOpen::Withheld.variant(), DisputeVariant::CashHold); + assert_eq!(FundsAtOpen::NotMoved.variant(), DisputeVariant::ArReclass); +} + +#[test] +fn enum_literals_round_trip() { + for v in [DisputeVariant::CashHold, DisputeVariant::ArReclass] { + assert_eq!(DisputeVariant::parse(v.as_str()), Some(v)); + } + for p in [ + DisputePhase::Opened, + DisputePhase::Won, + DisputePhase::Lost, + DisputePhase::Partial, + ] { + assert_eq!(DisputePhase::parse(p.as_str()), Some(p)); + } + for f in [FundsAtOpen::Withheld, FundsAtOpen::NotMoved] { + assert_eq!(FundsAtOpen::parse(f.as_str()), Some(f)); + } + assert_eq!(DisputeVariant::parse("NOPE"), None); + assert_eq!(DisputePhase::parse("NOPE"), None); + assert_eq!(FundsAtOpen::parse("nope"), None); +} + +#[test] +fn parse_is_case_insensitive() { + // Every wire literal is accepted in any case — the REST DTO documents the + // lowercase form, the stored/journal form is the canonical `as_str` case, and + // a client should not 400 over casing. + assert_eq!(DisputePhase::parse("opened"), Some(DisputePhase::Opened)); + assert_eq!(DisputePhase::parse("Won"), Some(DisputePhase::Won)); + assert_eq!(DisputePhase::parse("LoSt"), Some(DisputePhase::Lost)); + assert_eq!( + DisputeVariant::parse("cash_hold"), + Some(DisputeVariant::CashHold) + ); + assert_eq!( + DisputeVariant::parse("Ar_Reclass"), + Some(DisputeVariant::ArReclass) + ); + assert_eq!(FundsAtOpen::parse("WITHHELD"), Some(FundsAtOpen::Withheld)); + assert_eq!(FundsAtOpen::parse("Not_Moved"), Some(FundsAtOpen::NotMoved)); +} diff --git a/gears/bss/ledger/ledger/src/domain/payment/credit.rs b/gears/bss/ledger/ledger/src/domain/payment/credit.rs new file mode 100644 index 000000000..3c4c45970 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/credit.rs @@ -0,0 +1,503 @@ +//! Reusable-credit (wallet) money-shapes (architecture §5.2). Two balanced +//! [`PostEntry`] builders move money in and out of a tenant's reusable-credit +//! wallet, plus the pure planner + validator that decide the two sides of a +//! spend before it is built: +//! +//! - **Grant** ([`build_grant_entry`]) — parks unallocated pool cash into the +//! wallet. **DR `UNALLOCATED`** (amount) / **CR `REUSABLE_CREDIT`** (amount). The +//! credit line carries `credit_grant_event_type` (the wallet sub-grain bucket +//! the balance accrues to); the pool line does not. +//! - **Apply** ([`build_apply_entry`]) — spends the wallet against receivables. +//! **N×DR `REUSABLE_CREDIT`** (one per drawn-down sub-grain, each carrying its +//! `credit_grant_event_type`) / **M×CR `AR`** (one per receivable, each carrying +//! its `invoice_id`). `Σ DR == Σ CR` exactly. +//! +//! Both postings use `source_doc_type = CREDIT_APPLY` (there is no separate +//! `CREDIT_GRANT` doc type) and `source_business_id = credit_application_id`. +//! +//! **The two-sided event-type rule** (DB CHECK `chk_journal_line_credit_grant`): a +//! line carries `credit_grant_event_type = Some(..)` **iff** its class is +//! `REUSABLE_CREDIT`. So in the grant the CR `REUSABLE_CREDIT` line sets it and the +//! DR `UNALLOCATED` line leaves it `None`; in the apply every DR `REUSABLE_CREDIT` +//! line sets it and every CR `AR` line leaves it `None`. +//! +//! **The three caps and where each is enforced.** Two are *available*-balance caps +//! the orchestrator (Group C) checks against live ledger state — these builders +//! never see the balances, they validate only shape: +//! - `GrantExceedsUnallocated` — the grant amount must not exceed the payer's +//! available unallocated pool (orchestrator-side). +//! - `CreditExceedsWallet` — the spend must not exceed the available wallet. The +//! *shape* of this is enforced here by [`plan_wallet_debit`], which refuses to +//! fill more than the sub-grain availabilities it is handed (`Σ available < +//! amount` ⇒ `CreditExceedsWallet`); the orchestrator is what supplies the live +//! availabilities. +//! - `CreditExceedsOpenAr` — each spent target must name an open receivable and not +//! exceed its open balance; enforced here by [`validate_credit_targets`] against +//! the open candidate set the orchestrator supplies. +//! +//! Pure throughout (no infra / DB imports — dylint DE0301): sums fold through +//! `i128` to dodge an intermediate overflow. The emitted lines carry a placeholder +//! nil `account_id` (the `crate::infra` orchestrator binds the real chart row from +//! `(account_class, currency)` before posting) and placeholder header fields it +//! likewise overwrites (`period_id`, `posted_by_actor_id`, `correlation_id`, and +//! — when `effective_at` is `None` — `effective_at`). + +use bss_ledger_sdk::{AccountClass, MappingStatus, PostEntry, PostLine, Side, SourceDocType}; +use chrono::{DateTime, Utc}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::payment::precedence::{Allocated, Candidate}; + +/// A credit grant to post: how much pool cash to park into which wallet +/// sub-grain. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GrantInput { + /// The seller tenant whose ledger this posts into (`= entry.tenant_id`), and + /// the `seller_tenant_id` stamped on each line. + pub tenant_id: Uuid, + /// The tenant whose wallet is credited (the single payer of the entry). + pub payer_tenant_id: Uuid, + /// The `CREDIT_APPLY` idempotency business id (`source_business_id`). + pub credit_application_id: String, + /// ISO currency of the grant (every line shares it). + pub currency: String, + /// Amount to park into the wallet in minor units. Must be `> 0`. + pub amount_minor: i64, + /// The wallet sub-grain bucket the credit accrues to (carried on the + /// `REUSABLE_CREDIT` line). Must be non-empty. + pub credit_grant_event_type: String, + /// Grant instant. `None` ⇒ a placeholder effective date the orchestrator + /// overwrites before posting (see module docs). + pub effective_at: Option>, +} + +/// A wallet sub-grain available to spend. The caller supplies these in +/// oldest-grant-first order — [`plan_wallet_debit`] fills them verbatim in the +/// given order (it does NOT sort). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreditSubgrain { + /// The sub-grain bucket (matches a grant's `credit_grant_event_type`). + pub credit_grant_event_type: String, + /// Remaining available credit in this sub-grain, minor units. Non-positive + /// availabilities are skipped during the fill. + pub available_minor: i64, +} + +/// One per-sub-grain debit the apply will post: a `REUSABLE_CREDIT` draw-down of +/// `amount_minor` from the named sub-grain. Always `> 0` (the planner never emits +/// a zero debit). Consumed by [`build_apply_entry`]. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreditDebit { + /// The sub-grain this draw-down comes from (carried onto the DR + /// `REUSABLE_CREDIT` line's `credit_grant_event_type`). + pub credit_grant_event_type: String, + /// Amount drawn from this sub-grain in minor units (always `> 0`). + pub amount_minor: i64, +} + +/// Build the balanced credit-grant entry for `input`. +/// +/// Lines: DR `UNALLOCATED` (`amount`, `credit_grant_event_type = None`), CR +/// `REUSABLE_CREDIT` (`amount`, `credit_grant_event_type = +/// Some(input.credit_grant_event_type)`). `source_doc_type = CREDIT_APPLY`, +/// `source_business_id = credit_application_id`, `reverses_* = None`. Both lines +/// carry the payer, the currency, and `seller_tenant_id = Some(tenant_id)`; +/// `invoice_id` is `None` (a grant pays no receivable). `Σ DR (= amount) == Σ CR +/// (= amount)`. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `amount_minor <= 0` or +/// `credit_grant_event_type` is empty (an unrepresentable / unbucketed grant). +pub fn build_grant_entry(input: &GrantInput) -> Result { + if input.amount_minor <= 0 { + return Err(DomainError::InvalidRequest(format!( + "credit grant amount_minor must be > 0, got {}", + input.amount_minor + ))); + } + if input.credit_grant_event_type.is_empty() { + return Err(DomainError::InvalidRequest( + "credit grant credit_grant_event_type must not be empty".to_owned(), + )); + } + + // A nil account_id / Resolved status line carrying the entry-wide payer + + // currency; only the class / side / amount / event-type differ per line. + let line = |account_class: AccountClass, + side: Side, + amount_minor: i64, + credit_grant_event_type: Option| PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: input.payer_tenant_id, + seller_tenant_id: Some(input.tenant_id), + resource_tenant_id: None, + account_id: Uuid::nil(), + account_class, + gl_code: None, + side, + amount_minor, + currency: input.currency.clone(), + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type, + ar_status: None, + }; + + // DR UNALLOCATED (amount, no event-type) + CR REUSABLE_CREDIT (amount, + // carrying the sub-grain bucket — the two-sided event-type rule). Σ DR = + // amount = Σ CR. + let lines: Vec = vec![ + line( + AccountClass::Unallocated, + Side::Debit, + input.amount_minor, + None, + ), + line( + AccountClass::ReusableCredit, + Side::Credit, + input.amount_minor, + Some(input.credit_grant_event_type.clone()), + ), + ]; + + Ok(PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: input.tenant_id, + // Placeholder header fields the infra orchestrator overwrites before + // posting (period, actor/correlation, and a real effective date for the + // `None` case) — mirrors the nil account_id. + period_id: String::new(), + entry_currency: input.currency.clone(), + source_doc_type: SourceDocType::CreditApply, + source_business_id: input.credit_application_id.clone(), + effective_at: input + .effective_at + .unwrap_or(DateTime::UNIX_EPOCH) + .date_naive(), + posted_by_actor_id: Uuid::nil(), + correlation_id: Uuid::nil(), + reverses_entry_id: None, + reverses_period_id: None, + lines, + }) +} + +/// Plan a wallet spend of `amount_minor` across `subgrains` IN THE GIVEN ORDER +/// (the caller sorts oldest-grant-first; this never reorders). +/// +/// Walks `subgrains` in order, taking `give = min(remaining, available)` from +/// each, skipping any with `available_minor <= 0`, and stopping once `remaining` +/// hits zero. Returns the per-sub-grain debits in fill order, positive amounts +/// only (never a zero debit). The wallet-side cap is enforced here: the sub-grain +/// availabilities must cover the full amount. +/// +/// # Errors +/// [`DomainError::CreditExceedsWallet`] when `Σ available_minor < amount_minor` +/// (the wallet cannot cover the spend); [`DomainError::InvalidRequest`] when +/// `amount_minor <= 0` (an unrepresentable spend). +pub fn plan_wallet_debit( + subgrains: &[CreditSubgrain], + amount_minor: i64, +) -> Result, DomainError> { + if amount_minor <= 0 { + return Err(DomainError::InvalidRequest(format!( + "wallet debit amount_minor must be > 0, got {amount_minor}" + ))); + } + + // Total available across the sub-grains (the wallet cap). Widened to i128 + // while folding to dodge an intermediate overflow; non-positive availabilities + // contribute nothing to spendable capacity, so clamp them at 0 here exactly as + // the fill below skips them. + let available_total: i128 = subgrains + .iter() + .map(|s| i128::from(s.available_minor.max(0))) + .sum(); + if available_total < i128::from(amount_minor) { + return Err(DomainError::CreditExceedsWallet(format!( + "wallet debit {amount_minor} exceeds available {available_total}" + ))); + } + + // Fill in the given order: each sub-grain gives min(remaining, available), + // skipping non-positive availabilities, stopping at 0. The cap check above + // guarantees `remaining` reaches 0 before the list is exhausted. + let mut remaining = amount_minor; + let mut out: Vec = Vec::new(); + for sg in subgrains { + if remaining == 0 { + break; + } + // Nothing available ⇒ nothing to draw (and never a negative debit). + if sg.available_minor <= 0 { + continue; + } + let give = remaining.min(sg.available_minor); + if give > 0 { + out.push(CreditDebit { + credit_grant_event_type: sg.credit_grant_event_type.clone(), + amount_minor: give, + }); + remaining -= give; + } + } + Ok(out) +} + +/// Validate caller-named AR targets against the open candidate set — the +/// reusable-credit apply's receivable side. Mirrors +/// [`crate::domain::payment::allocation::validate_caller_split`] but with no lump +/// check (the wallet-side cap is [`plan_wallet_debit`], not a single lump) and a +/// `CreditExceedsOpenAr` error. +/// +/// Each target must name a present candidate with `open_minor > 0`, carry `0 < +/// amount_minor <= that candidate's open_minor`, and appear at most once. On +/// success the validated targets are returned in the caller's order (the order +/// the resulting CR AR lines are built in) — never reordered or coalesced. +/// +/// # Errors +/// [`DomainError::CreditExceedsOpenAr`] when any target names an unknown or closed +/// (`open_minor <= 0`) candidate, exceeds that candidate's open balance, is +/// non-positive, or repeats an invoice. +pub fn validate_credit_targets( + candidates: &[Candidate], + targets: &[Allocated], +) -> Result, DomainError> { + let mut seen: Vec<&str> = Vec::with_capacity(targets.len()); + for target in targets { + // Reject a duplicate invoice_id: two targets for the same receivable are + // ambiguous (which CR AR line wins?), so the apply path must not emit one. + if seen.contains(&target.invoice_id.as_str()) { + return Err(DomainError::CreditExceedsOpenAr(format!( + "duplicate invoice {} in credit targets", + target.invoice_id + ))); + } + seen.push(target.invoice_id.as_str()); + + // Each target must be representable and positive — a zero/negative + // application is meaningless. + if target.amount_minor <= 0 { + return Err(DomainError::CreditExceedsOpenAr(format!( + "credit target for invoice {} must be > 0, got {}", + target.invoice_id, target.amount_minor + ))); + } + + // The invoice must be a present, still-open candidate, and the target may + // not exceed its open balance (the per-invoice cap). + let candidate = candidates + .iter() + .find(|c| c.invoice_id == target.invoice_id) + .ok_or_else(|| { + DomainError::CreditExceedsOpenAr(format!( + "credit target names invoice {} which is not an open candidate", + target.invoice_id + )) + })?; + if candidate.open_minor <= 0 { + return Err(DomainError::CreditExceedsOpenAr(format!( + "credit target names invoice {} which is closed (open {})", + target.invoice_id, candidate.open_minor + ))); + } + if target.amount_minor > candidate.open_minor { + return Err(DomainError::CreditExceedsOpenAr(format!( + "credit target for invoice {} ({}) exceeds its open balance ({})", + target.invoice_id, target.amount_minor, candidate.open_minor + ))); + } + } + + Ok(targets.to_vec()) +} + +/// A reusable-credit application to post: the DR wallet side (from +/// [`plan_wallet_debit`]) and the CR receivable side (from +/// [`validate_credit_targets`]). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ApplyInput { + /// The seller tenant whose ledger this posts into (`= entry.tenant_id`), and + /// the `seller_tenant_id` stamped on each line. + pub tenant_id: Uuid, + /// The tenant whose wallet is spent and whose receivables are paid (the single + /// payer of the entry). + pub payer_tenant_id: Uuid, + /// The `CREDIT_APPLY` idempotency business id (`source_business_id`). + pub credit_application_id: String, + /// ISO currency of the application (every line shares it). + pub currency: String, + /// The DR side: per-sub-grain wallet draw-downs (from `plan_wallet_debit`). + /// Must be non-empty and every `amount_minor` must be `> 0`. + pub debits: Vec, + /// The CR side: per-invoice receivable shares (from `validate_credit_targets`). + /// Must be non-empty and every `amount_minor` must be `> 0`. + pub targets: Vec, + /// Application instant. `None` ⇒ a placeholder effective date the orchestrator + /// overwrites before posting (see module docs). + pub effective_at: Option>, +} + +/// Build the balanced reusable-credit apply entry for `input`. +/// +/// Lines: one DR `REUSABLE_CREDIT` per debit FIRST (in `debits` order, each +/// carrying `credit_grant_event_type = Some(debit.credit_grant_event_type)`, +/// `invoice_id = None`), then one CR `AR` per target (in `targets` order, each +/// carrying `invoice_id = Some(target.invoice_id)`, `credit_grant_event_type = +/// None` — the two-sided event-type rule). `source_doc_type = CREDIT_APPLY`, +/// `source_business_id = credit_application_id`, `reverses_* = None`. Every line +/// carries the payer, the currency, and `seller_tenant_id = Some(tenant_id)`. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `debits` or `targets` is empty, any +/// amount is `<= 0`, or `Σ debits != Σ targets`. The orchestrator guarantees the +/// two sides are equal (it sizes the debit plan to the targets); this is the +/// balance backstop that refuses to post an unbalanced entry. +pub fn build_apply_entry(input: &ApplyInput) -> Result { + if input.debits.is_empty() { + return Err(DomainError::InvalidRequest( + "credit apply has no debits".to_owned(), + )); + } + if input.targets.is_empty() { + return Err(DomainError::InvalidRequest( + "credit apply has no targets".to_owned(), + )); + } + for debit in &input.debits { + if debit.amount_minor <= 0 { + return Err(DomainError::InvalidRequest(format!( + "credit apply debit for sub-grain {} must be > 0, got {}", + debit.credit_grant_event_type, debit.amount_minor + ))); + } + } + for target in &input.targets { + if target.amount_minor <= 0 { + return Err(DomainError::InvalidRequest(format!( + "credit apply target for invoice {} must be > 0, got {}", + target.invoice_id, target.amount_minor + ))); + } + } + + // Σ DR (wallet draw-downs) must equal Σ CR (receivable shares). Both folds are + // widened to i128 to dodge an intermediate overflow before the comparison. + let debit_total: i128 = input + .debits + .iter() + .map(|d| i128::from(d.amount_minor)) + .sum(); + let credit_total: i128 = input + .targets + .iter() + .map(|t| i128::from(t.amount_minor)) + .sum(); + if debit_total != credit_total { + return Err(DomainError::InvalidRequest(format!( + "credit apply does not balance: Σ debits {debit_total} != Σ targets {credit_total}" + ))); + } + + // A nil account_id / Resolved status line carrying the entry-wide payer + + // currency; only the class / side / amount / invoice_id / event-type differ. + let line = |account_class: AccountClass, + side: Side, + amount_minor: i64, + invoice_id: Option, + credit_grant_event_type: Option| PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: input.payer_tenant_id, + seller_tenant_id: Some(input.tenant_id), + resource_tenant_id: None, + account_id: Uuid::nil(), + account_class, + gl_code: None, + side, + amount_minor, + currency: input.currency.clone(), + invoice_id, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type, + ar_status: None, + }; + + // N×DR REUSABLE_CREDIT (each carrying its sub-grain event-type, no invoice) + // first, then M×CR AR (each carrying its invoice_id, no event-type). Σ DR = Σ + // debits = Σ targets = Σ CR. + let mut lines: Vec = Vec::with_capacity(input.debits.len() + input.targets.len()); + for debit in &input.debits { + lines.push(line( + AccountClass::ReusableCredit, + Side::Debit, + debit.amount_minor, + None, + Some(debit.credit_grant_event_type.clone()), + )); + } + for target in &input.targets { + lines.push(line( + AccountClass::Ar, + Side::Credit, + target.amount_minor, + Some(target.invoice_id.clone()), + None, + )); + } + + Ok(PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: input.tenant_id, + // Placeholder header fields the infra orchestrator overwrites before + // posting (period, actor/correlation, and a real effective date for the + // `None` case) — mirrors the nil account_id. + period_id: String::new(), + entry_currency: input.currency.clone(), + source_doc_type: SourceDocType::CreditApply, + source_business_id: input.credit_application_id.clone(), + effective_at: input + .effective_at + .unwrap_or(DateTime::UNIX_EPOCH) + .date_naive(), + posted_by_actor_id: Uuid::nil(), + correlation_id: Uuid::nil(), + reverses_entry_id: None, + reverses_period_id: None, + lines, + }) +} + +#[cfg(test)] +#[path = "credit_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/payment/credit_tests.rs b/gears/bss/ledger/ledger/src/domain/payment/credit_tests.rs new file mode 100644 index 000000000..ece91c61e --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/credit_tests.rs @@ -0,0 +1,384 @@ +use super::*; + +// ── helpers ────────────────────────────────────────────────────────────── + +fn subgrain(event_type: &str, available: i64) -> CreditSubgrain { + CreditSubgrain { + credit_grant_event_type: event_type.to_owned(), + available_minor: available, + } +} + +fn debit(event_type: &str, amount: i64) -> CreditDebit { + CreditDebit { + credit_grant_event_type: event_type.to_owned(), + amount_minor: amount, + } +} + +fn target(id: &str, amount: i64) -> Allocated { + Allocated { + invoice_id: id.to_owned(), + amount_minor: amount, + } +} + +/// An open candidate with the given remaining balance. `original_posted_at` is +/// irrelevant to credit-target validation (no ordering), so it stays `None`. +fn candidate(id: &str, open: i64) -> Candidate { + Candidate { + invoice_id: id.to_owned(), + open_minor: open, + original_posted_at: None, + } +} + +fn grant_input(amount: i64, event_type: &str) -> GrantInput { + GrantInput { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + credit_application_id: "CREDIT-1".to_owned(), + currency: "USD".to_owned(), + amount_minor: amount, + credit_grant_event_type: event_type.to_owned(), + effective_at: None, + } +} + +fn apply_input(debits: Vec, targets: Vec) -> ApplyInput { + ApplyInput { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + credit_application_id: "CREDIT-1".to_owned(), + currency: "USD".to_owned(), + debits, + targets, + effective_at: None, + } +} + +fn sum_dr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +fn sum_cr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +// ── build_grant_entry ──────────────────────────────────────────────────── + +#[test] +fn grant_balances_dr_unallocated_cr_reusable_credit() { + let inp = grant_input(500, "promo"); + let entry = build_grant_entry(&inp).unwrap(); + + assert_eq!(entry.source_doc_type, SourceDocType::CreditApply); + assert_eq!(entry.source_business_id, "CREDIT-1"); + assert_eq!(entry.lines.len(), 2); + + // DR UNALLOCATED (amount), no event-type, no invoice. + let unalloc = &entry.lines[0]; + assert_eq!(unalloc.account_class, AccountClass::Unallocated); + assert_eq!(unalloc.side, Side::Debit); + assert_eq!(unalloc.amount_minor, 500); + assert_eq!(unalloc.credit_grant_event_type, None); + assert_eq!(unalloc.invoice_id, None); + + // CR REUSABLE_CREDIT (amount), carrying the sub-grain bucket. + let credit = &entry.lines[1]; + assert_eq!(credit.account_class, AccountClass::ReusableCredit); + assert_eq!(credit.side, Side::Credit); + assert_eq!(credit.amount_minor, 500); + assert_eq!( + credit.credit_grant_event_type, + Some("promo".to_owned()), + "the REUSABLE_CREDIT line carries the event-type" + ); + assert_eq!(credit.invoice_id, None); + + // Balanced: Σ DR (500) == Σ CR (500) == amount. + assert_eq!(sum_dr(&entry), 500); + assert_eq!(sum_cr(&entry), 500); + assert_eq!(sum_dr(&entry), sum_cr(&entry)); + + // Every line carries the payer, currency, and seller. + for l in &entry.lines { + assert_eq!(l.payer_tenant_id, inp.payer_tenant_id); + assert_eq!(l.currency, "USD"); + assert_eq!(l.seller_tenant_id, Some(inp.tenant_id)); + } +} + +#[test] +fn grant_non_positive_amount_is_rejected() { + let err = build_grant_entry(&grant_input(0, "promo")).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); + + let err = build_grant_entry(&grant_input(-5, "promo")).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); +} + +#[test] +fn grant_empty_event_type_is_rejected() { + let err = build_grant_entry(&grant_input(500, "")).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); +} + +// ── plan_wallet_debit ───────────────────────────────────────────────────── + +#[test] +fn plan_fills_oldest_first_across_two_subgrains() { + // 700 across [old:500, new:400] in order ⇒ 500 from old, 200 from new. + let subgrains = vec![subgrain("old", 500), subgrain("new", 400)]; + let out = plan_wallet_debit(&subgrains, 700).unwrap(); + assert_eq!(out, vec![debit("old", 500), debit("new", 200)]); +} + +#[test] +fn plan_exact_fit_single_grain() { + // Amount exactly matches the first sub-grain's availability ⇒ one debit, the + // rest untouched. + let subgrains = vec![subgrain("old", 300), subgrain("new", 400)]; + let out = plan_wallet_debit(&subgrains, 300).unwrap(); + assert_eq!(out, vec![debit("old", 300)]); +} + +#[test] +fn plan_spans_grains_in_order() { + // 250 across three grains ⇒ 100 + 100 + 50, in fill order, stopping at 0. + let subgrains = vec![subgrain("a", 100), subgrain("b", 100), subgrain("c", 100)]; + let out = plan_wallet_debit(&subgrains, 250).unwrap(); + assert_eq!(out, vec![debit("a", 100), debit("b", 100), debit("c", 50)]); +} + +#[test] +fn plan_overdraw_is_credit_exceeds_wallet() { + // Σ available (300) < amount (301) ⇒ wallet cannot cover the spend. + let subgrains = vec![subgrain("a", 100), subgrain("b", 200)]; + let err = plan_wallet_debit(&subgrains, 301).unwrap_err(); + assert!( + matches!(err, DomainError::CreditExceedsWallet(_)), + "{err:?}" + ); +} + +#[test] +fn plan_skips_zero_and_negative_availabilities() { + // Zero/negative sub-grains contribute nothing and are skipped in the fill; + // the 300 is drawn entirely from the two positive grains. + let subgrains = vec![ + subgrain("zero", 0), + subgrain("a", 200), + subgrain("neg", -50), + subgrain("b", 200), + ]; + let out = plan_wallet_debit(&subgrains, 300).unwrap(); + assert_eq!(out, vec![debit("a", 200), debit("b", 100)]); +} + +#[test] +fn plan_non_positive_amount_is_rejected() { + let subgrains = vec![subgrain("a", 500)]; + let err = plan_wallet_debit(&subgrains, 0).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); + + let err = plan_wallet_debit(&subgrains, -10).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); +} + +#[test] +fn plan_returns_positive_amounts_in_fill_order() { + // Every emitted debit is positive and they appear in the input order. + let subgrains = vec![subgrain("first", 50), subgrain("second", 50)]; + let out = plan_wallet_debit(&subgrains, 100).unwrap(); + assert_eq!(out.len(), 2); + assert!(out.iter().all(|d| d.amount_minor > 0)); + assert_eq!(out[0].credit_grant_event_type, "first"); + assert_eq!(out[1].credit_grant_event_type, "second"); +} + +// ── validate_credit_targets ──────────────────────────────────────────────── + +#[test] +fn targets_happy_preserves_order_and_amounts() { + let candidates = vec![candidate("A", 300), candidate("B", 800)]; + // Caller order (B before A) is preserved verbatim — never reordered to the + // candidate order; under-paying a candidate is allowed. + let targets = vec![target("B", 200), target("A", 250)]; + let out = validate_credit_targets(&candidates, &targets).unwrap(); + assert_eq!(out, targets, "validated targets returned in caller order"); +} + +#[test] +fn targets_full_open_is_allowed() { + // Exactly the open balance is at the boundary (`<=`), so it passes. There is + // no lump cap here (the wallet cap is plan_wallet_debit). + let candidates = vec![candidate("A", 300), candidate("B", 200)]; + let targets = vec![target("A", 300), target("B", 200)]; + assert!(validate_credit_targets(&candidates, &targets).is_ok()); +} + +#[test] +fn targets_over_open_is_credit_exceeds_open_ar() { + let candidates = vec![candidate("A", 300)]; + // 301 > A's open 300. + let err = validate_credit_targets(&candidates, &[target("A", 301)]).unwrap_err(); + assert!( + matches!(err, DomainError::CreditExceedsOpenAr(_)), + "{err:?}" + ); +} + +#[test] +fn targets_unknown_invoice_is_credit_exceeds_open_ar() { + let candidates = vec![candidate("A", 300)]; + let err = validate_credit_targets(&candidates, &[target("Z", 100)]).unwrap_err(); + assert!( + matches!(err, DomainError::CreditExceedsOpenAr(_)), + "{err:?}" + ); +} + +#[test] +fn targets_closed_candidate_is_credit_exceeds_open_ar() { + // A present but fully-paid (open 0) candidate is not a valid target. + let candidates = vec![candidate("A", 0)]; + let err = validate_credit_targets(&candidates, &[target("A", 1)]).unwrap_err(); + assert!( + matches!(err, DomainError::CreditExceedsOpenAr(_)), + "{err:?}" + ); +} + +#[test] +fn targets_duplicate_invoice_is_credit_exceeds_open_ar() { + let candidates = vec![candidate("A", 300)]; + let err = + validate_credit_targets(&candidates, &[target("A", 100), target("A", 50)]).unwrap_err(); + assert!( + matches!(err, DomainError::CreditExceedsOpenAr(_)), + "{err:?}" + ); +} + +#[test] +fn targets_non_positive_amount_is_credit_exceeds_open_ar() { + let candidates = vec![candidate("A", 300), candidate("B", 300)]; + let err = validate_credit_targets(&candidates, &[target("A", 0)]).unwrap_err(); + assert!( + matches!(err, DomainError::CreditExceedsOpenAr(_)), + "{err:?}" + ); + + let err = validate_credit_targets(&candidates, &[target("B", -5)]).unwrap_err(); + assert!( + matches!(err, DomainError::CreditExceedsOpenAr(_)), + "{err:?}" + ); +} + +// ── build_apply_entry ────────────────────────────────────────────────────── + +#[test] +fn apply_balances_dr_reusable_credit_cr_ar() { + // Two wallet draw-downs (200 + 100 = 300) paying two receivables (250 + 50 = + // 300). + let inp = apply_input( + vec![debit("old", 200), debit("new", 100)], + vec![target("INV-1", 250), target("INV-2", 50)], + ); + let entry = build_apply_entry(&inp).unwrap(); + + assert_eq!(entry.source_doc_type, SourceDocType::CreditApply); + assert_eq!(entry.source_business_id, "CREDIT-1"); + assert_eq!(entry.lines.len(), 4); + + // N×DR REUSABLE_CREDIT FIRST, in debits order, each carrying its event-type, + // no invoice. + let dr_old = &entry.lines[0]; + assert_eq!(dr_old.account_class, AccountClass::ReusableCredit); + assert_eq!(dr_old.side, Side::Debit); + assert_eq!(dr_old.amount_minor, 200); + assert_eq!(dr_old.credit_grant_event_type, Some("old".to_owned())); + assert_eq!(dr_old.invoice_id, None); + + let dr_new = &entry.lines[1]; + assert_eq!(dr_new.account_class, AccountClass::ReusableCredit); + assert_eq!(dr_new.side, Side::Debit); + assert_eq!(dr_new.amount_minor, 100); + assert_eq!(dr_new.credit_grant_event_type, Some("new".to_owned())); + assert_eq!(dr_new.invoice_id, None); + + // M×CR AR next, in targets order, each carrying its invoice_id, no event-type. + let cr_1 = &entry.lines[2]; + assert_eq!(cr_1.account_class, AccountClass::Ar); + assert_eq!(cr_1.side, Side::Credit); + assert_eq!(cr_1.amount_minor, 250); + assert_eq!(cr_1.invoice_id, Some("INV-1".to_owned())); + assert_eq!(cr_1.credit_grant_event_type, None); + + let cr_2 = &entry.lines[3]; + assert_eq!(cr_2.account_class, AccountClass::Ar); + assert_eq!(cr_2.side, Side::Credit); + assert_eq!(cr_2.amount_minor, 50); + assert_eq!(cr_2.invoice_id, Some("INV-2".to_owned())); + assert_eq!(cr_2.credit_grant_event_type, None); + + // Balanced: Σ DR (300) == Σ CR (300). + assert_eq!(sum_dr(&entry), 300); + assert_eq!(sum_cr(&entry), 300); + assert_eq!(sum_dr(&entry), sum_cr(&entry)); + + // Every line carries the payer, currency, and seller. + for l in &entry.lines { + assert_eq!(l.payer_tenant_id, inp.payer_tenant_id); + assert_eq!(l.currency, "USD"); + assert_eq!(l.seller_tenant_id, Some(inp.tenant_id)); + } +} + +#[test] +fn apply_unbalanced_sides_is_rejected() { + // Σ debits (300) != Σ targets (250) ⇒ the balance backstop rejects it. + let inp = apply_input(vec![debit("old", 300)], vec![target("INV-1", 250)]); + let err = build_apply_entry(&inp).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); +} + +#[test] +fn apply_empty_debits_is_rejected() { + let inp = apply_input(vec![], vec![target("INV-1", 100)]); + let err = build_apply_entry(&inp).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); +} + +#[test] +fn apply_empty_targets_is_rejected() { + let inp = apply_input(vec![debit("old", 100)], vec![]); + let err = build_apply_entry(&inp).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); +} + +#[test] +fn apply_non_positive_amount_is_rejected() { + // A non-positive debit. + let inp = apply_input(vec![debit("old", 0)], vec![target("INV-1", 0)]); + let err = build_apply_entry(&inp).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); + + // A non-positive target (debits sum positive so the empty/positive guards + // pass first; the target guard catches it). + let inp = apply_input(vec![debit("old", 100)], vec![target("INV-1", -5)]); + let err = build_apply_entry(&inp).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_)), "{err:?}"); +} diff --git a/gears/bss/ledger/ledger/src/domain/payment/precedence.rs b/gears/bss/ledger/ledger/src/domain/payment/precedence.rs new file mode 100644 index 000000000..1e69c1f2d --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/precedence.rs @@ -0,0 +1,208 @@ +//! Allocation precedence policies. Given a lump of cash and the open invoices it +//! may pay, a policy decides — purely, with no money math beyond `min` — how much +//! each invoice receives. +//! +//! Every policy is *sequential fill*, not proportional: order the candidates (an +//! optional `hint` jumps one to the front), give each `min(remaining, open)` until +//! the lump is exhausted. Any leftover (`lump - Σ given`) is implicit — it stays +//! in the unallocated pool and is NOT returned. Policies differ ONLY in the order +//! they walk the candidates; the fill itself is shared. Two orders exist today: +//! [`oldest_first`] (`original_posted_at` ascending) and [`highest_amount_first`] +//! (`open_minor` descending), both stable and total so the same inputs always +//! split identically. [`select_split`] dispatches on a [`PrecedenceStrategy`]. + +use std::cmp::Ordering; + +use chrono::{DateTime, Utc}; +use toolkit_macros::domain_model; + +/// The default precedence policy id stamped on an allocation when none is chosen. +/// Equals [`PrecedenceStrategy::OldestFirst`]'s [`policy_ref`](PrecedenceStrategy::policy_ref); +/// bump the underlying `.vN` if a fill order ever changes — the policy is part of +/// the allocation's audit trail. +pub const DEFAULT_PRECEDENCE_POLICY: &str = "oldest-first.v1"; + +/// Which order a lump walks its candidates in. Every strategy shares the same +/// sequential-fill body (see module docs) and differs ONLY in the sort key. The +/// chosen strategy's [`policy_ref`](Self::policy_ref) is stamped onto the +/// allocation's audit trail. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PrecedenceStrategy { + /// `original_posted_at` ascending (`None` last), ties by `invoice_id` — pay + /// the oldest receivable first. Policy id `oldest-first.v1`. + OldestFirst, + /// `open_minor` descending, ties by `invoice_id` — pay the largest open + /// balance first. Policy id `highest-amount-first.v1`. + HighestAmountFirst, +} + +impl PrecedenceStrategy { + /// The stable policy id stamped on an allocation's audit trail. Inverse of + /// [`parse`](Self::parse). + #[must_use] + pub fn policy_ref(self) -> &'static str { + match self { + Self::OldestFirst => "oldest-first.v1", + Self::HighestAmountFirst => "highest-amount-first.v1", + } + } + + /// Parse a policy id back into a strategy — the inverse of + /// [`policy_ref`](Self::policy_ref). Accepts exactly the ids it emits; + /// anything else (incl. an unknown `.vN`) is `None`. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "oldest-first.v1" => Some(Self::OldestFirst), + "highest-amount-first.v1" => Some(Self::HighestAmountFirst), + _ => None, + } + } +} + +/// One open invoice a lump may be applied to. `open_minor` is the remaining +/// receivable in minor units; `original_posted_at` drives the oldest-first order +/// (`None` ⇒ sorts last, treated as newest). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Candidate { + /// External invoice identity — carried onto the resulting [`Allocated`] and, + /// downstream, the `invoice_id` dim of the CR AR line. + pub invoice_id: String, + /// Remaining open receivable in the invoice's minor units. Candidates with + /// `open_minor <= 0` receive nothing (skipped during fill). + pub open_minor: i64, + /// When the invoice was originally posted — the oldest-first sort key. `None` + /// sorts after every `Some` (treated as the newest / last to be paid). + pub original_posted_at: Option>, +} + +/// One invoice's share of a lump: it receives `amount_minor` (always `> 0` — the +/// policy never emits a zero allocation). Consumed by +/// [`crate::domain::payment::allocation::build_allocation_entry`] as a split. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Allocated { + pub invoice_id: String, + pub amount_minor: i64, +} + +/// Split `lump_minor` across `candidates` using `strategy`'s order, returning only +/// the invoices that received a positive amount (in fill order). The dispatcher +/// over [`oldest_first`] / [`highest_amount_first`]; see those for the shared +/// fill/`hint`/leftover semantics. +#[must_use] +pub fn select_split( + candidates: &[Candidate], + lump_minor: i64, + hint: Option<&str>, + strategy: PrecedenceStrategy, +) -> Vec { + match strategy { + PrecedenceStrategy::OldestFirst => oldest_first(candidates, lump_minor, hint), + PrecedenceStrategy::HighestAmountFirst => { + highest_amount_first(candidates, lump_minor, hint) + } + } +} + +/// Split `lump_minor` across `candidates` oldest-first, returning only the +/// invoices that received a positive amount (in fill order). +/// +/// Order: `original_posted_at` ascending, `None` last, ties broken by +/// `invoice_id` ascending (a stable, total order). See [`fill`] for the shared +/// `hint` / fill / leftover semantics this delegates to. +#[must_use] +pub fn oldest_first( + candidates: &[Candidate], + lump_minor: i64, + hint: Option<&str>, +) -> Vec { + fill(candidates, lump_minor, hint, |a, b| { + // `None` (no posted_at) sorts AFTER any `Some` — treat as newest. Map to + // an `(is_none, posted_at)` tuple so `false < true` puts `Some` first, + // then break ties on `invoice_id`. + let key_a = (a.original_posted_at.is_none(), a.original_posted_at); + let key_b = (b.original_posted_at.is_none(), b.original_posted_at); + key_a + .cmp(&key_b) + .then_with(|| a.invoice_id.cmp(&b.invoice_id)) + }) +} + +/// Split `lump_minor` across `candidates` largest-open-balance first, returning +/// only the invoices that received a positive amount (in fill order). +/// +/// Order: `open_minor` descending, ties broken by `invoice_id` ascending (a +/// stable, total order). See [`fill`] for the shared `hint` / fill / leftover +/// semantics this delegates to. +#[must_use] +pub fn highest_amount_first( + candidates: &[Candidate], + lump_minor: i64, + hint: Option<&str>, +) -> Vec { + fill(candidates, lump_minor, hint, |a, b| { + // Largest open first (`b` vs `a` ⇒ descending), then `invoice_id` + // ascending so equal balances stay deterministic. + b.open_minor + .cmp(&a.open_minor) + .then_with(|| a.invoice_id.cmp(&b.invoice_id)) + }) +} + +/// Shared sequential-fill core. Orders `candidates` by `order` (a stable sort, so +/// the comparator alone defines precedence), applies the `hint`, then fills. +/// +/// `hint`: a hint that names a present candidate jumps to the FRONT; the rest keep +/// their `order`. A hint matching nothing is a no-op. +/// +/// Fill: walk the ordered list; each candidate gets `give = min(remaining, +/// open_minor)`; if `give > 0` it is pushed and `remaining -= give`. Candidates +/// with `open_minor <= 0` are skipped. Stops once `remaining == 0`; any leftover +/// lump is implicit (left for the pool) and not returned. Pure `i64` throughout — +/// no proportional / float math. +fn fill( + candidates: &[Candidate], + lump_minor: i64, + hint: Option<&str>, + order: impl Fn(&Candidate, &Candidate) -> Ordering, +) -> Vec { + // Order the candidates by reference (cheap: no clone of the open invoices). + // A stable sort, so `order` alone defines precedence. + let mut ordered: Vec<&Candidate> = candidates.iter().collect(); + ordered.sort_by(|a, b| order(a, b)); + + if let Some(hint_id) = hint + && let Some(pos) = ordered.iter().position(|c| c.invoice_id == hint_id) + { + let hinted = ordered.remove(pos); + ordered.insert(0, hinted); + } + + let mut remaining = lump_minor; + let mut out: Vec = Vec::new(); + for cand in ordered { + if remaining == 0 { + break; + } + // Nothing open ⇒ nothing to give (and never a negative allocation). + if cand.open_minor <= 0 { + continue; + } + let give = remaining.min(cand.open_minor); + if give > 0 { + out.push(Allocated { + invoice_id: cand.invoice_id.clone(), + amount_minor: give, + }); + remaining -= give; + } + } + out +} + +#[cfg(test)] +#[path = "precedence_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/payment/precedence_tests.rs b/gears/bss/ledger/ledger/src/domain/payment/precedence_tests.rs new file mode 100644 index 000000000..64b663f74 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/precedence_tests.rs @@ -0,0 +1,326 @@ +use super::*; + +fn ts(y: i32, m: u32, d: u32) -> DateTime { + chrono::NaiveDate::from_ymd_opt(y, m, d) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() +} + +fn cand(id: &str, open: i64, at: Option>) -> Candidate { + Candidate { + invoice_id: id.to_owned(), + open_minor: open, + original_posted_at: at, + } +} + +#[test] +fn orders_by_date_then_invoice_id_with_none_last() { + // C posted first, A second, B has no date ⇒ last. Lump fills all. + let cands = [ + cand("B", 100, None), + cand("A", 100, Some(ts(2026, 2, 1))), + cand("C", 100, Some(ts(2026, 1, 1))), + ]; + let out = oldest_first(&cands, 300, None); + let order: Vec<&str> = out.iter().map(|a| a.invoice_id.as_str()).collect(); + assert_eq!(order, ["C", "A", "B"]); +} + +#[test] +fn fills_min_of_remaining_and_open() { + // First invoice open 300, lump 500 ⇒ first gets 300, second gets 200. + let cands = [ + cand("A", 300, Some(ts(2026, 1, 1))), + cand("B", 999, Some(ts(2026, 2, 1))), + ]; + let out = oldest_first(&cands, 500, None); + assert_eq!(out.len(), 2); + assert_eq!( + out[0], + Allocated { + invoice_id: "A".to_owned(), + amount_minor: 300 + } + ); + assert_eq!( + out[1], + Allocated { + invoice_id: "B".to_owned(), + amount_minor: 200 + } + ); +} + +#[test] +fn lump_below_sum_open_stops_midway() { + // Lump 150: A (open 100) filled, B gets the remaining 50, C nothing. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 100, Some(ts(2026, 2, 1))), + cand("C", 100, Some(ts(2026, 3, 1))), + ]; + let out = oldest_first(&cands, 150, None); + assert_eq!(out.len(), 2); + assert_eq!( + out[0], + Allocated { + invoice_id: "A".to_owned(), + amount_minor: 100 + } + ); + assert_eq!( + out[1], + Allocated { + invoice_id: "B".to_owned(), + amount_minor: 50 + } + ); +} + +#[test] +fn lump_above_sum_open_fills_all_no_negative() { + // Lump 1000 > Σopen 300: every invoice filled to its open, leftover (700) + // is implicit (not returned), no negatives. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 200, Some(ts(2026, 2, 1))), + ]; + let out = oldest_first(&cands, 1000, None); + assert_eq!(out.len(), 2); + assert_eq!(out[0].amount_minor, 100); + assert_eq!(out[1].amount_minor, 200); + let total: i64 = out.iter().map(|a| a.amount_minor).sum(); + assert_eq!(total, 300); +} + +#[test] +fn hint_moves_candidate_to_front() { + // Without the hint order is A,B,C; hinting C pulls it first so it is paid + // before the older A/B. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 100, Some(ts(2026, 2, 1))), + cand("C", 100, Some(ts(2026, 3, 1))), + ]; + let out = oldest_first(&cands, 100, Some("C")); + assert_eq!(out.len(), 1); + assert_eq!(out[0].invoice_id, "C"); +} + +#[test] +fn hint_not_in_candidates_is_ignored() { + // Hint names nobody ⇒ plain oldest-first order, A first. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 100, Some(ts(2026, 2, 1))), + ]; + let out = oldest_first(&cands, 100, Some("ZZZ")); + assert_eq!(out.len(), 1); + assert_eq!(out[0].invoice_id, "A"); +} + +#[test] +fn ties_break_on_smaller_invoice_id_first() { + // Equal dates ⇒ invoice_id ascending: A before B. + let cands = [ + cand("B", 100, Some(ts(2026, 1, 1))), + cand("A", 100, Some(ts(2026, 1, 1))), + ]; + let out = oldest_first(&cands, 200, None); + let order: Vec<&str> = out.iter().map(|a| a.invoice_id.as_str()).collect(); + assert_eq!(order, ["A", "B"]); +} + +#[test] +fn zero_and_negative_open_are_skipped() { + // A open 0, B open -5 ⇒ both skipped; only C receives. + let cands = [ + cand("A", 0, Some(ts(2026, 1, 1))), + cand("B", -5, Some(ts(2026, 2, 1))), + cand("C", 100, Some(ts(2026, 3, 1))), + ]; + let out = oldest_first(&cands, 100, None); + assert_eq!(out.len(), 1); + assert_eq!(out[0].invoice_id, "C"); + assert_eq!(out[0].amount_minor, 100); +} + +#[test] +fn empty_candidates_yields_empty() { + let out = oldest_first(&[], 1000, None); + assert!(out.is_empty()); +} + +#[test] +fn highest_amount_orders_by_open_desc_then_invoice_id() { + // Open balances B=300, A=100, C=200 ⇒ B, C, A regardless of date. Lump fills + // all so order is observable. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 300, Some(ts(2026, 2, 1))), + cand("C", 200, Some(ts(2026, 3, 1))), + ]; + let out = highest_amount_first(&cands, 600, None); + let order: Vec<&str> = out.iter().map(|a| a.invoice_id.as_str()).collect(); + assert_eq!(order, ["B", "C", "A"]); +} + +#[test] +fn highest_amount_ties_break_on_smaller_invoice_id_first() { + // Equal open balances ⇒ invoice_id ascending: A before B. + let cands = [ + cand("B", 100, Some(ts(2026, 1, 1))), + cand("A", 100, Some(ts(2026, 2, 1))), + ]; + let out = highest_amount_first(&cands, 200, None); + let order: Vec<&str> = out.iter().map(|a| a.invoice_id.as_str()).collect(); + assert_eq!(order, ["A", "B"]); +} + +#[test] +fn highest_amount_hint_moves_candidate_to_front() { + // Without the hint order is B (largest), then A; hinting A pulls it first. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 300, Some(ts(2026, 2, 1))), + ]; + let out = highest_amount_first(&cands, 100, Some("A")); + assert_eq!(out.len(), 1); + assert_eq!(out[0].invoice_id, "A"); +} + +#[test] +fn highest_amount_hint_not_in_candidates_is_ignored() { + // Hint names nobody ⇒ plain highest-amount order, B (300) first. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 300, Some(ts(2026, 2, 1))), + ]; + let out = highest_amount_first(&cands, 100, Some("ZZZ")); + assert_eq!(out.len(), 1); + assert_eq!(out[0].invoice_id, "B"); +} + +#[test] +fn highest_amount_fill_and_leftover_match_oldest_total() { + // Same lump, same candidates ⇒ identical Σ given and identical leftover + // (not returned) regardless of strategy; only the per-invoice order differs. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 200, Some(ts(2026, 2, 1))), + ]; + let oldest = oldest_first(&cands, 1000, None); + let highest = highest_amount_first(&cands, 1000, None); + let sum = |v: &[Allocated]| v.iter().map(|a| a.amount_minor).sum::(); + assert_eq!(sum(&oldest), sum(&highest)); + assert_eq!(sum(&highest), 300); // leftover 700 is implicit, not returned +} + +#[test] +fn highest_amount_stops_at_remaining_zero() { + // Lump 350: B (open 300) filled, A gets the remaining 50, C nothing. + let cands = [ + cand("A", 200, Some(ts(2026, 1, 1))), + cand("B", 300, Some(ts(2026, 2, 1))), + cand("C", 100, Some(ts(2026, 3, 1))), + ]; + let out = highest_amount_first(&cands, 350, None); + assert_eq!(out.len(), 2); + assert_eq!( + out[0], + Allocated { + invoice_id: "B".to_owned(), + amount_minor: 300 + } + ); + assert_eq!( + out[1], + Allocated { + invoice_id: "A".to_owned(), + amount_minor: 50 + } + ); +} + +#[test] +fn highest_amount_zero_and_negative_open_are_skipped() { + // A open 0, B open -5 ⇒ both skipped; only C receives. + let cands = [ + cand("A", 0, Some(ts(2026, 1, 1))), + cand("B", -5, Some(ts(2026, 2, 1))), + cand("C", 100, Some(ts(2026, 3, 1))), + ]; + let out = highest_amount_first(&cands, 100, None); + assert_eq!(out.len(), 1); + assert_eq!(out[0].invoice_id, "C"); + assert_eq!(out[0].amount_minor, 100); +} + +#[test] +fn highest_amount_empty_candidates_yields_empty() { + let out = highest_amount_first(&[], 1000, None); + assert!(out.is_empty()); +} + +#[test] +fn select_split_dispatches_to_oldest_first() { + // Oldest A (Jan) before B (Feb) even though B has the larger balance. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 300, Some(ts(2026, 2, 1))), + ]; + let out = select_split(&cands, 100, None, PrecedenceStrategy::OldestFirst); + assert_eq!(out.len(), 1); + assert_eq!(out[0].invoice_id, "A"); +} + +#[test] +fn select_split_dispatches_to_highest_amount_first() { + // Largest B (300) before A (100) even though A is older. + let cands = [ + cand("A", 100, Some(ts(2026, 1, 1))), + cand("B", 300, Some(ts(2026, 2, 1))), + ]; + let out = select_split(&cands, 100, None, PrecedenceStrategy::HighestAmountFirst); + assert_eq!(out.len(), 1); + assert_eq!(out[0].invoice_id, "B"); +} + +#[test] +fn policy_ref_parse_round_trip() { + for strategy in [ + PrecedenceStrategy::OldestFirst, + PrecedenceStrategy::HighestAmountFirst, + ] { + assert_eq!( + PrecedenceStrategy::parse(strategy.policy_ref()), + Some(strategy) + ); + } + assert_eq!( + PrecedenceStrategy::OldestFirst.policy_ref(), + "oldest-first.v1" + ); + assert_eq!( + PrecedenceStrategy::HighestAmountFirst.policy_ref(), + "highest-amount-first.v1" + ); +} + +#[test] +fn parse_unknown_policy_is_none() { + assert_eq!(PrecedenceStrategy::parse("nope"), None); + assert_eq!(PrecedenceStrategy::parse(""), None); + assert_eq!(PrecedenceStrategy::parse("oldest-first.v2"), None); +} + +#[test] +fn default_policy_equals_oldest_first_policy_ref() { + assert_eq!( + DEFAULT_PRECEDENCE_POLICY, + PrecedenceStrategy::OldestFirst.policy_ref() + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/payment/settlement.rs b/gears/bss/ledger/ledger/src/domain/payment/settlement.rs new file mode 100644 index 000000000..da4c9bd71 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/settlement.rs @@ -0,0 +1,171 @@ +//! Settlement-entry builder (architecture §5.2, **Pattern A**). Turns a settled +//! payment into a balanced [`PostEntry`] that lands the cash in the unallocated +//! pool: +//! +//! - **DR `CASH_CLEARING`** — the net cash received (`gross - fee`). +//! - **DR `PSP_FEE_EXPENSE`** — the processor fee (`fee`); omitted when `fee == 0` +//! (no zero line). +//! - **CR `UNALLOCATED`** — the gross (`gross`); the whole receipt parks in the +//! pool, drained later by an allocation entry. +//! +//! `Σ DR (= net + fee = gross) == Σ CR (= gross)` exactly — pure `i64`, summed via +//! `i128` to dodge an intermediate overflow. The emitted lines carry a placeholder +//! nil `account_id` (the `crate::infra` orchestrator binds the real chart row from +//! `(account_class, currency)` before posting) and placeholder header fields it +//! likewise overwrites (`period_id`, `posted_by_actor_id`, `correlation_id`, and +//! — when `effective_at` is `None` — `effective_at`). + +use bss_ledger_sdk::{AccountClass, MappingStatus, PostEntry, PostLine, Side, SourceDocType}; +use chrono::{DateTime, Utc}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::error::DomainError; + +/// A settled payment to post (Pattern A input). `gross_minor` is the amount the +/// payer was charged; `fee_minor` is the processor's cut withheld from it. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SettlementInput { + /// The seller tenant whose ledger this posts into (`= entry.tenant_id`), and + /// the `seller_tenant_id` stamped on each line. + pub tenant_id: Uuid, + /// The tenant that paid (the single payer of the entry). + pub payer_tenant_id: Uuid, + /// External payment identity — the `PAYMENT_SETTLE` idempotency business id + /// (`source_business_id`). + pub payment_id: String, + /// Gross amount received in minor units (what the payer was charged). Must be + /// `> 0` and `> fee_minor` (the net parked in the pool must be positive). + pub gross_minor: i64, + /// Processor fee withheld in minor units. Must be `>= 0` and `< gross_minor`. + pub fee_minor: i64, + /// ISO currency of the payment (every line shares it). + pub currency: String, + /// Settlement instant. `None` ⇒ a placeholder effective date the orchestrator + /// overwrites before posting (see module docs). + pub effective_at: Option>, +} + +/// Build the balanced Pattern-A settlement entry for `input`. +/// +/// Lines: DR `CASH_CLEARING` (`gross - fee`), DR `PSP_FEE_EXPENSE` (`fee`, omitted +/// when zero), CR `UNALLOCATED` (`gross`). `source_doc_type = PAYMENT_SETTLE`, +/// `source_business_id = payment_id`, `reverses_* = None`. Every line carries the +/// payer, the currency, and `seller_tenant_id = Some(tenant_id)`; `invoice_id` is +/// `None` (the receipt is not yet tied to a receivable). +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `gross_minor <= 0`, `fee_minor < 0`, or +/// `fee_minor >= gross_minor` (a meaningless / unrepresentable settlement, or a +/// 100%-fee settlement that would park a zero net in the pool). +pub fn build_settlement_entry(input: &SettlementInput) -> Result { + // Reject a non-positive gross at the boundary with a precise `InvalidRequest`. + // A zero gross would emit only zero-amount lines (`DR CASH_CLEARING 0` / `CR + // UNALLOCATED 0`) which the engine rejects deep down as the misleading + // `AMOUNT_OUT_OF_RANGE`; catch the meaningless settlement here (there is + // nothing to park in the pool) where the wire code is clear. + if input.gross_minor <= 0 { + return Err(DomainError::InvalidRequest(format!( + "settlement gross_minor must be > 0, got {}", + input.gross_minor + ))); + } + if input.fee_minor < 0 { + return Err(DomainError::InvalidRequest(format!( + "settlement fee_minor must be >= 0, got {}", + input.fee_minor + ))); + } + // Reject `fee >= gross` (net <= 0): a 100%-fee settlement parks nothing in the + // pool — `net = 0` would emit a `DR CASH_CLEARING 0` line the engine rejects + // deep down as the misleading `AMOUNT_OUT_OF_RANGE`. Catch it here with a clear + // wire code (mirroring the `gross <= 0` guard): the net cash must be strictly + // positive for there to be a receipt to settle. This also keeps `net > 0` for + // every settled payment, so a later CASH_HOLD dispute always has a positive net + // to size its hold against. + if input.fee_minor >= input.gross_minor { + return Err(DomainError::InvalidRequest(format!( + "settlement fee_minor ({}) must be < gross_minor ({}) — net cash must be > 0", + input.fee_minor, input.gross_minor + ))); + } + + // Net cash = gross - fee. Both are non-negative and fee < gross (checked + // above), so the difference is a strictly positive i64 with no overflow. + let net_minor = input.gross_minor - input.fee_minor; + + // A nil account_id / Resolved status line carrying the entry-wide payer + + // currency; only the class / side / amount differ per line. + let line = |account_class: AccountClass, side: Side, amount_minor: i64| PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: input.payer_tenant_id, + seller_tenant_id: Some(input.tenant_id), + resource_tenant_id: None, + account_id: Uuid::nil(), + account_class, + gl_code: None, + side, + amount_minor, + currency: input.currency.clone(), + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }; + + // DR CASH_CLEARING (net) + optional DR PSP_FEE_EXPENSE (fee) + CR UNALLOCATED + // (gross). Σ DR = net + fee = gross = Σ CR. + let mut lines: Vec = Vec::with_capacity(3); + lines.push(line(AccountClass::CashClearing, Side::Debit, net_minor)); + if input.fee_minor > 0 { + // Omit the fee line entirely when there is no fee — never a zero line. + lines.push(line( + AccountClass::PspFeeExpense, + Side::Debit, + input.fee_minor, + )); + } + lines.push(line( + AccountClass::Unallocated, + Side::Credit, + input.gross_minor, + )); + + Ok(PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: input.tenant_id, + // Placeholder header fields the infra orchestrator overwrites before + // posting (it derives the period, stamps the actor/correlation, and fills + // a real effective date for the `None` case) — mirrors the nil account_id. + period_id: String::new(), + entry_currency: input.currency.clone(), + source_doc_type: SourceDocType::PaymentSettle, + source_business_id: input.payment_id.clone(), + effective_at: input + .effective_at + .unwrap_or(DateTime::UNIX_EPOCH) + .date_naive(), + posted_by_actor_id: Uuid::nil(), + correlation_id: Uuid::nil(), + reverses_entry_id: None, + reverses_period_id: None, + lines, + }) +} + +#[cfg(test)] +#[path = "settlement_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/payment/settlement_return.rs b/gears/bss/ledger/ledger/src/domain/payment/settlement_return.rs new file mode 100644 index 000000000..a18fd9cd3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/settlement_return.rs @@ -0,0 +1,194 @@ +//! Settlement-return entry builder (architecture §4.2). Reverses a settled +//! receipt SYMMETRICALLY (Model N, D1) — the mirror image of the settle entry, +//! which under Model N split the gross into NET cash + the PSP fee: +//! +//! - **DR `UNALLOCATED`** (`amount`) — remove the returned gross from the pool. +//! - **CR `CASH_CLEARING`** (`amount − fee_share`) — the NET cash leaves clearing +//! (clearing only ever held net; the fee never entered it). +//! - **CR `PSP_FEE_EXPENSE`** (`fee_share`) — reverse the proportional slice of the +//! fee that was expensed at settle. **Omitted entirely when `fee_share == 0`** +//! (never a zero line — matches how settle omits its fee leg). +//! +//! `Σ DR == Σ CR == amount_minor` exactly (`amount = (amount − fee_share) + +//! fee_share`). A full return (`amount = gross`, `fee_share = fee`) is the exact +//! mirror of settle. Mirrors the settlement builder's shape: each line carries a +//! placeholder nil `account_id` (the `crate::infra` orchestrator binds the real +//! chart row before posting) and placeholder header fields it likewise overwrites +//! (`period_id`, `posted_by_actor_id`, `correlation_id`, and — when `effective_at` +//! is `None` — `effective_at`). +//! +//! The builder stays PURE: it sizes the legs on the `fee_share` the orchestrator +//! hands it (computed proportionally against the CURRENT remaining balances so +//! repeated partial returns stay proportional) — it does NO IO and reads no +//! settlement row. +//! +//! Scope (Phase 4, Group D): the happy-path clawback-from-pool. One edge is +//! tracked as a follow-up (not built here): +//! - **over-allocated** — a return that would push `allocated_minor > +//! settled_minor` must route to the exception queue rather than auto-post. +//! +//! The §4.5 documented-loss-on-negative-cash substitution is GONE (Model N +//! removed it): the symmetric reverse no longer over-credits `CASH_CLEARING` by +//! the fee. A genuine pool/cash underflow stays on the engine's guarded +//! no-negative CHECK (reject), and the `settled_minor` cap CHECK rejects an +//! over-allocated return (surfaced as +//! [`DomainError::SettlementReturnOverAllocated`]) — neither auto-posts a wrong +//! entry. + +use bss_ledger_sdk::{AccountClass, MappingStatus, PostEntry, PostLine, Side, SourceDocType}; +use chrono::{DateTime, Utc}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::error::DomainError; + +/// A settlement to claw back (architecture §4.2 input). `amount_minor` is the +/// gross amount the PSP returned; it decrements the original payment's +/// `settled_minor` and leaves the pool. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SettlementReturnInput { + /// The seller tenant whose ledger this posts into (`= entry.tenant_id`), and + /// the `seller_tenant_id` stamped on each line. + pub tenant_id: Uuid, + /// The tenant that paid (the single payer of the entry). + pub payer_tenant_id: Uuid, + /// The original settled payment being clawed back — the `payment_settlement` + /// row whose `settled_minor` this return decrements. + pub payment_id: String, + /// External return identity — the `SETTLEMENT_RETURN` idempotency business id + /// (`source_business_id`). + pub psp_return_id: String, + /// Amount returned in minor units. Must be `> 0`. + pub amount_minor: i64, + /// ISO currency of the return (every line shares it). + pub currency: String, + /// Return instant. `None` ⇒ a placeholder effective date the orchestrator + /// overwrites before posting (see module docs). + pub effective_at: Option>, +} + +/// Build the balanced settlement-return entry for `input`, sized for a return of +/// `amount_minor` given the proportional `fee_share_minor` the orchestrator +/// computed against the current remaining balances (Model N, symmetric reverse). +/// +/// Lines: DR `UNALLOCATED` (`amount`), CR `CASH_CLEARING` (`amount − fee_share`), +/// and — only when `fee_share > 0` — CR `PSP_FEE_EXPENSE` (`fee_share`). +/// `Σ DR == Σ CR == amount`. A full return (`amount = gross`, `fee_share = fee`) +/// is the exact mirror of settle; `fee_share = 0` omits the fee leg (a 2-leg +/// entry identical to the pre-Model-N shape). +/// `source_doc_type = SETTLEMENT_RETURN`, `source_business_id = psp_return_id`, +/// `reverses_* = None` (this is a fresh compensating post, not an entry +/// reversal). Every line carries the payer, the currency, and +/// `seller_tenant_id = Some(tenant_id)`; `invoice_id` is `None`. +/// +/// The builder is PURE — it only sizes the legs on the numbers handed in; the +/// orchestrator reads the settlement and computes `fee_share` before calling. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] when `amount_minor <= 0` (a meaningless +/// return — there is nothing to claw back), or when `fee_share_minor` is out of +/// the `0 ..= amount_minor` range (a fee slice larger than the return, or +/// negative, can't be reversed — a defensive guard on the orchestrator's +/// arithmetic). +pub fn build_settlement_return_entry( + input: &SettlementReturnInput, + fee_share_minor: i64, +) -> Result { + // Reject a non-positive return at the boundary with a precise + // `InvalidRequest`: zero-amount lines would otherwise surface deep down as + // the misleading `AMOUNT_OUT_OF_RANGE`. + if input.amount_minor <= 0 { + return Err(DomainError::InvalidRequest(format!( + "settlement return amount_minor must be > 0, got {}", + input.amount_minor + ))); + } + // Defensive: the fee slice being reversed must fit within the return + // (`0 <= fee_share <= amount`). The orchestrator computes + // `fee_share = fee × amount / settled` with `fee <= settled` and + // `amount <= settled`, so this always holds; a breach is a programming + // error, surfaced as `InvalidRequest` rather than an unbalanced entry. + if fee_share_minor < 0 || fee_share_minor > input.amount_minor { + return Err(DomainError::InvalidRequest(format!( + "settlement return fee_share_minor must be in 0..={}, got {fee_share_minor}", + input.amount_minor + ))); + } + + // A nil account_id / Resolved status line carrying the entry-wide payer + + // currency; only the class / side / amount differ per line. + let line = |account_class: AccountClass, side: Side, amount_minor: i64| PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: input.payer_tenant_id, + seller_tenant_id: Some(input.tenant_id), + resource_tenant_id: None, + account_id: Uuid::nil(), + account_class, + gl_code: None, + side, + amount_minor, + currency: input.currency.clone(), + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }; + + // Symmetric reverse of settle (Model N): DR UNALLOCATED (claw the gross back + // from the pool) + CR CASH_CLEARING (the NET cash leaves) + CR + // PSP_FEE_EXPENSE (reverse the proportional fee slice). Omit the fee leg + // entirely when `fee_share == 0` — never a zero line. Σ DR = amount = + // (amount − fee_share) + fee_share = Σ CR. + let mut lines: Vec = vec![ + line(AccountClass::Unallocated, Side::Debit, input.amount_minor), + line( + AccountClass::CashClearing, + Side::Credit, + input.amount_minor - fee_share_minor, + ), + ]; + if fee_share_minor > 0 { + lines.push(line( + AccountClass::PspFeeExpense, + Side::Credit, + fee_share_minor, + )); + } + + Ok(PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: input.tenant_id, + // Placeholder header fields the infra orchestrator overwrites before + // posting (period, actor/correlation, real effective date for `None`). + period_id: String::new(), + entry_currency: input.currency.clone(), + source_doc_type: SourceDocType::SettlementReturn, + source_business_id: input.psp_return_id.clone(), + effective_at: input + .effective_at + .unwrap_or(DateTime::UNIX_EPOCH) + .date_naive(), + posted_by_actor_id: Uuid::nil(), + correlation_id: Uuid::nil(), + reverses_entry_id: None, + reverses_period_id: None, + lines, + }) +} + +#[cfg(test)] +#[path = "settlement_return_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/payment/settlement_return_tests.rs b/gears/bss/ledger/ledger/src/domain/payment/settlement_return_tests.rs new file mode 100644 index 000000000..b5fbcbdef --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/settlement_return_tests.rs @@ -0,0 +1,176 @@ +use super::*; + +fn input(amount: i64) -> SettlementReturnInput { + SettlementReturnInput { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + payment_id: "PAY-1".to_owned(), + psp_return_id: "RET-1".to_owned(), + amount_minor: amount, + currency: "USD".to_owned(), + effective_at: None, + } +} + +/// Σ of the debit-side line amounts (widened, mirroring the engine's i128). +fn sum_dr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +/// Σ of the credit-side line amounts. +fn sum_cr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +/// Find the single line of a given class (panics if absent / not unique). +fn line_of(entry: &PostEntry, class: AccountClass) -> &PostLine { + let mut it = entry.lines.iter().filter(|l| l.account_class == class); + let line = it.next().expect("line of class present"); + assert!(it.next().is_none(), "class {class:?} is not unique"); + line +} + +#[test] +fn fee_share_zero_omits_the_fee_leg() { + // fee_share = 0 ⇒ the pre-Model-N 2-leg shape: DR UNALLOCATED / CR + // CASH_CLEARING, both = amount, and NO PSP_FEE_EXPENSE line (never zero). + let inp = input(1000); + let entry = build_settlement_return_entry(&inp, 0).unwrap(); + + assert_eq!(entry.source_doc_type, SourceDocType::SettlementReturn); + // The business id is the PSP return id, NOT the original payment id. + assert_eq!(entry.source_business_id, "RET-1"); + assert_eq!(entry.reverses_entry_id, None); + assert_eq!(entry.lines.len(), 2, "no fee leg when fee_share == 0"); + assert!( + !entry + .lines + .iter() + .any(|l| l.account_class == AccountClass::PspFeeExpense), + "PSP_FEE_EXPENSE leg must be omitted entirely" + ); + + // DR UNALLOCATED (claw back from the pool). + let unalloc = line_of(&entry, AccountClass::Unallocated); + assert_eq!(unalloc.side, Side::Debit); + assert_eq!(unalloc.amount_minor, 1000); + + // CR CASH_CLEARING (full amount — no fee to peel off). + let cash = line_of(&entry, AccountClass::CashClearing); + assert_eq!(cash.side, Side::Credit); + assert_eq!(cash.amount_minor, 1000); + + // Balanced: Σ DR (1000) == Σ CR (1000). + assert_eq!(sum_dr(&entry), 1000); + assert_eq!(sum_cr(&entry), 1000); + + // Every line carries the payer, currency, and seller; none is tied to an + // invoice (a return touches the pool, not a receivable). + for l in &entry.lines { + assert_eq!(l.payer_tenant_id, inp.payer_tenant_id); + assert_eq!(l.currency, "USD"); + assert_eq!(l.seller_tenant_id, Some(inp.tenant_id)); + assert_eq!(l.invoice_id, None); + } +} + +#[test] +fn full_return_is_the_mirror_of_settle() { + // Full return of a fee-bearing payment (gross 100, fee 3): the symmetric + // reverse of settle — DR UNALLOCATED 100 · CR CASH_CLEARING 97 · CR + // PSP_FEE_EXPENSE 3 (the mirror of `DR CASH_CLEARING 97 · DR PSP_FEE_EXPENSE + // 3 · CR UNALLOCATED 100`). + let inp = input(100); + let entry = build_settlement_return_entry(&inp, 3).unwrap(); + assert_eq!(entry.lines.len(), 3, "3-leg symmetric reverse"); + + let unalloc = line_of(&entry, AccountClass::Unallocated); + assert_eq!((unalloc.side, unalloc.amount_minor), (Side::Debit, 100)); + + let cash = line_of(&entry, AccountClass::CashClearing); + assert_eq!( + (cash.side, cash.amount_minor), + (Side::Credit, 97), + "CASH_CLEARING reversed by the NET (amount − fee_share)" + ); + + let fee = line_of(&entry, AccountClass::PspFeeExpense); + assert_eq!( + (fee.side, fee.amount_minor), + (Side::Credit, 3), + "PSP_FEE_EXPENSE reversed by the fee_share" + ); + + // Balanced: Σ DR (100) == Σ CR (97 + 3 = 100). + assert_eq!(sum_dr(&entry), 100); + assert_eq!(sum_cr(&entry), 100); + assert_eq!(sum_dr(&entry), sum_cr(&entry)); +} + +#[test] +fn partial_return_peels_a_proportional_fee_slice() { + // A partial return with a non-trivial fee slice: amount 600, fee_share 18 ⇒ + // CASH_CLEARING reversed by 582, PSP_FEE_EXPENSE by 18; still balanced. + let inp = input(600); + let entry = build_settlement_return_entry(&inp, 18).unwrap(); + assert_eq!(entry.lines.len(), 3); + + assert_eq!(line_of(&entry, AccountClass::Unallocated).amount_minor, 600); + assert_eq!( + line_of(&entry, AccountClass::CashClearing).amount_minor, + 582, + "amount − fee_share" + ); + assert_eq!( + line_of(&entry, AccountClass::PspFeeExpense).amount_minor, + 18 + ); + assert_eq!(sum_dr(&entry), 600); + assert_eq!(sum_cr(&entry), 600); +} + +#[test] +fn zero_amount_is_rejected() { + let err = build_settlement_return_entry(&input(0), 0).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +#[test] +fn negative_amount_is_rejected() { + let err = build_settlement_return_entry(&input(-1), 0).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +#[test] +fn negative_fee_share_is_rejected() { + // fee_share < 0 is a defensive `InvalidRequest` (the orchestrator never + // produces it; a breach would otherwise unbalance the entry). + let err = build_settlement_return_entry(&input(1000), -1).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +#[test] +fn fee_share_exceeding_amount_is_rejected() { + // fee_share > amount can't be reversed (the CASH_CLEARING leg would go + // negative) ⇒ `InvalidRequest`. fee_share == amount is the boundary and is + // accepted (a degenerate all-fee return). + let err = build_settlement_return_entry(&input(1000), 1001).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); + + let ok = build_settlement_return_entry(&input(1000), 1000).unwrap(); + assert_eq!(ok.lines.len(), 3, "fee_share == amount is accepted"); + // CASH_CLEARING leg is zero-amount here, but it is a structural reverse leg + // (not a fee leg) so it is still emitted; the entry balances (DR 1000 = CR + // 0 + CR 1000). + assert_eq!(sum_dr(&ok), sum_cr(&ok)); +} diff --git a/gears/bss/ledger/ledger/src/domain/payment/settlement_tests.rs b/gears/bss/ledger/ledger/src/domain/payment/settlement_tests.rs new file mode 100644 index 000000000..b993973a6 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/payment/settlement_tests.rs @@ -0,0 +1,122 @@ +use super::*; + +fn input(gross: i64, fee: i64) -> SettlementInput { + SettlementInput { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + payment_id: "PAY-1".to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "USD".to_owned(), + effective_at: None, + } +} + +/// Σ of the debit-side line amounts (widened, mirroring the builder's i128). +fn sum_dr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +/// Σ of the credit-side line amounts. +fn sum_cr(entry: &PostEntry) -> i128 { + entry + .lines + .iter() + .filter(|l| l.side == Side::Credit) + .map(|l| i128::from(l.amount_minor)) + .sum() +} + +#[test] +fn three_lines_with_fee() { + let inp = input(1000, 30); + let entry = build_settlement_entry(&inp).unwrap(); + + assert_eq!(entry.source_doc_type, SourceDocType::PaymentSettle); + assert_eq!(entry.source_business_id, "PAY-1"); + assert_eq!(entry.lines.len(), 3); + + // DR CASH_CLEARING net (970). + let cash = &entry.lines[0]; + assert_eq!(cash.account_class, AccountClass::CashClearing); + assert_eq!(cash.side, Side::Debit); + assert_eq!(cash.amount_minor, 970); + + // DR PSP_FEE_EXPENSE fee (30). + let fee = &entry.lines[1]; + assert_eq!(fee.account_class, AccountClass::PspFeeExpense); + assert_eq!(fee.side, Side::Debit); + assert_eq!(fee.amount_minor, 30); + + // CR UNALLOCATED gross (1000). + let unalloc = &entry.lines[2]; + assert_eq!(unalloc.account_class, AccountClass::Unallocated); + assert_eq!(unalloc.side, Side::Credit); + assert_eq!(unalloc.amount_minor, 1000); + + // Balanced: Σ DR (1000) == Σ CR (1000). + assert_eq!(sum_dr(&entry), 1000); + assert_eq!(sum_cr(&entry), 1000); + assert_eq!(sum_dr(&entry), sum_cr(&entry)); + + // Every line carries the payer, currency, and seller. + for l in &entry.lines { + assert_eq!(l.payer_tenant_id, inp.payer_tenant_id); + assert_eq!(l.currency, "USD"); + assert_eq!(l.seller_tenant_id, Some(inp.tenant_id)); + assert_eq!(l.invoice_id, None); + } +} + +#[test] +fn two_lines_when_fee_zero() { + let inp = input(1000, 0); + let entry = build_settlement_entry(&inp).unwrap(); + + // No PSP_FEE line is emitted for a zero fee. + assert_eq!(entry.lines.len(), 2); + assert!( + entry + .lines + .iter() + .all(|l| l.account_class != AccountClass::PspFeeExpense) + ); + + let cash = &entry.lines[0]; + assert_eq!(cash.account_class, AccountClass::CashClearing); + assert_eq!(cash.side, Side::Debit); + assert_eq!(cash.amount_minor, 1000); + + let unalloc = &entry.lines[1]; + assert_eq!(unalloc.account_class, AccountClass::Unallocated); + assert_eq!(unalloc.side, Side::Credit); + assert_eq!(unalloc.amount_minor, 1000); + + assert_eq!(sum_dr(&entry), sum_cr(&entry)); +} + +#[test] +fn negative_fee_is_rejected() { + let err = build_settlement_entry(&input(1000, -1)).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +#[test] +fn fee_above_gross_is_rejected() { + let err = build_settlement_entry(&input(1000, 1001)).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} + +#[test] +fn fee_equal_to_gross_is_rejected() { + // A 100%-fee settlement (net = 0) parks nothing in the pool — rejected at the + // boundary with a clear InvalidRequest, not a misleading AMOUNT_OUT_OF_RANGE + // from a zero `DR CASH_CLEARING` line deep in the engine. + let err = build_settlement_entry(&input(1000, 1000)).unwrap_err(); + assert!(matches!(err, DomainError::InvalidRequest(_))); +} diff --git a/gears/bss/ledger/ledger/src/domain/period.rs b/gears/bss/ledger/ledger/src/domain/period.rs new file mode 100644 index 000000000..85ccaa7ed --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/period.rs @@ -0,0 +1,113 @@ +//! Pure period-id math: derive the current `period_id` from an instant and the +//! next `period_id` from a `YYYYMM` string. No `chrono-tz` (decision 1) — the +//! next period is plain integer arithmetic on `YYYYMM`. Free functions only (no +//! `#[domain_model]`), no infrastructure imports (DE0301). + +use chrono::{DateTime, NaiveDate, Utc}; + +/// Derive the `period_id` for a UTC instant as `"%Y%m"` (`YYYYMM`). +#[must_use] +pub fn period_id_for(now: DateTime) -> String { + now.format("%Y%m").to_string() +} + +/// Increment a `YYYYMM` `period_id` by one month (December rolls into the next +/// January). Returns `None` when the input is not a valid 6-character `YYYYMM` +/// (month in `1..=12`). Pure integer arithmetic — no chrono date math. +#[must_use] +pub fn next_period_id(period_id: &str) -> Option { + if period_id.len() != 6 { + return None; + } + let year: i32 = period_id.get(0..4)?.parse().ok()?; + let month: u32 = period_id.get(4..6)?.parse().ok()?; + if !(1..=12).contains(&month) { + return None; + } + let (next_year, next_month) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + Some(format!("{next_year:04}{next_month:02}")) +} + +/// Advance a `YYYYMM` `period_id` by `k` whole months (`k == 0` returns the +/// input unchanged after validation). Returns `None` when the input is not a +/// valid 6-character `YYYYMM`. Pure integer arithmetic on the month-of-epoch +/// (`year * 12 + (month - 1)`), so it stays consistent with +/// [`next_period_id`] (`period_id_plus(p, 1) == next_period_id(p)`) without a +/// chrono date — the recognition `ScheduleBuilder` uses it to lay out the N +/// consecutive fiscal periods of a straight-line schedule from its first +/// period. +#[must_use] +pub fn period_id_plus(period_id: &str, k: u32) -> Option { + if period_id.len() != 6 { + return None; + } + let year: i32 = period_id.get(0..4)?.parse().ok()?; + let month: u32 = period_id.get(4..6)?.parse().ok()?; + if !(1..=12).contains(&month) { + return None; + } + // Months since year 0 (0-based month), then add k and re-split. `i64` + // arithmetic so a large `k` (capped by the segment ceiling) can never + // overflow the intermediate. + let total = i64::from(year) * 12 + i64::from(month - 1) + i64::from(k); + let next_year = total.div_euclid(12); + let next_month = total.rem_euclid(12) + 1; + Some(format!("{next_year:04}{next_month:02}")) +} + +/// Decrement a `YYYYMM` `period_id` by one month (January rolls back into the +/// previous December). Returns `None` when the input is not a valid 6-character +/// `YYYYMM`. The revaluation job reverses the period immediately preceding the +/// current open one with this. +#[must_use] +pub fn previous_period_id(period_id: &str) -> Option { + if period_id.len() != 6 { + return None; + } + let year: i32 = period_id.get(0..4)?.parse().ok()?; + let month: u32 = period_id.get(4..6)?.parse().ok()?; + if !(1..=12).contains(&month) { + return None; + } + let (prev_year, prev_month) = if month == 1 { + (year - 1, 12) + } else { + (year, month - 1) + }; + Some(format!("{prev_year:04}{prev_month:02}")) +} + +/// The UTC instant at which `period_id` (`YYYYMM`) BEGINS — `YYYY-MM-01T00:00:00Z`. +/// Pure UTC month arithmetic (decision 1 — no `chrono-tz`); returns `None` when +/// the input is not a valid 6-character `YYYYMM`. +#[must_use] +pub fn period_start_utc(period_id: &str) -> Option> { + if period_id.len() != 6 { + return None; + } + let year: i32 = period_id.get(0..4)?.parse().ok()?; + let month: u32 = period_id.get(4..6)?.parse().ok()?; + if !(1..=12).contains(&month) { + return None; + } + let date = NaiveDate::from_ymd_opt(year, month, 1)?; + Some(date.and_hms_opt(0, 0, 0)?.and_utc()) +} + +/// The UTC instant at which `period_id` (`YYYYMM`) ENDS — the first instant of +/// the following month (`period_start_utc(next_period_id(period_id))`). The +/// unrealized-revaluation run uses this as the period-end `as_of` for the rate +/// resolve (the rate in effect at period close, design §4.5). Returns `None` +/// when the input is not a valid 6-character `YYYYMM`. +#[must_use] +pub fn period_end_utc(period_id: &str) -> Option> { + period_start_utc(&next_period_id(period_id)?) +} + +#[cfg(test)] +#[path = "period_tests.rs"] +mod period_tests; diff --git a/gears/bss/ledger/ledger/src/domain/period_tests.rs b/gears/bss/ledger/ledger/src/domain/period_tests.rs new file mode 100644 index 000000000..e4d8e2f40 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/period_tests.rs @@ -0,0 +1,109 @@ +//! Tests for the pure period-id math. + +use chrono::TimeZone; + +use super::*; + +#[test] +fn next_period_id_advances_within_year() { + assert_eq!(next_period_id("202606"), Some("202607".to_owned())); +} + +#[test] +fn next_period_id_rolls_december_into_next_january() { + assert_eq!(next_period_id("202612"), Some("202701".to_owned())); +} + +#[test] +fn next_period_id_rejects_short_input() { + assert_eq!(next_period_id("2026"), None); +} + +#[test] +fn next_period_id_rejects_out_of_range_month() { + assert_eq!(next_period_id("202613"), None); + assert_eq!(next_period_id("202600"), None); +} + +#[test] +fn period_id_for_formats_year_month() { + let now = Utc.with_ymd_and_hms(2026, 6, 19, 0, 0, 0).unwrap(); + assert_eq!(period_id_for(now), "202606"); +} + +#[test] +fn period_id_plus_zero_is_identity() { + assert_eq!(period_id_plus("202606", 0), Some("202606".to_owned())); +} + +#[test] +fn period_id_plus_one_matches_next_period_id() { + assert_eq!( + period_id_plus("202611", 1), + next_period_id("202611"), + "period_id_plus(_, 1) must agree with next_period_id" + ); + assert_eq!(period_id_plus("202611", 1), Some("202612".to_owned())); +} + +#[test] +fn period_id_plus_crosses_year_boundaries() { + // 2026-06 + 12 months → 2027-06 (full year). + assert_eq!(period_id_plus("202606", 12), Some("202706".to_owned())); + // 2026-11 + 3 → 2027-02 (rolls the year once). + assert_eq!(period_id_plus("202611", 3), Some("202702".to_owned())); + // 2026-12 + 1 → 2027-01 (December roll, matches next_period_id). + assert_eq!(period_id_plus("202612", 1), Some("202701".to_owned())); +} + +#[test] +fn period_id_plus_rejects_malformed_input() { + assert_eq!(period_id_plus("2026", 1), None); + assert_eq!(period_id_plus("202613", 1), None); + assert_eq!(period_id_plus("202600", 1), None); +} + +#[test] +fn previous_period_id_decrements_within_year() { + assert_eq!(previous_period_id("202606"), Some("202605".to_owned())); +} + +#[test] +fn previous_period_id_rolls_january_into_prior_december() { + assert_eq!(previous_period_id("202601"), Some("202512".to_owned())); +} + +#[test] +fn previous_period_id_is_inverse_of_next() { + assert_eq!( + previous_period_id(&next_period_id("202606").unwrap()), + Some("202606".to_owned()) + ); + assert_eq!(previous_period_id("2026"), None); + assert_eq!(previous_period_id("202600"), None); +} + +#[test] +fn period_start_utc_is_first_instant_of_month() { + assert_eq!( + period_start_utc("202606"), + Some(Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap()) + ); + assert_eq!(period_start_utc("2026"), None); + assert_eq!(period_start_utc("202613"), None); +} + +#[test] +fn period_end_utc_is_first_instant_of_next_month() { + // End of 2026-06 is the first instant of 2026-07. + assert_eq!( + period_end_utc("202606"), + Some(Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()) + ); + // December rolls into next January. + assert_eq!( + period_end_utc("202612"), + Some(Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap()) + ); + assert_eq!(period_end_utc("bad"), None); +} diff --git a/gears/bss/ledger/ledger/src/domain/ports.rs b/gears/bss/ledger/ledger/src/domain/ports.rs new file mode 100644 index 000000000..fdbd6456e --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/ports.rs @@ -0,0 +1,5 @@ +//! Outbound port traits — interfaces the domain calls. Adapters live under +//! `infra/` (e.g. `crate::infra::metrics` implements [`metrics::LedgerMetricsPort`]). + +pub mod metrics; +pub mod obligation_state; diff --git a/gears/bss/ledger/ledger/src/domain/ports/metrics.rs b/gears/bss/ledger/ledger/src/domain/ports/metrics.rs new file mode 100644 index 000000000..a24f7f20f --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/ports/metrics.rs @@ -0,0 +1,484 @@ +//! Typed metrics port for the BSS ledger invoice-posting domain. +//! +//! Mirrors the canonical RBAC pattern: label values come from a closed enum +//! (`as_str()` → `snake_case`) so metric cardinality is bounded at compile +//! time. The infra adapter (`crate::infra::metrics`) implements +//! [`LedgerMetricsPort`]; [`NoopLedgerMetrics`] is the safe default for unit +//! tests and any construction before an exporter is wired. + +use toolkit_macros::domain_model; +use uuid::Uuid; + +/// Outcome class of one invoice-post attempt (the `result` label on +/// `ledger_invoice_post_total`). +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostResult { + /// A fresh balanced entry was written. + Posted, + /// An idempotent re-post returned the prior entry (no new ledger effect). + Replayed, + /// The attempt was rejected before any ledger effect (validation / + /// invariant / authz). + Rejected, +} + +impl PostResult { + /// Stable `snake_case` label value for this outcome. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Posted => "posted", + Self::Replayed => "replayed", + Self::Rejected => "rejected", + } + } +} + +/// Which write flow drove a post attempt (the `flow` label on +/// `ledger_invoice_post_total` / `_duration_seconds`), so reversals and +/// corrections are not mis-counted as fresh invoice posts. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostFlow { + /// A fresh invoice post (`POST /journal-entries`). + InvoicePost, + /// A strict line-negation reversal. + Reversal, + /// A mapping-correction re-post. + MappingCorrection, + /// A payment settlement post (`POST …/payments:settle`). + Settle, + /// A payment allocation post (`POST …/payments/{id}:allocate`). + Allocate, + /// A reusable-credit grant/apply post (`POST …/credit:grant|:apply`). + CreditApply, + /// A settlement-return post (`POST …/payments/{id}/returns`). + SettlementReturn, + /// A chargeback dispute-phase post (`POST …/disputes/{id}/phases`). + Chargeback, +} + +impl PostFlow { + /// Stable `snake_case` label value for this flow. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::InvoicePost => "invoice_post", + Self::Reversal => "reversal", + Self::MappingCorrection => "mapping_correction", + Self::Settle => "settle", + Self::Allocate => "allocate", + Self::CreditApply => "credit_apply", + Self::SettlementReturn => "settlement_return", + Self::Chargeback => "chargeback", + } + } +} + +/// Outcome class of one credit-note / debit-note attempt (the `outcome` label on +/// `ledger_credit_note_total` / `ledger_debit_note_total`, Slice 3 §4.2 / §4.3 / +/// Group F). A closed enum so the label cardinality is bounded at compile time +/// (mirrors [`PostResult`]). The `blocked_*` variants name WHY a note was +/// rejected before any books effect (the credit-note split / headroom gates); a +/// debit note only ever reports `Posted` / `Replayed` / `Rejected` (it has no +/// split-ambiguous or headroom-cap rejection — it raises the headroom). +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NoteOutcome { + /// A fresh note posted (balanced compensating / charge entry written). + Posted, + /// An idempotent re-post returned the prior entry (no new ledger effect). + Replayed, + /// Rejected before any ledger effect for a reason with no dedicated label + /// (shape / payer-closed / account-closed / not-found / infra). + Rejected, + /// A credit note blocked because its recognized-vs-deferred split was + /// indeterminable (`CREDIT_NOTE_SPLIT_AMBIGUOUS`, block-on-ambiguous). + BlockedSplit, + /// A credit note blocked because it would exceed the invoice's remaining + /// headroom (`CREDIT_NOTE_EXCEEDS_HEADROOM`). + BlockedHeadroom, +} + +impl NoteOutcome { + /// Stable `snake_case` label value for this outcome. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Posted => "posted", + Self::Replayed => "replayed", + Self::Rejected => "rejected", + Self::BlockedSplit => "blocked_split", + Self::BlockedHeadroom => "blocked_headroom", + } + } +} + +/// Metrics sink for the invoice-posting domain. Implemented by the infra `OTel` +/// adapter; [`NoopLedgerMetrics`] is the default for tests / pre-init. +pub trait LedgerMetricsPort: Send + Sync + 'static { + /// Increment the invoice-post counter for one post attempt, labelled by + /// outcome + flow (`ledger_invoice_post_total{result,flow}`). + fn invoice_post(&self, result: PostResult, flow: PostFlow); + /// Record one end-to-end post latency sample, labelled by flow + /// (`ledger_invoice_post_duration_seconds{flow}`). + fn invoice_post_duration(&self, secs: f64, flow: PostFlow); + /// Increment the payment-settle counter for one settle attempt, labelled by + /// outcome (`ledger_payment_settle_total{result}`). + fn payment_settle(&self, result: PostResult); + /// Increment the settlement-return counter for one return attempt, labelled + /// by outcome (`ledger_settlement_return_total{result}`). + fn settlement_return(&self, result: PostResult); + /// Increment the chargeback counter for one dispute-phase attempt, labelled + /// by outcome (`ledger_chargeback_total{result}`). + fn chargeback(&self, result: PostResult); + /// Increment the allocation counter for one allocate attempt, labelled by + /// outcome (`ledger_allocation_total{result}`). + fn allocation(&self, result: PostResult); + /// Record the current deferred-apply queue depth for the `PAYMENT_ALLOCATE` + /// flow (`ledger_allocation_queue_depth`) — the count of rows still `QUEUED`, + /// observed by the sweep job each tick. A gauge: it reflects the live backlog + /// (rises as unsettled allocations queue, falls as the drain applies them). + fn allocation_queue_depth(&self, depth: i64); + /// Increment the reusable-credit counter for one grant/apply attempt, + /// labelled by outcome (`ledger_credit_application_total{result}`). + fn credit_application(&self, result: PostResult); + /// Record one end-to-end payment-post latency sample, labelled by flow + /// (`ledger_payment_post_duration_seconds{flow}`). + fn payment_post_duration(&self, secs: f64, flow: PostFlow); + /// Record the live suspense backlog for a tenant: the pending-line count + /// (`ledger_suspense_pending_lines{tenant}`) and the age of the oldest + /// pending line in seconds (`ledger_suspense_pending_age_seconds{tenant}`). + fn suspense_pending(&self, tenant: Uuid, lines: i64, oldest_age_secs: f64); + /// Increment the §9 catalog-wide alarm rollup + /// (`ledger_alarm_total{category,severity}`). Mirrors the durable alarm event + /// so the alarm is observable even when the broker is absent; the §4.7 + /// tamper-failure count is this counter filtered to + /// `category="TAMPER_VERIFY_FAILED"`. + fn invariant_alarm(&self, category: &str, severity: &str); + /// Record one recognition-run pass latency sample + /// (`ledger_recognition_run_duration_seconds`) — the wall time one + /// `RecognitionRunService::trigger` pass (release the period's due segments) + /// took (design §9). Unlabelled: a run covers one `(tenant, period)`. + fn recognition_run_duration(&self, secs: f64); + /// Increment the recognized-revenue counter by `amount_minor` for one + /// released segment, labelled by `revenue_stream` + /// (`ledger_revenue_recognized_minor{stream}`, design §9) — the minor-unit + /// amount moved `CONTRACT_LIABILITY → REVENUE` this release. The stream label + /// is the schedule's revenue stream (bounded cardinality — a closed set per + /// deployment). + fn revenue_recognized_minor(&self, amount_minor: i64, revenue_stream: &str); + /// Increment the over-recognition counter + /// (`ledger_over_recognition_total`, design §9) — one release rejected by the + /// per-schedule `recognized_minor <= total_deferred_minor` cap CHECK. Paired + /// with the `OVER_RECOGNITION` invariant alarm. + fn over_recognition(&self); + /// Increment the recognition-double-credit counter + /// (`ledger_recognition_double_credit_total`, design §9) — one detected + /// attempt to re-credit an already-released segment (the at-most-once stamp + /// guard tripped). Paired with the `RECOGNITION_DOUBLE_CREDIT` alarm. + fn recognition_double_credit(&self); + /// Record the current recognition-period queue depth + /// (`ledger_recognition_period_queue_depth`, design §9) — the count of + /// segments parked `QUEUED` (a predecessor period not yet `DONE`) observed by + /// a run pass. A gauge: rises as out-of-order segments park, falls as later + /// runs drain them. + fn recognition_period_queue_depth(&self, depth: i64); + /// Increment the dual-control pending-created counter, labelled by `kind` + /// (`ledger_dual_control_pending_total{kind}`) — an over-threshold mutation + /// routed to the preparer→approver queue (VHP-1852). + fn dual_control_pending(&self, kind: &str); + /// Increment the dual-control decision counter, labelled by `kind` + + /// `decision` (approved / rejected / `needs_rework` / cancelled / expired) + /// (`ledger_dual_control_decided_total{kind,decision}`). + fn dual_control_decided(&self, kind: &str, decision: &str); + /// Increment the self-approval-denied counter, labelled by `kind` + /// (`ledger_dual_control_self_approval_denied_total{kind}`) — a + /// `preparer == approver` attempt (a fraud / mis-config signal). + fn dual_control_self_approval_denied(&self, kind: &str); + /// Record the count of approvals currently in the transient `APPROVING` latch + /// (`ledger_dual_control_approving`, Z8-1). A healthy approve clears the latch + /// within one txn; a value that stays `> 0` across maintenance ticks is a + /// crash-stranded approve (excluded from the TTL sweep, still holding the + /// active-uniqueness slot) that needs a manual re-approve. Recorded by the + /// dual-control sweep on its maintenance cadence. + fn dual_control_approving(&self, count: i64); + + // ── §9 feature metrics ─────────────────────────────────────────────────── + /// Record one chain-Verifier run for a tenant (§9): increments + /// `ledger_tamper_verify_runs_total` always, and + /// `ledger_tamper_verify_failures_total` when `failed` (the walk found a + /// break). `failures/runs` is the break rate. + fn tamper_verify_run(&self, tenant: Uuid, failed: bool); + /// Set the observed tamper-chain length for a tenant (§9): + /// `ledger_tamper_chain_length{tenant}` (gauge). + fn chain_length(&self, tenant: Uuid, length: i64); + /// Set the count of active scope freezes for a tenant (§9): + /// `ledger_scope_freeze_active{tenant}` (gauge). + fn scope_freeze_active(&self, tenant: Uuid, active: i64); + /// Increment the cross-tenant audit-access counter (§9): + /// `ledger_cross_tenant_access_total{reason_code}`. `reason_code` is a + /// bounded forensic token (mirrors `invariant_alarm`'s `&str` params). + fn cross_tenant_access(&self, reason_code: &str); + /// Increment the forensic re-identification counter (§9): + /// `ledger_reidentification_total`. + fn reidentification(&self); + /// Increment the GDPR right-to-erasure counter (§9): + /// `ledger_erasure_applied_total`. + fn erasure_applied(&self); + /// Increment the controlled-metadata-change counter (§9): + /// `ledger_metadata_change_total{attribute}`. `attribute` is a closed + /// allow-list token (mirrors `invariant_alarm`'s `&str` params). + fn metadata_change(&self, attribute: &str); + /// Record one audit-pack CSV export latency sample in seconds (§9): + /// `ledger_audit_pack_export_duration_seconds` (histogram). + fn audit_pack_export_duration(&self, secs: f64); + /// Increment the credit-note counter for one post attempt, labelled by + /// outcome (`ledger_credit_note_total{outcome}`, Slice 3 §4.2 / Group F) — + /// `posted` / `replayed` on success, or `blocked_split` / `blocked_headroom` + /// / `rejected` on a rejection. + fn credit_note(&self, outcome: NoteOutcome); + /// Increment the debit-note counter for one post attempt, labelled by outcome + /// (`ledger_debit_note_total{outcome}`, Slice 3 §4.3 / Group F) — `posted` / + /// `replayed` on success, or `rejected` on a rejection (a debit note has no + /// split / headroom block). + fn debit_note(&self, outcome: NoteOutcome); + /// Increment the refund counter for one successfully-POSTED refund phase, + /// labelled by `phase` + `pattern` (`ledger_refund_total{phase,pattern}`, + /// Slice 3 §4.4 / §9 / Group G). One increment per FRESH refund post (never on + /// replay) — every advanced phase (`initiated` / `confirmed` / `rejected` / + /// `voided` / `unknown_final`). `phase` is the [`RefundPhase`] wire literal; + /// `pattern` is the [`RefundPattern`] wire literal (`A_UNALLOCATED` / + /// `B_RESTORE_AR`). Bounded cardinality (5 × 2). + /// + /// [`RefundPhase`]: crate::domain::adjustment::refund::RefundPhase + /// [`RefundPattern`]: crate::domain::adjustment::refund::RefundPattern + fn refund(&self, phase: &str, pattern: &str); + /// Record the current refund-quarantine queue depth + /// (`ledger_refund_quarantine_depth`, Slice 3 §4.4 / §9 / Group G) — the count + /// of refund-before-payment rows still `QUEUED` on the `REFUND_QUARANTINE` + /// flow, observed by the sweep job each tick. A gauge: rises as + /// refund-before-payment requests quarantine, falls as the de-quarantine drain + /// resolves them (post / approval / give-up). Unlabelled (cross-tenant total, + /// mirroring `allocation_queue_depth`). + fn refund_quarantine_depth(&self, depth: i64); + /// Increment the refund `unknown_final` disposition counter + /// (`ledger_refund_unknown_final_total`, Slice 3 §4.4 / §9 / K-1) — one + /// terminal ledger-side dual-control disposition that cleared a stuck + /// `REFUND_CLEARING` to a documented loss line + wrote a secured-audit record. + /// Unlabelled (a rare governed event). Paired with the secured-audit append. + fn refund_unknown_final(&self); + /// Record the current open `REFUND_CLEARING` balance for a tenant + /// (`ledger_refund_clearing_balance_minor{tenant}` gauge, Slice 3 §9) — the + /// summed unsettled clearing the `AgedAlarmJob` observes each tick. Rises as + /// stage-1 refunds initiate, falls as they settle / reverse / are disposed. + fn refund_clearing_balance_minor(&self, tenant: Uuid, balance_minor: i64); + /// Record the age (seconds) of the oldest open `REFUND_CLEARING` balance for a + /// tenant (`ledger_refund_clearing_aged_seconds{tenant}` gauge, Slice 3 §9) — + /// the worst-case clearing latency the aging alarm thresholds (7d/14d) gate. + fn refund_clearing_aged_seconds(&self, tenant: Uuid, age_secs: f64); + /// Increment the stage-1-refund-orphan counter + /// (`ledger_stage1_refund_orphan_total`, Slice 3 §4.4 / §9) — one stage-1 + /// refund with no matching stage-2 / reversal beyond the aging threshold, + /// paged to Revenue Assurance. Unlabelled; re-counted each tick the orphan + /// persists (a latency signal, like the aged-allocation count). + fn stage1_refund_orphan(&self); + /// Record one unrealized-revaluation run-pass latency sample in seconds + /// (Slice 5 Phase 3 / §9): `ledger_fx_revaluation_duration_seconds` + /// (histogram) — the wall time one `RevaluationRunJob` tick took across the + /// in-scope grains (the close-window cost, NFR ≤ 30 min). + fn fx_revaluation_duration(&self, secs: f64); + /// Increment the FX provider-fallback counter (Slice 5 Phase 3 / §9): + /// `ledger_fx_provider_fallback_total{provider}` — one lock-time rate resolve + /// that fell to a lower-priority provider. `provider` is the resolved provider + /// id (bounded cardinality — the configured `provider_order`). + fn fx_provider_fallback(&self, provider: &str); + /// Increment the realized-FX counter by `amount_minor` (Slice 5 Phase 2 / §9): + /// `ledger_fx_realized_minor{functional_currency,direction}` — the functional + /// magnitude of a net `FX_GAIN_LOSS` line posted on a cross-currency allocation + /// close. `amount_minor` is the non-negative magnitude; `direction` (`"gain"` / + /// `"loss"`) carries the sign-by-role (the spec names `{functional_currency}`; + /// the `direction` label makes a realized gain distinguishable from a loss in + /// the one monotonic counter). Both labels are bounded cardinality (the + /// functional currency is per-tenant; direction is a two-value closed set). + fn fx_realized_minor(&self, amount_minor: i64, functional_currency: &str, direction: &str); + + // ── Slice 7 Phase 3 reconciliation ───────────────────────────────────────── + /// Record the signed variance (minor units) observed by one reconciliation + /// check run, labelled by `check_type` + /// (`ledger_reconciliation_variance_minor{check_type}`, design §9 / spec §3.5 + /// J4). A gauge: each run records its own signed observed value (a tie-out can + /// be positive or negative). `check_type` is the closed reconciliation-check + /// kind (bounded cardinality — a fixed set of control feeds). + fn reconciliation_variance_minor(&self, check_type: &str, variance_minor: i64); + /// Increment the reconciliation-run counter for one check pass, labelled by + /// `check_type` (`ledger_reconciliation_runs_total{check_type}`, design §9 / + /// spec §3.5 J4) — one increment per reconciliation check executed. + fn reconciliation_run(&self, check_type: &str); + /// Increment the out-of-tolerance counter for one reconciliation check whose + /// variance breached its configured tolerance, labelled by `check_type` + /// (`ledger_reconciliation_out_of_tolerance_total{check_type}`, design §9 / + /// spec §3.5 J4). `out_of_tolerance/runs` is the breach rate per check type. + fn reconciliation_out_of_tolerance(&self, check_type: &str); + /// Increment the period-close-blocked counter for one close attempt rejected + /// by a pre-close gate, labelled by `reason` + /// (`ledger_period_close_blocked_total{reason}`, design §9 / spec §3.5 J4) — + /// `reason` is the close-gate rejection token (bounded cardinality). + fn period_close_blocked(&self, reason: &str); + /// Record the current exception-queue depth, labelled by `type` + /// (`ledger_exception_queue_depth{type}`, design §9 / spec §3.5 J4) — the count + /// of rows still open on the exception queue for the given exception type, + /// observed by the sweep each tick. A gauge: rises as exceptions route in, + /// falls as they are resolved. + fn exception_queue_depth(&self, exception_type: &str, depth: i64); +} + +/// No-op metrics. Used by unit tests and any construction before an exporter is +/// wired. +#[domain_model] +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopLedgerMetrics; + +impl LedgerMetricsPort for NoopLedgerMetrics { + fn invoice_post(&self, _: PostResult, _: PostFlow) {} + fn invoice_post_duration(&self, _: f64, _: PostFlow) {} + fn payment_settle(&self, _: PostResult) {} + fn settlement_return(&self, _: PostResult) {} + fn chargeback(&self, _: PostResult) {} + fn allocation(&self, _: PostResult) {} + fn allocation_queue_depth(&self, _: i64) {} + fn credit_application(&self, _: PostResult) {} + fn payment_post_duration(&self, _: f64, _: PostFlow) {} + fn suspense_pending(&self, _: Uuid, _: i64, _: f64) {} + fn invariant_alarm(&self, _: &str, _: &str) {} + fn recognition_run_duration(&self, _: f64) {} + fn revenue_recognized_minor(&self, _: i64, _: &str) {} + fn over_recognition(&self) {} + fn recognition_double_credit(&self) {} + fn recognition_period_queue_depth(&self, _: i64) {} + fn dual_control_pending(&self, _: &str) {} + fn dual_control_decided(&self, _: &str, _: &str) {} + fn dual_control_self_approval_denied(&self, _: &str) {} + fn dual_control_approving(&self, _: i64) {} + fn tamper_verify_run(&self, _: Uuid, _: bool) {} + fn chain_length(&self, _: Uuid, _: i64) {} + fn scope_freeze_active(&self, _: Uuid, _: i64) {} + fn cross_tenant_access(&self, _: &str) {} + fn reidentification(&self) {} + fn erasure_applied(&self) {} + fn metadata_change(&self, _: &str) {} + fn audit_pack_export_duration(&self, _: f64) {} + fn credit_note(&self, _: NoteOutcome) {} + fn debit_note(&self, _: NoteOutcome) {} + fn refund(&self, _: &str, _: &str) {} + fn refund_quarantine_depth(&self, _: i64) {} + fn refund_unknown_final(&self) {} + fn refund_clearing_balance_minor(&self, _: Uuid, _: i64) {} + fn refund_clearing_aged_seconds(&self, _: Uuid, _: f64) {} + fn stage1_refund_orphan(&self) {} + fn fx_revaluation_duration(&self, _: f64) {} + fn fx_provider_fallback(&self, _: &str) {} + fn fx_realized_minor(&self, _: i64, _: &str, _: &str) {} + fn reconciliation_variance_minor(&self, _: &str, _: i64) {} + fn reconciliation_run(&self, _: &str) {} + fn reconciliation_out_of_tolerance(&self, _: &str) {} + fn period_close_blocked(&self, _: &str) {} + fn exception_queue_depth(&self, _: &str, _: i64) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn post_result_label_strings_are_snake_case() { + assert_eq!(PostResult::Posted.as_str(), "posted"); + assert_eq!(PostResult::Replayed.as_str(), "replayed"); + assert_eq!(PostResult::Rejected.as_str(), "rejected"); + } + + #[test] + fn post_flow_label_strings_are_snake_case() { + assert_eq!(PostFlow::InvoicePost.as_str(), "invoice_post"); + assert_eq!(PostFlow::Reversal.as_str(), "reversal"); + assert_eq!(PostFlow::MappingCorrection.as_str(), "mapping_correction"); + } + + #[test] + fn payment_post_flow_label_strings_are_snake_case() { + assert_eq!(PostFlow::Settle.as_str(), "settle"); + assert_eq!(PostFlow::Allocate.as_str(), "allocate"); + assert_eq!(PostFlow::CreditApply.as_str(), "credit_apply"); + assert_eq!(PostFlow::SettlementReturn.as_str(), "settlement_return"); + assert_eq!(PostFlow::Chargeback.as_str(), "chargeback"); + } + + #[test] + fn note_outcome_label_strings_are_snake_case() { + assert_eq!(NoteOutcome::Posted.as_str(), "posted"); + assert_eq!(NoteOutcome::Replayed.as_str(), "replayed"); + assert_eq!(NoteOutcome::Rejected.as_str(), "rejected"); + assert_eq!(NoteOutcome::BlockedSplit.as_str(), "blocked_split"); + assert_eq!(NoteOutcome::BlockedHeadroom.as_str(), "blocked_headroom"); + } + + // Exercise every `NoopLedgerMetrics` sink method: the no-op bodies are the + // safe default wired in unit tests / pre-exporter construction, so they must + // accept every signal without panicking. Also pins the `LedgerMetricsPort` + // contract surface (a new method forces this call site to compile). + #[test] + fn noop_metrics_accepts_every_signal_without_panicking() { + let m = NoopLedgerMetrics; + let t = Uuid::now_v7(); + m.invoice_post(PostResult::Posted, PostFlow::InvoicePost); + m.invoice_post_duration(1.0, PostFlow::Reversal); + m.payment_settle(PostResult::Replayed); + m.settlement_return(PostResult::Rejected); + m.chargeback(PostResult::Posted); + m.allocation(PostResult::Posted); + m.allocation_queue_depth(5); + m.credit_application(PostResult::Posted); + m.payment_post_duration(0.5, PostFlow::Settle); + m.suspense_pending(t, 3, 10.0); + m.invariant_alarm("TAMPER_VERIFY_FAILED", "critical"); + m.recognition_run_duration(0.1); + m.revenue_recognized_minor(100, "subscription"); + m.over_recognition(); + m.recognition_double_credit(); + m.recognition_period_queue_depth(2); + m.dual_control_pending("reverse"); + m.dual_control_decided("reverse", "approved"); + m.dual_control_self_approval_denied("reverse"); + m.dual_control_approving(1); + m.tamper_verify_run(t, false); + m.tamper_verify_run(t, true); + m.chain_length(t, 10); + m.scope_freeze_active(t, 0); + m.cross_tenant_access("INVESTIGATION"); + m.reidentification(); + m.erasure_applied(); + m.metadata_change("payer_phone"); + m.audit_pack_export_duration(2.0); + m.credit_note(NoteOutcome::BlockedSplit); + m.debit_note(NoteOutcome::Rejected); + m.refund("initiated", "A_UNALLOCATED"); + m.refund_quarantine_depth(0); + m.refund_unknown_final(); + m.refund_clearing_balance_minor(t, 1_000); + m.refund_clearing_aged_seconds(t, 3_600.0); + m.stage1_refund_orphan(); + m.fx_revaluation_duration(1.5); + m.fx_provider_fallback("ecb"); + m.fx_realized_minor(240, "USD", "loss"); + } + + // `NoopLedgerMetrics` is the trait's safe default — usable behind the + // `Arc` the services hold. Covers the dyn-dispatch path. + #[test] + fn noop_metrics_is_usable_as_dyn_port() { + let m: std::sync::Arc = std::sync::Arc::new(NoopLedgerMetrics); + m.invoice_post(PostResult::Rejected, PostFlow::Chargeback); + m.fx_realized_minor(0, "EUR", "gain"); + } +} diff --git a/gears/bss/ledger/ledger/src/domain/ports/obligation_state.rs b/gears/bss/ledger/ledger/src/domain/ports/obligation_state.rs new file mode 100644 index 000000000..726698e52 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/ports/obligation_state.rs @@ -0,0 +1,150 @@ +//! Obligation-satisfaction run-gating port (ASC 606, design §4.3 — Slice 4 +//! Group E4). +//! +//! A recognition run keys on whether the schedule's **performance obligation** +//! is satisfied — **not** on collections/dunning (a collections-suspended payer +//! keeps recognizing; revenue is earned regardless of collection; only an +//! upstream *cancellation* stops the schedule, PRD L680–681). The +//! [`ObligationStateResolver`] is the outbound port the +//! [`RecognitionRunner`](crate::infra::recognition::runner) consults before it +//! releases a segment: a satisfied obligation may proceed; a NOT-satisfied one +//! is skipped (delayed — never released early). +//! +//! **v1 default = proceed.** There is no Subscriptions obligation-satisfaction +//! feed in v1 (the consumer is deferred to Slice 7 / VHP-1859), and the design +//! is explicit that **revenue is earned regardless of collections** — so the v1 +//! default [`AlwaysSatisfiedObligationState`] returns +//! [`ObligationState::Satisfied`] for every schedule and recognition actually +//! runs. This is deliberately the opposite of a no-op that would *stall* +//! recognition: with no feed, the safe-for-revenue behaviour is to proceed. +//! +//! Concretely, **v1 recognition is calendar/time-based** (a segment releases on +//! its planned period). **Obligation-gated** recognition — where a release WAITS +//! on the Subscriptions performance-satisfaction signal — is itself the Slice 7 +//! capability (it needs the feed), so v1 has no obligation-gated segment that +//! `proceed` could release early; applying the fail-safe `NotSatisfied` here +//! instead would simply stall ALL (time-based) v1 recognition, which is wrong. +//! +//! **Fail-safe contract for the real feed (Slice 7).** Once the Subscriptions +//! feed lands, the resolver becomes the eventually-consistent reader of that +//! state, and the contract flips to fail-safe: an **unknown or stale** state is +//! treated as [`ObligationState::NotSatisfied`] — recognition is delayed (and +//! surfaced at the Slice 7 close gate via undone-due-segment blocking), never +//! released early. A stale "satisfied" can only ever release up to +//! `total_deferred_minor` (the per-schedule cap CHECK caps it). The runner only +//! ever asks "may I release this segment now?" via [`Self::is_satisfied`], so +//! swapping in the real reader needs no runner change. Mirrors the +//! [`LedgerMetricsPort`](super::metrics::LedgerMetricsPort) / +//! [`NoopLedgerMetrics`](super::metrics::NoopLedgerMetrics) port + safe-default +//! shape. + +use toolkit_macros::domain_model; +use uuid::Uuid; + +/// The performance-obligation context a recognition release draws against — the +/// minimum the resolver needs to look up the obligation's satisfaction state. +/// Carries the schedule identity, the seller tenant, and the schedule's +/// `subscription_ref` when it is subscription-scoped (`None` for a non- +/// subscription schedule — e.g. a one-time deferred line). PII-free by +/// construction (ledger identities + an opaque subscription ref only). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ObligationContext { + /// The seller tenant whose ledger owns the schedule. + pub tenant_id: Uuid, + /// The schedule whose segment is up for release. + pub schedule_id: String, + /// The schedule's `subscription_ref` when subscription-scoped; `None` + /// otherwise (the obligation is not tracked by a Subscriptions entitlement). + pub subscription_ref: Option, +} + +/// The resolved obligation-satisfaction state for one [`ObligationContext`]. +/// `Satisfied` ⇒ recognition may proceed; `NotSatisfied` ⇒ delay (do not +/// release). The real Slice 7 reader folds *unknown / stale* into +/// `NotSatisfied` (the fail-safe contract); v1's default never reports +/// `NotSatisfied` (no feed ⇒ proceed). +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObligationState { + /// The obligation is satisfied (or, in v1, presumed satisfied — no feed): + /// recognition may release the segment. + Satisfied, + /// The obligation is not (yet) satisfied — or unknown/stale under the real + /// feed's fail-safe rule: the release is delayed, never made early. + NotSatisfied, +} + +impl ObligationState { + /// Whether a release may proceed for this state (`Satisfied` ⇒ `true`). + #[must_use] + pub const fn may_recognize(self) -> bool { + matches!(self, Self::Satisfied) + } +} + +/// Outbound port: resolve whether a schedule's performance obligation is +/// satisfied, so the [`RecognitionRunner`](crate::infra::recognition::runner) +/// can gate a release on obligation-satisfaction (not collections). The real +/// adapter (Slice 7) reads the eventually-consistent Subscriptions feed and +/// applies the fail-safe rule; [`AlwaysSatisfiedObligationState`] is the v1 +/// default (no feed ⇒ proceed). +#[async_trait::async_trait] +pub trait ObligationStateResolver: Send + Sync + 'static { + /// Resolve the obligation-satisfaction state for `ctx`. v1 always returns + /// [`ObligationState::Satisfied`]; the real reader returns the latest known + /// state and folds unknown/stale into [`ObligationState::NotSatisfied`]. + async fn resolve(&self, ctx: &ObligationContext) -> ObligationState; + + /// Convenience: `true` iff the obligation may be recognized now. Default + /// impl over [`Self::resolve`] — adapters need only implement `resolve`. + async fn is_satisfied(&self, ctx: &ObligationContext) -> bool { + self.resolve(ctx).await.may_recognize() + } +} + +/// The v1 default resolver: every obligation is treated as satisfied, so +/// recognition runs (design §4.3 — revenue is earned regardless of collections, +/// and there is no Subscriptions feed in v1). Replaced by the real fail-safe +/// reader when the Subscriptions obligation-satisfaction feed lands (Slice 7 / +/// VHP-1859). Mirrors [`NoopLedgerMetrics`](super::metrics::NoopLedgerMetrics): +/// a zero-state safe default wired in until the real adapter exists. +#[domain_model] +#[derive(Debug, Default, Clone, Copy)] +pub struct AlwaysSatisfiedObligationState; + +#[async_trait::async_trait] +impl ObligationStateResolver for AlwaysSatisfiedObligationState { + async fn resolve(&self, _ctx: &ObligationContext) -> ObligationState { + // No Subscriptions feed in v1 → proceed (revenue is earned regardless of + // collections). The fail-safe unknown/stale → NotSatisfied rule applies + // only once the real feed lands (Slice 7). + ObligationState::Satisfied + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> ObligationContext { + ObligationContext { + tenant_id: Uuid::from_u128(1), + schedule_id: "sched-1".to_owned(), + subscription_ref: Some("sub-1".to_owned()), + } + } + + #[test] + fn may_recognize_is_true_only_when_satisfied() { + assert!(ObligationState::Satisfied.may_recognize()); + assert!(!ObligationState::NotSatisfied.may_recognize()); + } + + #[tokio::test] + async fn v1_default_always_proceeds() { + let resolver = AlwaysSatisfiedObligationState; + assert_eq!(resolver.resolve(&ctx()).await, ObligationState::Satisfied); + assert!(resolver.is_satisfied(&ctx()).await); + } +} diff --git a/gears/bss/ledger/ledger/src/domain/posting.rs b/gears/bss/ledger/ledger/src/domain/posting.rs new file mode 100644 index 000000000..1d7625487 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/posting.rs @@ -0,0 +1,181 @@ +//! Pure, backend-agnostic posting invariants — the same checks the P1 +//! `bss.check_entry_balanced` trigger enforces, reproduced in app code so +//! the engine errors deterministically on both backends and before COMMIT. + +use bss_ledger_sdk::Side; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::error::DomainError; + +/// Minimal per-line facts the balance check needs. +#[domain_model] +#[derive(Clone, Debug)] +pub struct LineFacts { + pub side: Side, + pub amount_minor: i64, + pub currency: String, + pub currency_scale: u8, + pub payer_tenant_id: Uuid, + pub functional_amount_minor: Option, +} + +impl LineFacts { + /// A functional-only line carries no transaction-currency amount. + fn is_functional_only(&self) -> bool { + self.amount_minor == 0 && self.functional_amount_minor.is_some() + } +} + +/// A structural posting-invariant breach. Projected into a [`DomainError`] +/// (the gear's single canonical-mapping vocabulary) by the `From` impl below. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PostingViolation { + #[error("entry has no lines")] + Empty, + #[error("entry does not net to zero per currency")] + Unbalanced, + #[error("entry spans more than one payer tenant")] + MixedPayer, + #[error("line currency does not match the entry currency")] + CurrencyMismatch, + #[error("lines in the same currency carry different scales")] + InconsistentScale, + #[error("line amount must be positive, or zero with a functional amount")] + AmountOutOfRange, + #[error("entry mixes functional and non-functional lines")] + FunctionalPartial, + #[error("entry does not net to zero in the functional currency")] + FunctionalUnbalanced, +} + +impl From for DomainError { + // Explicit per-variant detail literals (not `v.to_string()`): the source + // error type must not be flattened into a string here (DE1302), and the + // literals keep the domain detail clean (no category-prefix doubling). + fn from(v: PostingViolation) -> Self { + match v { + PostingViolation::Empty => Self::Empty("entry has no lines".to_owned()), + PostingViolation::MixedPayer => { + Self::MixedPayer("entry spans more than one payer tenant".to_owned()) + } + // A wrong per-line scale changes the implied magnitude of the + // amount — surfaced as out-of-range (wire `AMOUNT_OUT_OF_RANGE`) + // rather than a balance fault, preserving the prior contract. + PostingViolation::InconsistentScale => Self::InconsistentScale( + "lines in the same currency carry different scales".to_owned(), + ), + PostingViolation::Unbalanced => { + Self::Unbalanced("entry does not net to zero per currency".to_owned()) + } + // CurrencyMismatch has no dedicated variant — a line in another + // currency cannot net to zero, so it surfaces as unbalanced. + PostingViolation::CurrencyMismatch => { + Self::Unbalanced("line currency does not match the entry currency".to_owned()) + } + PostingViolation::AmountOutOfRange => Self::AmountOutOfRange( + "line amount must be positive, or zero with a functional amount".to_owned(), + ), + // FX dual-column (Slice 5): a partial-functional or functional-imbalance + // entry is a balance-class fault (no dedicated canonical variant). + PostingViolation::FunctionalPartial => { + Self::Unbalanced("entry mixes functional and non-functional lines".to_owned()) + } + PostingViolation::FunctionalUnbalanced => { + Self::Unbalanced("entry does not net to zero in the functional currency".to_owned()) + } + } + } +} + +/// Validate the structural invariants of a balanced entry. +/// +/// # Errors +/// A [`PostingViolation`] when the entry is empty, spans payers, mixes +/// currencies, or does not net to zero per `(currency, scale)` group. +pub fn validate_balanced_entry( + entry_currency: &str, + lines: &[LineFacts], +) -> Result<(), PostingViolation> { + if lines.is_empty() { + return Err(PostingViolation::Empty); + } + // chk_journal_line_amount, reproduced before COMMIT: every amount must be + // positive, or exactly zero with a POSITIVE functional amount (functional-only + // line — the DR/CR side carries the sign, so the functional amount is > 0). + if lines.iter().any(|l| { + l.amount_minor < 0 + || (l.amount_minor == 0 && !matches!(l.functional_amount_minor, Some(f) if f > 0)) + }) { + return Err(PostingViolation::AmountOutOfRange); + } + let first_payer = lines[0].payer_tenant_id; + if lines.iter().any(|l| l.payer_tenant_id != first_payer) { + return Err(PostingViolation::MixedPayer); + } + if lines + .iter() + .any(|l| l.currency != entry_currency && !l.is_functional_only()) + { + return Err(PostingViolation::CurrencyMismatch); + } + // One scale per currency: the registry resolves a single scale per + // currency and the currency-keyed balance caches hold one magnitude, so a + // line whose scale disagrees would corrupt the cached magnitude even if the + // entry nets to zero. `PostingService::post` is `pub` and trusts the + // caller-supplied `currency_scale`, so this guards that surface too. + let mut scale_by_ccy: std::collections::HashMap<&str, u8> = std::collections::HashMap::new(); + for l in lines { + if let Some(&s) = scale_by_ccy.get(l.currency.as_str()) + && s != l.currency_scale + { + return Err(PostingViolation::InconsistentScale); + } + scale_by_ccy + .entry(l.currency.as_str()) + .or_insert(l.currency_scale); + } + // Net per (currency, scale) group must be exactly zero. + let mut groups: std::collections::HashMap<(&str, u8), i128> = std::collections::HashMap::new(); + for l in lines { + let signed = match l.side { + Side::Debit => i128::from(l.amount_minor), + Side::Credit => -i128::from(l.amount_minor), + }; + *groups + .entry((l.currency.as_str(), l.currency_scale)) + .or_insert(0) += signed; + } + if groups.values().any(|net| *net != 0) { + return Err(PostingViolation::Unbalanced); + } + // FX dual-column functional balance (NULL-aware; mirrors bss.check_entry_balanced). + // f = count(functional Some): f=0 → single-currency, skip; f=len → enforce + // SUM(DR.functional) == SUM(CR.functional); 0 0 && func_count < lines.len() { + return Err(PostingViolation::FunctionalPartial); + } + if func_count == lines.len() { + let mut func_net: i128 = 0; + for l in lines { + let f = i128::from(l.functional_amount_minor.unwrap_or(0)); + func_net += match l.side { + Side::Debit => f, + Side::Credit => -f, + }; + } + if func_net != 0 { + return Err(PostingViolation::FunctionalUnbalanced); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "posting_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/domain/posting_tests.rs b/gears/bss/ledger/ledger/src/domain/posting_tests.rs new file mode 100644 index 000000000..41136e16e --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/posting_tests.rs @@ -0,0 +1,236 @@ +//! Tests for the pure posting invariants ([`super::validate_balanced_entry`]). + +use super::*; + +fn line(side: Side, amount: i64, payer: Uuid) -> LineFacts { + LineFacts { + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + payer_tenant_id: payer, + functional_amount_minor: None, + } +} + +/// A line carrying an explicit functional amount (Slice 5 dual-column tests). +fn line_f(side: Side, amount: i64, payer: Uuid, functional: Option) -> LineFacts { + LineFacts { + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + payer_tenant_id: payer, + functional_amount_minor: functional, + } +} + +#[test] +fn balanced_entry_is_ok() { + let p = Uuid::now_v7(); + let lines = vec![line(Side::Debit, 1000, p), line(Side::Credit, 1000, p)]; + assert!(validate_balanced_entry("USD", &lines).is_ok()); +} + +#[test] +fn empty_is_rejected() { + assert_eq!( + validate_balanced_entry("USD", &[]), + Err(PostingViolation::Empty) + ); +} + +#[test] +fn unbalanced_is_rejected() { + let p = Uuid::now_v7(); + let lines = vec![line(Side::Debit, 1000, p), line(Side::Credit, 700, p)]; + assert_eq!( + validate_balanced_entry("USD", &lines), + Err(PostingViolation::Unbalanced) + ); +} + +#[test] +fn mixed_payer_is_rejected() { + let lines = vec![ + line(Side::Debit, 1000, Uuid::now_v7()), + line(Side::Credit, 1000, Uuid::now_v7()), + ]; + assert_eq!( + validate_balanced_entry("USD", &lines), + Err(PostingViolation::MixedPayer) + ); +} + +#[test] +fn foreign_currency_line_is_rejected() { + let p = Uuid::now_v7(); + let mut eur = line(Side::Credit, 1000, p); + eur.currency = "EUR".to_owned(); + let lines = vec![line(Side::Debit, 1000, p), eur]; + assert_eq!( + validate_balanced_entry("USD", &lines), + Err(PostingViolation::CurrencyMismatch) + ); +} + +#[test] +fn inconsistent_scale_same_currency_is_rejected() { + let p = Uuid::now_v7(); + // Two USD lines that net to zero but carry different scales: a wrong + // per-line scale must be rejected, not silently posted at the wrong + // implied magnitude. + let mut hi = line(Side::Credit, 1000, p); + hi.currency_scale = 3; + let lines = vec![line(Side::Debit, 1000, p), hi]; + assert_eq!( + validate_balanced_entry("USD", &lines), + Err(PostingViolation::InconsistentScale) + ); +} + +#[test] +fn each_violation_maps_to_its_domain_error() { + assert!(matches!( + DomainError::from(PostingViolation::Empty), + DomainError::Empty(_) + )); + assert!(matches!( + DomainError::from(PostingViolation::MixedPayer), + DomainError::MixedPayer(_) + )); + // A wrong per-line scale surfaces as InconsistentScale (wire + // `AMOUNT_OUT_OF_RANGE`), not a balance fault. + assert!(matches!( + DomainError::from(PostingViolation::InconsistentScale), + DomainError::InconsistentScale(_) + )); + assert!(matches!( + DomainError::from(PostingViolation::Unbalanced), + DomainError::Unbalanced(_) + )); + // CurrencyMismatch has no dedicated variant — surfaces as unbalanced. + assert!(matches!( + DomainError::from(PostingViolation::CurrencyMismatch), + DomainError::Unbalanced(_) + )); +} + +#[test] +fn negative_amount_lines_are_rejected_even_when_balanced() { + let p = Uuid::now_v7(); + // Two negative-amount lines net to zero, but a negative amount violates + // chk_journal_line_amount (amount > 0, or 0 with a functional amount) — + // it must be rejected before COMMIT, not surface as a DB constraint fault. + let lines = vec![line(Side::Debit, -100, p), line(Side::Credit, -100, p)]; + assert_eq!( + validate_balanced_entry("USD", &lines), + Err(PostingViolation::AmountOutOfRange) + ); +} + +#[test] +fn zero_amount_without_functional_is_rejected() { + let p = Uuid::now_v7(); + let lines = vec![line(Side::Debit, 0, p), line(Side::Credit, 0, p)]; + assert_eq!( + validate_balanced_entry("USD", &lines), + Err(PostingViolation::AmountOutOfRange) + ); +} + +#[test] +fn functional_only_zero_amount_lines_are_allowed() { + let p = Uuid::now_v7(); + // Functional-only lines (amount 0 WITH a positive functional amount) are valid + // and must balance in the functional column — a DR/CR pair nets to zero there. + let dr = line_f(Side::Debit, 0, p, Some(500)); + let cr = line_f(Side::Credit, 0, p, Some(500)); + assert!(validate_balanced_entry("USD", &[dr, cr]).is_ok()); +} + +#[test] +fn zero_amount_with_zero_functional_is_rejected() { + let p = Uuid::now_v7(); + // Tightened chk_journal_line_amount: a functional-only line must carry a + // POSITIVE functional amount (the side carries the sign), so functional 0 fails. + let lines = vec![ + line_f(Side::Debit, 0, p, Some(0)), + line_f(Side::Credit, 0, p, Some(0)), + ]; + assert_eq!( + validate_balanced_entry("USD", &lines), + Err(PostingViolation::AmountOutOfRange) + ); +} + +#[test] +fn single_currency_entry_skips_functional_check() { + let p = Uuid::now_v7(); + // f = 0: no functional amounts → the functional check is skipped, so existing + // single-currency posts are byte-unaffected. + let lines = vec![line(Side::Debit, 1000, p), line(Side::Credit, 1000, p)]; + assert!(validate_balanced_entry("USD", &lines).is_ok()); +} + +#[test] +fn cross_currency_functional_balanced_is_ok() { + let p = Uuid::now_v7(); + // Every line carries a functional amount; BOTH the transaction column + // (1000 = 1000) and the functional column (1100 = 1100) balance. + let lines = vec![ + line_f(Side::Debit, 1000, p, Some(1100)), + line_f(Side::Credit, 1000, p, Some(1100)), + ]; + assert!(validate_balanced_entry("USD", &lines).is_ok()); +} + +#[test] +fn functional_unbalanced_is_rejected() { + let p = Uuid::now_v7(); + // Transaction balances (1000 = 1000) but the functional column does not + // (1100 != 1090) — the dual-column invariant rejects it. + let lines = vec![ + line_f(Side::Debit, 1000, p, Some(1100)), + line_f(Side::Credit, 1000, p, Some(1090)), + ]; + assert_eq!( + validate_balanced_entry("USD", &lines), + Err(PostingViolation::FunctionalUnbalanced) + ); +} + +#[test] +fn partial_functional_entry_is_rejected() { + let p = Uuid::now_v7(); + // 0 < f < len: one line carries functional, the other does not — a posting bug, + // fail loud rather than silently imbalance the functional column. + let lines = vec![ + line_f(Side::Debit, 1000, p, Some(1100)), + line_f(Side::Credit, 1000, p, None), + ]; + assert_eq!( + validate_balanced_entry("USD", &lines), + Err(PostingViolation::FunctionalPartial) + ); +} + +#[test] +fn amount_out_of_range_maps_to_its_domain_error() { + assert!(matches!( + DomainError::from(PostingViolation::AmountOutOfRange), + DomainError::AmountOutOfRange(_) + )); +} + +#[test] +fn functional_violations_map_to_unbalanced() { + assert!(matches!( + DomainError::from(PostingViolation::FunctionalPartial), + DomainError::Unbalanced(_) + )); + assert!(matches!( + DomainError::from(PostingViolation::FunctionalUnbalanced), + DomainError::Unbalanced(_) + )); +} diff --git a/gears/bss/ledger/ledger/src/domain/provisioning.rs b/gears/bss/ledger/ledger/src/domain/provisioning.rs new file mode 100644 index 000000000..b865048fb --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/provisioning.rs @@ -0,0 +1,4 @@ +//! Pure seller-provisioning plan: fiscal-calendar validation and +//! initial-period-id derivation. No infrastructure deps (DE0301). + +pub mod plan; diff --git a/gears/bss/ledger/ledger/src/domain/provisioning/plan.rs b/gears/bss/ledger/ledger/src/domain/provisioning/plan.rs new file mode 100644 index 000000000..267fee8b7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/provisioning/plan.rs @@ -0,0 +1,131 @@ +//! Pure provisioning-plan logic: validate the fiscal-calendar fields and +//! derive the initial `period_id`. Free functions only (no `#[domain_model]`), +//! no infrastructure imports (DE0301). + +use bss_ledger_sdk::{FiscalCalendarSpec, Granularity}; +use chrono::{DateTime, Utc}; + +use crate::domain::error::DomainError; + +/// Validate a fiscal-calendar spec before any DB work: reject an empty +/// timezone, a fiscal-year start month outside `1..=12`, and (defensively) +/// any granularity other than `Month` (MVP supports monthly only). +/// +/// # Errors +/// [`DomainError::InvalidRequest`] on an empty timezone, an out-of-range +/// `fy_start_month`, or an unsupported granularity. +pub fn validate_calendar(spec: &FiscalCalendarSpec) -> Result<(), DomainError> { + if spec.timezone.is_empty() { + return Err(DomainError::InvalidRequest( + "fiscal calendar timezone must not be empty".to_owned(), + )); + } + if !(1..=12).contains(&spec.fy_start_month) { + return Err(DomainError::InvalidRequest(format!( + "fiscal calendar fy_start_month must be 1..=12 (got {})", + spec.fy_start_month + ))); + } + if spec.granularity != Granularity::Month { + return Err(DomainError::InvalidRequest( + "fiscal calendar granularity must be MONTH (MVP)".to_owned(), + )); + } + // S5-F3 functional currency (optional — `None` = single-currency tenant). When + // present it must be a plausible ISO-4217-ish code (non-empty, ASCII, ≤ 10), + // the same envelope the FX-rate ingest + unallocated read use. + if let Some(fc) = &spec.functional_currency + && (fc.is_empty() || fc.len() > 10 || !fc.is_ascii()) + { + return Err(DomainError::InvalidRequest(format!( + "fiscal calendar functional_currency must be a non-empty ASCII code of at most \ + 10 chars (got {fc:?})" + ))); + } + Ok(()) +} + +/// Derive the initial `period_id` from a UTC instant as `"%Y%m"` (decision 4 — +/// UTC-derived; tz-precise month boundaries are refined by the P6 automation). +#[must_use] +pub fn initial_period_id(now: DateTime) -> String { + crate::domain::period::period_id_for(now) +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + + fn spec(timezone: &str, granularity: Granularity, fy_start_month: u8) -> FiscalCalendarSpec { + FiscalCalendarSpec { + timezone: timezone.to_owned(), + granularity, + fy_start_month, + functional_currency: None, + } + } + + fn spec_fc(functional_currency: Option<&str>) -> FiscalCalendarSpec { + FiscalCalendarSpec { + timezone: "UTC".to_owned(), + granularity: Granularity::Month, + fy_start_month: 1, + functional_currency: functional_currency.map(ToOwned::to_owned), + } + } + + #[test] + fn validate_calendar_accepts_valid_spec() { + assert!(validate_calendar(&spec("UTC", Granularity::Month, 1)).is_ok()); + assert!(validate_calendar(&spec("Europe/Madrid", Granularity::Month, 12)).is_ok()); + } + + #[test] + fn validate_calendar_rejects_empty_timezone() { + assert!(matches!( + validate_calendar(&spec("", Granularity::Month, 1)), + Err(DomainError::InvalidRequest(_)) + )); + } + + #[test] + fn validate_calendar_rejects_out_of_range_fy_start_month() { + assert!(matches!( + validate_calendar(&spec("UTC", Granularity::Month, 0)), + Err(DomainError::InvalidRequest(_)) + )); + assert!(matches!( + validate_calendar(&spec("UTC", Granularity::Month, 13)), + Err(DomainError::InvalidRequest(_)) + )); + } + + #[test] + fn validate_calendar_accepts_functional_currency_or_none() { + assert!(validate_calendar(&spec_fc(Some("USD"))).is_ok()); + assert!( + validate_calendar(&spec_fc(None)).is_ok(), + "a single-currency tenant (no functional currency) is valid" + ); + } + + #[test] + fn validate_calendar_rejects_malformed_functional_currency() { + assert!(matches!( + validate_calendar(&spec_fc(Some(""))), + Err(DomainError::InvalidRequest(_)) + )); + assert!(matches!( + validate_calendar(&spec_fc(Some("THIS_IS_WAY_TOO_LONG"))), + Err(DomainError::InvalidRequest(_)) + )); + } + + #[test] + fn initial_period_id_formats_year_month() { + let now = Utc.with_ymd_and_hms(2026, 6, 19, 10, 30, 0).unwrap(); + assert_eq!(initial_period_id(now), "202606"); + } +} diff --git a/gears/bss/ledger/ledger/src/domain/recognition.rs b/gears/bss/ledger/ledger/src/domain/recognition.rs new file mode 100644 index 000000000..51463d0a3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/recognition.rs @@ -0,0 +1,39 @@ +//! ASC 606 revenue-recognition domain (Slice 4, design §4.2 / §4.4). Pure, +//! backend-agnostic deferral/timing derivation for the deferred Contract-liability +//! balance posted at invoice (Slice 1) — the **plan** half of recognition, with no +//! DB / txn / async I/O (the in-txn materialization is the Group C +//! `ScheduleBuilderSidecar`, the release is the Phase 2 `RecognitionRunner`). +//! +//! Two layers, both pure: +//! +//! - [`input`] — [`input::RecognitionInput`], the per-invoice-item recognition +//! spec (the approved v1 interface). An item with no `RecognitionInput` is +//! recognized now (`deferred = 0`, today's Variant-A behaviour); one with a +//! `POINT_IN_TIME` timing is likewise undeferred; one with +//! `STRAIGHT_LINE { periods, first_period_id }` defers its whole ex-tax amount to +//! `CONTRACT_LIABILITY` and recognizes it over N equal segments. +//! - [`ports`] — the three resolver port traits ([`ports::DeferralPolicyResolver`], +//! [`ports::SspResolver`], [`ports::VcResolver`]) the derivation calls to resolve +//! deferral/timing (R1/R2 precedence), validate SSP-snapshot presence for a +//! multi-PO line (§4.4), and carry the VC refs (VC posting is OUT of the MVP, +//! N-revrec-4). v1 ships a config/input-backed default of each +//! ([`ports::DefaultDeferralPolicyResolver`] etc.) that reads only the request +//! input + tenant-config defaults — **no network, no snapshot tables** (those +//! resolve locally in a later refinement, design §13 / I-6). +//! - [`builder`] — [`builder::ScheduleBuilder`], the pure derivation that turns the +//! resolved policy + item context into a [`builder::ScheduleOutcome`] +//! (`NoDeferral` or a [`builder::BuiltSchedule`] plan), or a +//! [`crate::domain::error::DomainError`] block. The Group C sidecar reads the +//! plan's public fields to build the `recognition_schedule` / +//! `recognition_segment` insert rows — the builder itself never imports the +//! repo (DE0301 — no infra in domain). +//! +//! Ports are **sync** (mirroring [`crate::domain::ports::metrics`]): the v1 +//! resolvers are pure functions of local data, so there is no async I/O to model, +//! and a sync trait keeps [`builder::ScheduleBuilder`] callable from the in-txn +//! sidecar without an executor. + +pub mod builder; +pub mod change; +pub mod input; +pub mod ports; diff --git a/gears/bss/ledger/ledger/src/domain/recognition/builder.rs b/gears/bss/ledger/ledger/src/domain/recognition/builder.rs new file mode 100644 index 000000000..0869950fd --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/recognition/builder.rs @@ -0,0 +1,342 @@ +//! [`ScheduleBuilder`] — the **pure** recognition-schedule derivation (design +//! §4.2). Inputs → plan, with **no DB / txn / async I/O**: it resolves the +//! policy/timing/SSP/VC via the [`ports`](super::ports), applies the R4 +//! immaterial-one-shot exemption + the SSP presence gate, lays out the N +//! straight-line segments via [`crate::domain::allocate`] (residual cent → last, +//! design §4.3), enforces the configured segment ceiling, and returns a +//! [`ScheduleOutcome`]. The Group C `ScheduleBuilderSidecar` reads the plan's +//! public fields to build the `recognition_schedule` / `recognition_segment` +//! insert shapes inside the invoice-post txn; the builder itself never imports +//! the repo (DE0301 — no infra in domain). +//! +//! Outcome (per item, per revenue stream — one schedule per stream, §4.5): +//! +//! - [`ScheduleOutcome::NoDeferral`] — `deferred = 0`, no schedule. The item is a +//! `POINT_IN_TIME` line, has no spec at all (handled by the caller before it +//! reaches the builder — absence ⇒ no [`RecognitionContext`]), or qualifies for +//! the **R4 immaterial-one-shot exemption** (point-in-time treatment even +//! though a deferring timing was requested). Byte-for-byte today's Variant-A +//! behaviour. +//! - [`ScheduleOutcome::Schedule`] — a [`BuiltSchedule`] plan: the whole ex-tax +//! amount deferred to `CONTRACT_LIABILITY`, split into `segments` equal +//! slices, with the immutable `policy_ref` / `ssp_snapshot_ref` / +//! `po_allocation_group` / VC refs stamped. +//! - `Err(DomainError)` — a block: [`DomainError::SspSnapshotRequired`] (multi-PO +//! without a resolvable SSP snapshot), [`DomainError::RecognitionPolicyConflict`] +//! (R1/R2 ambiguity), [`DomainError::ScheduleTooLong`] (segment count over the +//! configured ceiling), or [`DomainError::AmountOutOfRange`] (a malformed +//! amount / period that cannot lay out). + +use toolkit_macros::domain_model; + +use crate::config::RecognitionConfig; +use crate::domain::allocate::{Residual, allocate}; +use crate::domain::error::DomainError; +use crate::domain::period::period_id_plus; +use crate::domain::recognition::input::RecognitionTiming; +use crate::domain::recognition::ports::{ + DeferralPolicyResolver, RecognitionContext, SspResolver, VcResolver, +}; + +/// One planned recognition segment — a `(period_id, amount_minor)` slice the +/// sidecar will materialize as a `recognition_segment` row. `segment_no` is the +/// 1-based position (1:1 with `period_id`, the storage invariant); the sidecar +/// stamps it. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PlannedSegment { + /// 1-based segment number (immutable, 1:1 with `period_id`). + pub segment_no: i32, + /// Fiscal `period_id` (`YYYYMM`) this segment recognizes into. + pub period_id: String, + /// Minor-unit amount of this segment (`>= 0`; `Σ == deferred_minor`). + pub amount_minor: i64, +} + +/// The derived schedule plan for one deferred item-stream: the whole ex-tax +/// amount deferred, the equal segments, and the immutable refs to stamp. Pure +/// data — the Group C sidecar reads these public fields to build the +/// `recognition_schedule` + `recognition_segment` insert rows (the builder does +/// not import the repo; see the note below the struct). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +// The `*_ref` / `*_group` fields mirror the `recognition_schedule` columns. +#[allow(clippy::struct_field_names)] +pub struct BuiltSchedule { + /// The whole ex-tax amount deferred to `CONTRACT_LIABILITY` (`= Σ segment + /// amounts`). + pub deferred_minor: i64, + /// The equal recognition segments (residual cent on the last), in period + /// order. + pub segments: Vec, + /// The immutable deferral+timing policy version (stamped). + pub policy_ref: String, + /// The SSP snapshot ref to stamp (`None` for a single-PO line). + pub ssp_snapshot_ref: Option, + /// The PO / allocation group to stamp. + pub po_allocation_group: Option, + /// The subscription/entitlement ref to stamp. + pub subscription_ref: Option, + /// VC estimate ref (carry-only, N-revrec-4). + pub vc_estimate_ref: Option, + /// VC method ref (carry-only, N-revrec-4). + pub vc_method_ref: Option, + /// The item's revenue stream (one schedule per stream). + pub revenue_stream: String, + /// The item's ISO currency (stamped on the schedule). + pub currency: String, +} + +// NOTE: the projection of a `BuiltSchedule` into the repo insert shapes +// (`NewSchedule` / `NewSegment`) lives in the Group C `ScheduleBuilderSidecar` +// (infra), NOT here: the domain builder must not import the repo (DE0301 — no +// infra in domain). Every field the sidecar needs is `pub` on `BuiltSchedule` +// (`deferred_minor`, `segments` with their `segment_no` / `period_id` / +// `amount_minor`, and the stamped `policy_ref` / `ssp_snapshot_ref` / +// `po_allocation_group` / `subscription_ref` / `vc_*_ref` / `revenue_stream` / +// `currency`), so the sidecar builds `NewSchedule` + `NewSegment` directly, +// minting the `schedule_id` and supplying the posting-context identity +// (`source_invoice_id` / `source_invoice_item_ref` / `payer_tenant_id` / +// `tenant_id`) it already holds. + +/// The result of deriving recognition for one item-stream: either no deferral +/// (recognized now) or a [`BuiltSchedule`] plan. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +// `Schedule` dwarfs the unit `NoDeferral`, but the outcome is a transient +// per-item return consumed immediately (never collected in bulk), so boxing +// would add an allocation for no meaningful saving. +#[allow(clippy::large_enum_variant)] +pub enum ScheduleOutcome { + /// `deferred = 0` — recognized now, no schedule (`POINT_IN_TIME` or the R4 + /// exemption applied). + NoDeferral, + /// A materializable schedule plan. + Schedule(BuiltSchedule), +} + +impl ScheduleOutcome { + /// The amount deferred to `CONTRACT_LIABILITY` by this outcome: `0` for + /// [`Self::NoDeferral`], the schedule's `deferred_minor` otherwise. The + /// Group C builder split feeds this into the `CR CONTRACT_LIABILITY` / + /// `CR REVENUE` line amounts (`CR REVENUE = amount − deferred`). + #[must_use] + pub fn deferred_minor(&self) -> i64 { + match self { + Self::NoDeferral => 0, + Self::Schedule(s) => s.deferred_minor, + } + } +} + +/// The pure recognition-schedule derivation. Holds the three resolver ports + +/// the config (for the segment ceiling + R4 thresholds); [`Self::derive`] is the +/// single entry point. +#[domain_model] +pub struct ScheduleBuilder<'r, P, S, V> +where + P: DeferralPolicyResolver, + S: SspResolver, + V: VcResolver, +{ + policy: &'r P, + ssp: &'r S, + vc: &'r V, + config: &'r RecognitionConfig, +} + +impl<'r, P, S, V> ScheduleBuilder<'r, P, S, V> +where + P: DeferralPolicyResolver, + S: SspResolver, + V: VcResolver, +{ + /// Build a derivation over the three resolvers + the recognition config. + #[must_use] + pub fn new(policy: &'r P, ssp: &'r S, vc: &'r V, config: &'r RecognitionConfig) -> Self { + Self { + policy, + ssp, + vc, + config, + } + } + + /// Derive the recognition outcome for one item-stream from `ctx`. **Pure** — + /// no DB / txn / async. Order of operations (each a documented gate): + /// + /// 1. **SSP presence gate** (§4.4): a `multi_po` line whose SSP snapshot ref + /// is missing/unresolvable ⇒ [`DomainError::SspSnapshotRequired`]. Checked + /// first so a multi-PO config gap blocks regardless of timing. + /// 2. **Policy resolution** (R1/R2, [`DeferralPolicyResolver`]): yields the + /// immutable `policy_ref` + concrete timing (with `first_period_id` + /// defaulted from the invoice period). Ambiguity ⇒ + /// [`DomainError::RecognitionPolicyConflict`]. + /// 3. **`POINT_IN_TIME`** ⇒ [`ScheduleOutcome::NoDeferral`] (recognized now). + /// 4. **R4 immaterial-one-shot exemption**: a deferring timing that is + /// SKU-flagged AND under the materiality threshold is treated as + /// point-in-time ⇒ [`ScheduleOutcome::NoDeferral`] (design §1.4 R4 / §13). + /// 5. **`STRAIGHT_LINE`**: defer the whole ex-tax amount, lay out `periods` + /// consecutive segments (residual cent → last), enforce the segment + /// ceiling, stamp the refs. + /// + /// # Errors + /// [`DomainError::SspSnapshotRequired`], [`DomainError::RecognitionPolicyConflict`], + /// [`DomainError::ScheduleTooLong`] (segment count over + /// `config.max_segments_per_schedule`), or [`DomainError::AmountOutOfRange`] + /// (negative amount, zero/over-large `periods`, or an unparseable period that + /// cannot lay out). + pub fn derive(&self, ctx: &RecognitionContext<'_>) -> Result { + if ctx.item_amount_minor_ex_tax < 0 { + return Err(DomainError::AmountOutOfRange(format!( + "recognition item amount must be >= 0, got {}", + ctx.item_amount_minor_ex_tax + ))); + } + + // 1. SSP presence gate (§4.4) — before policy so a multi-PO config gap + // blocks even a malformed/point-in-time timing. + let ssp_snapshot_ref = self.ssp.resolve(ctx)?; + + // 2. Resolve deferral + timing (R1/R2 precedence). + let resolved = self.policy.resolve(ctx)?; + + // 3. POINT_IN_TIME ⇒ no schedule. + let RecognitionTiming::StraightLine { + periods, + first_period_id, + } = resolved.timing + else { + return Ok(ScheduleOutcome::NoDeferral); + }; + + // 4. R4 immaterial-one-shot exemption: a deferring timing that is + // SKU-flagged AND immaterial recognizes now (point-in-time treatment), + // no schedule. (The exemption is specifically for point-in-time + // *eligible* one-shots; we apply it to a would-be-deferred line that + // carries the flag and clears the threshold.) + if ctx.input.immaterial_one_shot_sku + && is_immaterial( + ctx.item_amount_minor_ex_tax, + ctx.invoice_total_minor, + self.config, + ) + { + return Ok(ScheduleOutcome::NoDeferral); + } + + // 5. STRAIGHT_LINE: defer the whole ex-tax amount across `periods` + // consecutive segments. The resolver is contracted to fill + // `first_period_id` (the default resolver defaults it from the invoice + // period); a `None` that slips through is a resolver defect, surfaced + // as a policy conflict rather than a panic. + let first_period_id = first_period_id.ok_or_else(|| { + DomainError::RecognitionPolicyConflict( + "straight-line timing resolved without a first_period_id".to_owned(), + ) + })?; + let deferred_minor = ctx.item_amount_minor_ex_tax; + let segments = self.plan_straight_line(deferred_minor, periods, &first_period_id)?; + + // VC refs via the port (carry-only in v1 — N-revrec-4 — so the default + // echoes the input refs; a future impl validates VC evidence). Routing + // through `self.vc` keeps the port live + consistent with policy / ssp. + let (vc_estimate_ref, vc_method_ref) = self.vc.resolve(ctx)?; + + Ok(ScheduleOutcome::Schedule(BuiltSchedule { + deferred_minor, + segments, + policy_ref: resolved.policy_ref, + ssp_snapshot_ref, + po_allocation_group: ctx.input.po_allocation_group.clone(), + subscription_ref: ctx.input.subscription_ref.clone(), + vc_estimate_ref, + vc_method_ref, + revenue_stream: ctx.revenue_stream.to_owned(), + currency: ctx.currency.to_owned(), + })) + } + + /// Lay out `periods` equal segments of `deferred_minor` over consecutive + /// fiscal periods from `first_period_id`, residual cent on the last + /// (`allocate(deferred, &[1; N], Residual::Last)`). Enforces the configured + /// ceiling. + fn plan_straight_line( + &self, + deferred_minor: i64, + periods: u32, + first_period_id: &str, + ) -> Result, DomainError> { + if periods == 0 { + return Err(DomainError::AmountOutOfRange( + "straight-line schedule must have >= 1 period".to_owned(), + )); + } + // Segment-count guard (design §4.2 / §3.7, decision 3): block over the + // configured ceiling — no degrade in v1. Compared as usize against the + // config ceiling. + let n = periods as usize; + if n > self.config.max_segments_per_schedule { + return Err(DomainError::ScheduleTooLong(format!( + "{n} segments exceeds the configured ceiling of {}", + self.config.max_segments_per_schedule + ))); + } + + // Equal weights ⇒ even split with the residual cent on the last segment + // (the schedule-version's pinned residual rule, §4.3). `allocate` errors + // only on empty/negative/zero-sum weights — none possible here (n >= 1, + // unit weights) — so map any surprise to AmountOutOfRange rather than + // unwrapping. + let weights = vec![1_i64; n]; + let amounts = allocate(deferred_minor, &weights, Residual::Last) + .map_err(|e| DomainError::AmountOutOfRange(format!("segment allocation: {e}")))?; + + let mut segments = Vec::with_capacity(n); + for (i, amount_minor) in amounts.into_iter().enumerate() { + // `i` fits the segment count (<= ceiling, default 120), well within + // u32/i32; periods are consecutive from the first. + let offset = u32::try_from(i) + .map_err(|_| DomainError::AmountOutOfRange("segment index overflow".to_owned()))?; + let period_id = period_id_plus(first_period_id, offset).ok_or_else(|| { + DomainError::AmountOutOfRange(format!( + "cannot advance period `{first_period_id}` by {offset} months" + )) + })?; + let segment_no = i32::try_from(i + 1) + .map_err(|_| DomainError::AmountOutOfRange("segment number overflow".to_owned()))?; + segments.push(PlannedSegment { + segment_no, + period_id, + amount_minor, + }); + } + Ok(segments) + } +} + +/// `true` iff `amount_minor` clears the R4 materiality threshold: the **lower** +/// of 1% of the invoice total or the 100-USD-equiv floor (design §1.4 R4 / §13). +/// v1 treats `amount_minor` as USD-equivalent minor units against a fixed +/// 100-USD floor (`100 * 100 = 10_000` minor) — FX normalization is deferred +/// (Slice 5); the threshold is otherwise tenant-configurable via +/// [`RecognitionConfig`] in a later refinement (today the floor is the ratified +/// constant). The 1% leg uses `i128` to avoid an intermediate overflow. +pub(crate) fn is_immaterial( + amount_minor: i64, + invoice_total_minor: i64, + _config: &RecognitionConfig, +) -> bool { + // 100 USD-equiv in minor units (cents). v1 fixed floor; FX-normalized in a + // later slice. Declared first (clippy::items_after_statements). + const USD_FLOOR_MINOR: i128 = 100 * 100; + // 1% of the invoice total (floored, i128 to dodge overflow on the multiply). + let one_percent = (i128::from(invoice_total_minor)) / 100; + // The exemption ceiling is the *lower* of the two (design §1.4 R4). + let threshold = one_percent.min(USD_FLOOR_MINOR); + i128::from(amount_minor) <= threshold +} + +#[cfg(test)] +#[path = "builder_tests.rs"] +mod builder_tests; diff --git a/gears/bss/ledger/ledger/src/domain/recognition/builder_tests.rs b/gears/bss/ledger/ledger/src/domain/recognition/builder_tests.rs new file mode 100644 index 000000000..2a968eb3a --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/recognition/builder_tests.rs @@ -0,0 +1,264 @@ +//! Tests for the pure `ScheduleBuilder` derivation: straight-line segment +//! generation (count, sum, residual on last, consecutive periods), `POINT_IN_TIME` +//! ⇒ no deferral, the R4 immaterial-one-shot exemption boundary, the SSP-required +//! decision (multi-PO with/without ref), and the segment-count ceiling. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use crate::config::RecognitionConfig; +use crate::domain::error::DomainError; +use crate::domain::recognition::input::{RecognitionInput, RecognitionTiming}; +use crate::domain::recognition::ports::{ + DefaultDeferralPolicyResolver, DefaultSspResolver, DefaultVcResolver, RecognitionContext, +}; + +/// A straight-line spec over `periods`, first period defaulted from the invoice. +fn straight_line(periods: u32) -> RecognitionInput { + RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods, + first_period_id: None, + }, + po_allocation_group: Some("grp".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub.1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + } +} + +fn point_in_time() -> RecognitionInput { + RecognitionInput { + policy_ref: "policy.pit.v1".to_owned(), + timing: RecognitionTiming::PointInTime, + po_allocation_group: Some("default".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: None, + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + } +} + +/// A context for `input`, with an explicit item amount + invoice total (for R4). +fn ctx_amt<'a>( + input: &'a RecognitionInput, + invoice_period: &'a str, + amount: i64, + invoice_total: i64, +) -> RecognitionContext<'a> { + RecognitionContext { + input, + invoice_period_id: invoice_period, + item_amount_minor_ex_tax: amount, + invoice_total_minor: invoice_total, + currency: "USD", + revenue_stream: "recurring", + } +} + +/// Derive with the three v1 default resolvers + `config`. +fn derive( + ctx: &RecognitionContext<'_>, + config: &RecognitionConfig, +) -> Result { + let policy = DefaultDeferralPolicyResolver; + let ssp = DefaultSspResolver; + let vc = DefaultVcResolver; + ScheduleBuilder::new(&policy, &ssp, &vc, config).derive(ctx) +} + +#[test] +fn straight_line_generates_n_segments_summing_to_deferred() { + let cfg = RecognitionConfig::default(); + let input = straight_line(12); + // 1000.00 over 12 → 11×83.33 + residual on last; Σ == 100_000. + let outcome = derive(&ctx_amt(&input, "202606", 100_000, 100_000), &cfg).unwrap(); + let ScheduleOutcome::Schedule(s) = outcome else { + panic!("expected a schedule"); + }; + assert_eq!(s.deferred_minor, 100_000); + assert_eq!(s.segments.len(), 12); + let sum: i64 = s.segments.iter().map(|seg| seg.amount_minor).sum(); + assert_eq!(sum, 100_000, "segments must sum to the deferred amount"); + // Residual cent lands on the LAST segment (allocate Residual::Last). + let last = s.segments.last().unwrap().amount_minor; + let first = s.segments.first().unwrap().amount_minor; + assert!(last >= first, "residual is placed on the last segment"); + assert_eq!(first, 8_333); + assert_eq!(last, 8_337); // 100_000 - 11*8_333 +} + +#[test] +fn straight_line_lays_out_consecutive_periods_from_invoice_period() { + let cfg = RecognitionConfig::default(); + let input = straight_line(3); + let outcome = derive(&ctx_amt(&input, "202611", 300, 300), &cfg).unwrap(); + let ScheduleOutcome::Schedule(s) = outcome else { + panic!("expected a schedule"); + }; + let periods: Vec<&str> = s.segments.iter().map(|x| x.period_id.as_str()).collect(); + // From 2026-11, three consecutive months crossing the year boundary. + assert_eq!(periods, vec!["202611", "202612", "202701"]); + // segment_no is 1-based and 1:1 with period order. + assert_eq!( + s.segments.iter().map(|x| x.segment_no).collect::>(), + vec![1, 2, 3] + ); + // Stamped refs flow through from the input + context. + assert_eq!(s.policy_ref, "policy.sl.v1"); + assert_eq!(s.po_allocation_group.as_deref(), Some("grp")); + assert_eq!(s.subscription_ref.as_deref(), Some("sub.1")); + assert_eq!(s.revenue_stream, "recurring"); + assert_eq!(s.currency, "USD"); +} + +#[test] +fn point_in_time_yields_no_deferral() { + let cfg = RecognitionConfig::default(); + let input = point_in_time(); + let outcome = derive(&ctx_amt(&input, "202606", 5_000, 500_000), &cfg).unwrap(); + assert_eq!(outcome, ScheduleOutcome::NoDeferral); + assert_eq!(outcome.deferred_minor(), 0); +} + +#[test] +fn r4_exemption_just_under_threshold_recognizes_now() { + // invoice_total = 500_000 ⇒ 1% leg = 5_000 (< 10_000 USD floor) ⇒ threshold + // = 5_000. A SKU-flagged straight-line item of exactly 5_000 is immaterial + // (<=), so it recognizes now (no schedule) despite the deferring timing. + let cfg = RecognitionConfig::default(); + let input = RecognitionInput { + immaterial_one_shot_sku: true, + ..straight_line(12) + }; + let outcome = derive(&ctx_amt(&input, "202606", 5_000, 500_000), &cfg).unwrap(); + assert_eq!( + outcome, + ScheduleOutcome::NoDeferral, + "at/under the materiality threshold ⇒ exempt" + ); +} + +#[test] +fn r4_exemption_just_over_threshold_defers() { + // One minor unit over the 5_000 threshold ⇒ material ⇒ a real schedule. + let cfg = RecognitionConfig::default(); + let input = RecognitionInput { + immaterial_one_shot_sku: true, + ..straight_line(12) + }; + let outcome = derive(&ctx_amt(&input, "202606", 5_001, 500_000), &cfg).unwrap(); + assert!( + matches!(outcome, ScheduleOutcome::Schedule(_)), + "just over the threshold ⇒ not exempt, must defer" + ); +} + +#[test] +fn r4_exemption_needs_the_sku_flag() { + // Under the threshold but NOT SKU-flagged ⇒ no exemption, defers normally. + let cfg = RecognitionConfig::default(); + let input = straight_line(12); // immaterial_one_shot_sku = false + let outcome = derive(&ctx_amt(&input, "202606", 1_000, 500_000), &cfg).unwrap(); + assert!( + matches!(outcome, ScheduleOutcome::Schedule(_)), + "exemption requires the SKU flag, not just a small amount" + ); +} + +#[test] +fn multi_po_without_ssp_ref_blocks() { + let cfg = RecognitionConfig::default(); + let input = RecognitionInput { + multi_po: true, + ssp_snapshot_ref: None, + ..straight_line(6) + }; + let err = derive(&ctx_amt(&input, "202606", 60_000, 60_000), &cfg).unwrap_err(); + assert!(matches!(err, DomainError::SspSnapshotRequired(_))); +} + +#[test] +fn multi_po_with_ssp_ref_builds_and_stamps_it() { + let cfg = RecognitionConfig::default(); + let input = RecognitionInput { + multi_po: true, + ssp_snapshot_ref: Some("ssp.pinned.v2".to_owned()), + ..straight_line(6) + }; + let outcome = derive(&ctx_amt(&input, "202606", 60_000, 60_000), &cfg).unwrap(); + let ScheduleOutcome::Schedule(s) = outcome else { + panic!("expected a schedule"); + }; + assert_eq!(s.ssp_snapshot_ref.as_deref(), Some("ssp.pinned.v2")); + assert_eq!(s.segments.len(), 6); +} + +#[test] +fn segments_over_ceiling_block_with_schedule_too_long() { + // Default ceiling is 120; 121 segments must block. + let cfg = RecognitionConfig::default(); + let input = straight_line(121); + let err = derive(&ctx_amt(&input, "202606", 121_000, 121_000), &cfg).unwrap_err(); + assert!(matches!(err, DomainError::ScheduleTooLong(_))); +} + +#[test] +fn segments_at_ceiling_are_allowed() { + // Exactly at the ceiling is fine (the guard is strictly-greater-than). + let cfg = RecognitionConfig { + max_segments_per_schedule: 3, + ..RecognitionConfig::default() + }; + let input = straight_line(3); + let outcome = derive(&ctx_amt(&input, "202606", 300, 300), &cfg).unwrap(); + assert!(matches!(outcome, ScheduleOutcome::Schedule(_))); + // One over the lowered ceiling blocks. + let input4 = straight_line(4); + let err = derive(&ctx_amt(&input4, "202606", 400, 400), &cfg).unwrap_err(); + assert!(matches!(err, DomainError::ScheduleTooLong(_))); +} + +#[test] +fn zero_periods_is_rejected() { + let cfg = RecognitionConfig::default(); + let input = straight_line(0); + let err = derive(&ctx_amt(&input, "202606", 100, 100), &cfg).unwrap_err(); + assert!(matches!(err, DomainError::AmountOutOfRange(_))); +} + +#[test] +fn negative_amount_is_rejected() { + let cfg = RecognitionConfig::default(); + let input = straight_line(3); + let err = derive(&ctx_amt(&input, "202606", -1, 100), &cfg).unwrap_err(); + assert!(matches!(err, DomainError::AmountOutOfRange(_))); +} + +#[test] +fn built_schedule_exposes_the_fields_the_sidecar_needs() { + // The Group C sidecar (infra) builds the repo rows from these public fields; + // assert they are populated for a representative straight-line schedule. + let cfg = RecognitionConfig::default(); + let input = straight_line(2); + let outcome = derive(&ctx_amt(&input, "202606", 200, 200), &cfg).unwrap(); + let ScheduleOutcome::Schedule(s) = outcome else { + panic!("expected a schedule"); + }; + assert_eq!(s.deferred_minor, 200); + assert_eq!(s.policy_ref, "policy.sl.v1"); + assert_eq!(s.revenue_stream, "recurring"); + assert_eq!(s.currency, "USD"); + assert_eq!(s.subscription_ref.as_deref(), Some("sub.1")); + assert_eq!(s.segments.len(), 2); + assert_eq!(s.segments[0].segment_no, 1); + assert_eq!(s.segments[0].period_id, "202606"); + assert_eq!(s.segments[1].period_id, "202607"); + assert_eq!(s.segments.iter().map(|x| x.amount_minor).sum::(), 200); +} diff --git a/gears/bss/ledger/ledger/src/domain/recognition/change.rs b/gears/bss/ledger/ledger/src/domain/recognition/change.rs new file mode 100644 index 000000000..893c76946 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/recognition/change.rs @@ -0,0 +1,99 @@ +//! Pure schedule-change vocabulary (design §3.6 / §4.6, Group H): the +//! modification **action** (`cancel` / `replace`) and the upstream +//! modification-accounting **treatment** gate. No DB / txn / async I/O — the +//! infra `RecognitionChangeService` applies the durable transition; this module +//! only parses the wire literals and decides whether the treatment lets the +//! change proceed. +//! +//! **Treatment gate (the §3.6 invariant).** A schedule modification is applied to +//! the ledger ONLY when upstream has decided it is `prospective` or a +//! `separate_contract` (both ⇒ apply prospectively / mint a new version). A +//! `catch_up` modification — or any unknown/unmarked treatment — is NEVER silently +//! treated as prospective: it is surfaced as +//! [`DomainError::ModificationTreatmentReview`] with NO state change, because the +//! ledger does not own the catch-up (cumulative true-up) decision (durable +//! exception-queue handling is Slice 7; v1 surfaces the rejection). The gate runs +//! FIRST, before any schedule read or mutation. + +use toolkit_macros::domain_model; + +use crate::domain::error::DomainError; + +/// The change action a [`crate::api::rest`] request names: cancel the schedule +/// outright, or replace it with a new prospective version. Parsed from the wire +/// `action` literal at the service boundary. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChangeAction { + /// Mark the ACTIVE schedule `CANCELLED`; the unreleased deferred remainder + /// stays as `CONTRACT_LIABILITY` (no auto-reversal in v1). + Cancel, + /// Mark the ACTIVE schedule `REPLACED` and mint a new ACTIVE version that + /// re-plans the remaining deferred over the supplied segments (prospective). + Replace, +} + +/// The `cancel` wire literal. +const ACTION_CANCEL: &str = "cancel"; +/// The `replace` wire literal. +const ACTION_REPLACE: &str = "replace"; + +impl ChangeAction { + /// Parse a wire `action` literal (`"cancel"` | `"replace"`), case-sensitive. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] for any other literal. + pub fn parse(literal: &str) -> Result { + match literal { + ACTION_CANCEL => Ok(Self::Cancel), + ACTION_REPLACE => Ok(Self::Replace), + other => Err(DomainError::InvalidRequest(format!( + "unknown schedule-change action {other:?} (expected \"cancel\" or \"replace\")" + ))), + } + } +} + +/// The upstream modification-accounting treatment that lets a change PROCEED +/// (design §3.6). Only these two apply directly; every other value +/// (`catch_up` / unknown) is a review, not a treatment. +#[domain_model] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProceedTreatment { + /// Apply the modification prospectively (the remaining deferred is re-planned + /// forward; already-recognized revenue is not unwound). + Prospective, + /// Account the modification as a separate contract (treated, for the ledger's + /// purposes, the same as prospective: the old schedule terminates / is + /// superseded and a fresh schedule carries the remaining obligation). + SeparateContract, +} + +/// The `prospective` treatment literal. +const TREATMENT_PROSPECTIVE: &str = "prospective"; +/// The `separate_contract` treatment literal. +const TREATMENT_SEPARATE_CONTRACT: &str = "separate_contract"; + +/// Gate the upstream `treatment` literal (design §3.6 — runs FIRST, before any +/// schedule state is read or mutated). `"prospective"` / `"separate_contract"` +/// ⇒ proceed; `"catch_up"` or any unknown/unmarked value ⇒ surface for review +/// ([`DomainError::ModificationTreatmentReview`]) with NO state change. The ledger +/// never silently treats a modification as prospective. +/// +/// # Errors +/// [`DomainError::ModificationTreatmentReview`] when `treatment` is not one of the +/// two proceed treatments (incl. `"catch_up"` and any unknown literal). +pub fn gate_treatment(treatment: &str) -> Result { + match treatment { + TREATMENT_PROSPECTIVE => Ok(ProceedTreatment::Prospective), + TREATMENT_SEPARATE_CONTRACT => Ok(ProceedTreatment::SeparateContract), + other => Err(DomainError::ModificationTreatmentReview(format!( + "treatment {other:?} is not auto-prospective (expected \"prospective\" or \ + \"separate_contract\"); a catch-up / unknown modification needs upstream review" + ))), + } +} + +#[cfg(test)] +#[path = "change_tests.rs"] +mod change_tests; diff --git a/gears/bss/ledger/ledger/src/domain/recognition/change_tests.rs b/gears/bss/ledger/ledger/src/domain/recognition/change_tests.rs new file mode 100644 index 000000000..b6b0e1285 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/recognition/change_tests.rs @@ -0,0 +1,61 @@ +//! Unit tests for the pure schedule-change vocabulary (Group H4): the treatment +//! gate (the §3.6 invariant — `catch_up`/unknown never auto-proceeds) and the +//! action parser. +#![allow(clippy::unwrap_used, clippy::panic)] + +use super::{ChangeAction, ProceedTreatment, gate_treatment}; +use crate::domain::error::DomainError; + +#[test] +fn prospective_proceeds() { + assert_eq!( + gate_treatment("prospective").unwrap(), + ProceedTreatment::Prospective + ); +} + +#[test] +fn separate_contract_proceeds() { + assert_eq!( + gate_treatment("separate_contract").unwrap(), + ProceedTreatment::SeparateContract + ); +} + +#[test] +fn catch_up_is_review() { + let err = gate_treatment("catch_up").expect_err("catch_up must not proceed"); + assert!( + matches!(err, DomainError::ModificationTreatmentReview(_)), + "catch_up must surface as ModificationTreatmentReview, got {err:?}" + ); +} + +#[test] +fn unknown_treatment_is_review() { + for literal in ["", "PROSPECTIVE", "retrospective", "nonsense"] { + let err = gate_treatment(literal).expect_err("unknown treatment must not proceed"); + assert!( + matches!(err, DomainError::ModificationTreatmentReview(_)), + "treatment {literal:?} must be a review, got {err:?}" + ); + } +} + +#[test] +fn action_parses_cancel_and_replace() { + assert_eq!(ChangeAction::parse("cancel").unwrap(), ChangeAction::Cancel); + assert_eq!( + ChangeAction::parse("replace").unwrap(), + ChangeAction::Replace + ); +} + +#[test] +fn action_rejects_unknown() { + let err = ChangeAction::parse("delete").expect_err("unknown action rejected"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "unknown action must be InvalidRequest, got {err:?}" + ); +} diff --git a/gears/bss/ledger/ledger/src/domain/recognition/input.rs b/gears/bss/ledger/ledger/src/domain/recognition/input.rs new file mode 100644 index 000000000..c9c15b4e7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/recognition/input.rs @@ -0,0 +1,117 @@ +//! [`RecognitionInput`] — the per-invoice-item recognition spec (the approved v1 +//! interface). A **domain** value type, not a REST DTO: the invoice-post request +//! carries an optional spec per item, the glue maps it into this shape, and the +//! [`ScheduleBuilder`](super::builder::ScheduleBuilder) derives the schedule plan +//! from it. An item with **no** [`RecognitionInput`] is recognized now +//! (`deferred = 0`, today's Variant-A behaviour) — absence is the default, not an +//! error. +//! +//! v1 semantics (pinned): an item is **all** point-in-time **or** **all** +//! straight-line. There is no partial-defer-per-item and no milestone timing; +//! the whole ex-tax amount either recognizes now or defers in full to +//! `CONTRACT_LIABILITY` over N equal segments. Partial defer + milestone timing +//! are a later refinement. + +use toolkit_macros::domain_model; + +/// Recognition timing for one invoice item — **all** point-in-time or **all** +/// straight-line (the v1 pin). The `POINT_IN_TIME` / `STRAIGHT_LINE` wire forms +/// are the two timing patterns R2 may resolve to. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RecognitionTiming { + /// No deferral — the whole ex-tax amount recognizes at invoice + /// (`deferred = 0`). Equivalent to the absence of a spec, but explicit (a + /// policy may resolve `POINT_IN_TIME` deliberately, e.g. a one-time setup fee). + PointInTime, + /// Straight-line deferral — the whole ex-tax amount defers to + /// `CONTRACT_LIABILITY` and recognizes in `periods` equal segments + /// (residual cent on the last), the first segment landing in + /// `first_period_id`. `periods` must be `>= 1`; `first_period_id` is a + /// `YYYYMM` fiscal period. When `first_period_id` is `None` the + /// [`DeferralPolicyResolver`](super::ports::DeferralPolicyResolver) fills it + /// from the invoice period (the default resolver does so). + StraightLine { + /// Number of equal recognition segments (`>= 1`). + periods: u32, + /// First fiscal period (`YYYYMM`) the schedule recognizes into; `None` ⇒ + /// the resolver defaults it to the invoice period. + first_period_id: Option, + }, +} + +impl RecognitionTiming { + /// `true` iff this timing defers any amount (i.e. is straight-line). A + /// `POINT_IN_TIME` item never produces a schedule. + #[must_use] + pub const fn is_deferred(&self) -> bool { + matches!(self, Self::StraightLine { .. }) + } +} + +/// The optional per-item recognition spec (the v1 interface). `policy_ref` is +/// stamped immutably on the materialized schedule (historical immutability, +/// design §4.2); `timing` decides `POINT_IN_TIME` vs `STRAIGHT_LINE`. The optional +/// refs/flags carry PO/SSP/VC and subscription context through to the schedule. +/// +/// "Multi-PO" — the condition that makes a missing SSP snapshot a block (§4.4) — +/// is modeled by the explicit [`Self::multi_po`] flag rather than inferred from +/// `po_allocation_group`: an ordinary point-in-time line carries a (default) +/// allocation group but is **not** multi-PO, so inferring multi-PO from the +/// group's presence would over-block routine billing. The upstream caller sets +/// `multi_po = true` only for a genuine multi-performance-obligation line; v1 +/// keeps the condition this simple and documented (the SSP gate then fires only +/// when `multi_po && ssp_snapshot_ref` is absent). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +// The `*_ref` / `*_group` fields mirror the `recognition_schedule` column names +// verbatim (the storage contract); renaming to satisfy `struct_field_names` +// would diverge from `NewSchedule`. +#[allow(clippy::struct_field_names)] +pub struct RecognitionInput { + /// The deferral+timing policy version stamped immutably on the schedule + /// (design §4.2). Resolved/validated by the + /// [`DeferralPolicyResolver`](super::ports::DeferralPolicyResolver). + pub policy_ref: String, + /// The resolved recognition timing (`POINT_IN_TIME` or `STRAIGHT_LINE`). + pub timing: RecognitionTiming, + /// The PO / allocation group this line books under (audit, §4.7). A Catalog + /// **default** group auto-tags ordinary lines; `None` is allowed for a + /// non-multi-PO point-in-time line. + pub po_allocation_group: Option, + /// `true` for a genuine multi-performance-obligation line — the only case + /// where a missing/unresolvable SSP snapshot blocks the post (§4.4). A + /// single-PO line leaves this `false`. + pub multi_po: bool, + /// The SSP snapshot ref pinned at contract inception and reused per invoice + /// (R3 / N-revrec-2). Required to be present + resolvable for a `multi_po` + /// line; `None`/empty on such a line ⇒ `SspSnapshotRequired`. + pub ssp_snapshot_ref: Option, + /// The subscription/entitlement this obligation belongs to, threaded onto the + /// schedule for audit (§4.7); `None` when not subscription-scoped. + pub subscription_ref: Option, + /// Variable-consideration estimate ref (carried only — VC posting is OUT of + /// the MVP, N-revrec-4). + pub vc_estimate_ref: Option, + /// Variable-consideration method ref (carried only — VC posting is OUT of + /// the MVP, N-revrec-4). + pub vc_method_ref: Option, + /// `true` iff the Catalog SKU is flagged immaterial-one-shot-eligible — a + /// precondition of the R4 exemption (point-in-time, under the threshold, AND + /// SKU-flagged). v1 carries the flag on the input; absence ⇒ not eligible. + pub immaterial_one_shot_sku: bool, +} + +impl RecognitionInput { + /// `true` iff a missing/unresolvable SSP snapshot must block this line: a + /// `multi_po` line whose `ssp_snapshot_ref` is absent or blank (§4.4). A + /// single-PO line never trips this (documented pricing suffices, R3). + #[must_use] + pub fn ssp_snapshot_missing(&self) -> bool { + self.multi_po && self.ssp_snapshot_ref.as_deref().is_none_or(str::is_empty) + } +} + +#[cfg(test)] +#[path = "input_tests.rs"] +mod input_tests; diff --git a/gears/bss/ledger/ledger/src/domain/recognition/input_tests.rs b/gears/bss/ledger/ledger/src/domain/recognition/input_tests.rs new file mode 100644 index 000000000..b0bbbb7b5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/recognition/input_tests.rs @@ -0,0 +1,69 @@ +//! Tests for the recognition input value type — the timing predicate and the +//! SSP-missing decision (the input-level half of the §4.4 gate). + +use super::*; + +/// A point-in-time spec (no deferral), single-PO. +fn point_in_time() -> RecognitionInput { + RecognitionInput { + policy_ref: "policy.v1".to_owned(), + timing: RecognitionTiming::PointInTime, + po_allocation_group: Some("default".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: None, + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + } +} + +#[test] +fn point_in_time_is_not_deferred() { + assert!(!RecognitionTiming::PointInTime.is_deferred()); +} + +#[test] +fn straight_line_is_deferred() { + let t = RecognitionTiming::StraightLine { + periods: 12, + first_period_id: None, + }; + assert!(t.is_deferred()); +} + +#[test] +fn single_po_never_requires_ssp() { + let input = point_in_time(); + assert!(!input.ssp_snapshot_missing()); +} + +#[test] +fn multi_po_without_ref_is_ssp_missing() { + let input = RecognitionInput { + multi_po: true, + ssp_snapshot_ref: None, + ..point_in_time() + }; + assert!(input.ssp_snapshot_missing()); +} + +#[test] +fn multi_po_with_blank_ref_is_ssp_missing() { + let input = RecognitionInput { + multi_po: true, + ssp_snapshot_ref: Some(String::new()), + ..point_in_time() + }; + assert!(input.ssp_snapshot_missing()); +} + +#[test] +fn multi_po_with_ref_is_present() { + let input = RecognitionInput { + multi_po: true, + ssp_snapshot_ref: Some("ssp.snap.v3".to_owned()), + ..point_in_time() + }; + assert!(!input.ssp_snapshot_missing()); +} diff --git a/gears/bss/ledger/ledger/src/domain/recognition/ports.rs b/gears/bss/ledger/ledger/src/domain/recognition/ports.rs new file mode 100644 index 000000000..c7b72dd77 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/recognition/ports.rs @@ -0,0 +1,206 @@ +//! Resolver port traits for recognition derivation (design §4.2 / §4.4) + their +//! v1 config/input-backed default impls. +//! +//! Three ports, each a **sync** trait (mirroring +//! [`crate::domain::ports::metrics`]): the v1 derivation reads only **local** +//! facts (the post request's item attributes + tenant-config defaults), so there +//! is no async I/O to model and a sync trait keeps the +//! [`ScheduleBuilder`](super::builder::ScheduleBuilder) callable from the in-txn +//! Group C sidecar without an executor. +//! +//! - [`DeferralPolicyResolver`] resolves deferral + timing per the **R1/R2** +//! precedence (Contract → Catalog SKU/Plan → PO type → billing model; same +//! dimension → Contract wins; unresolvable → block). The v1 +//! [`DefaultDeferralPolicyResolver`] uses the timing already on the input and +//! fills a `STRAIGHT_LINE` schedule's missing `first_period_id` from the +//! invoice period — but the trait signature is **precedence-capable**: it +//! takes the whole [`RecognitionContext`] (input + invoice period + the +//! dimensions a future impl needs), so a Contract→Catalog→PO-type→billing-model +//! implementation drops in without a signature change. +//! - [`SspResolver`] validates the SSP-snapshot ref presence for a multi-PO line +//! (§4.4) — the per-post presence guard, not a fresh SSP pick (the value is +//! pinned at inception, N-revrec-2). +//! - [`VcResolver`] is minimal — VC posting is OUT of the MVP (N-revrec-4); it +//! only carries the VC refs through to the schedule. +//! +//! The defaults are **constructed with the tenant-config defaults they need** +//! (here: nothing beyond the invoice period, which arrives per-call on the +//! context) so a future config-backed impl is a drop-in. They perform **no** +//! network call and read **no** snapshot tables — those are deferred (design +//! §13 / I-6). + +use toolkit_macros::domain_model; + +use crate::domain::error::DomainError; +use crate::domain::period::period_id_plus; +use crate::domain::recognition::input::{RecognitionInput, RecognitionTiming}; + +/// The local context the resolvers + builder derive over: the per-item spec plus +/// the invoice facts needed to fill defaults and apply the R4 exemption. Pure +/// data — no DB handle, no snapshot table (those are deferred). +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecognitionContext<'a> { + /// The per-item recognition spec (the v1 interface). + pub input: &'a RecognitionInput, + /// The invoice's fiscal `period_id` (`YYYYMM`) — the default first segment + /// period for a `STRAIGHT_LINE` schedule whose `first_period_id` is `None`. + pub invoice_period_id: &'a str, + /// This item's ex-tax amount in minor units (the whole-amount that defers / + /// recognizes). Must be `>= 0`. + pub item_amount_minor_ex_tax: i64, + /// The invoice's gross total in minor units — the denominator of the R4 + /// immaterial-one-shot exemption (`<= 1% of invoice total`). + pub invoice_total_minor: i64, + /// The item's ISO currency (stamped on the schedule; the R4 100-USD-equiv leg + /// is evaluated against it — v1 treats the minor amount as USD-equivalent, + /// see [`super::builder`]). + pub currency: &'a str, + /// The item's revenue stream (one schedule per stream, §4.5). + pub revenue_stream: &'a str, +} + +/// The deferral + timing a [`DeferralPolicyResolver`] resolved for an item: the +/// immutable `policy_ref` to stamp and the concrete [`RecognitionTiming`] with +/// any default (e.g. `first_period_id`) already filled. The builder turns this +/// into the schedule plan. +#[domain_model] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedPolicy { + /// The immutable deferral+timing policy version stamped on the schedule. + pub policy_ref: String, + /// The resolved timing — `first_period_id` filled when it was `None` on the + /// input. + pub timing: RecognitionTiming, +} + +/// Resolves deferral + timing for one item per the R1/R2 precedence. The +/// signature is precedence-capable (it takes the whole [`RecognitionContext`]), +/// so a future Contract→Catalog→PO-type→billing-model impl needs no signature +/// change — only the body changes. +pub trait DeferralPolicyResolver: Send + Sync + 'static { + /// Resolve the deferral+timing policy for the item in `ctx`. + /// + /// # Errors + /// [`DomainError::RecognitionPolicyConflict`] when R1/R2 cannot be resolved + /// unambiguously (a same-dimension conflict that does not reduce to "Contract + /// wins", or no resolvable policy at all). The v1 default never conflicts (it + /// trusts the input's timing), but the trait carries the contract for the + /// precedence-aware impls. + fn resolve(&self, ctx: &RecognitionContext<'_>) -> Result; +} + +/// Validates the SSP-snapshot ref presence for a multi-PO line (§4.4) — the +/// per-post presence guard. The SSP **value** is pinned at inception and reused +/// (N-revrec-2); this never re-picks a fresh SSP. +pub trait SspResolver: Send + Sync + 'static { + /// Resolve/validate the SSP snapshot ref for the item in `ctx`, returning the + /// ref to stamp (`None` for a single-PO line that needs none). + /// + /// # Errors + /// [`DomainError::SspSnapshotRequired`] when a multi-PO line's snapshot ref is + /// missing or unresolvable. + fn resolve(&self, ctx: &RecognitionContext<'_>) -> Result, DomainError>; +} + +/// Carries the variable-consideration refs through to the schedule. Minimal by +/// design — VC estimate / true-up posting is OUT of the MVP (N-revrec-4); this +/// port only threads the immutable refs, it never computes an estimate. +pub trait VcResolver: Send + Sync + 'static { + /// Resolve the `(vc_estimate_ref, vc_method_ref)` to stamp on the schedule + /// for the item in `ctx`. + /// + /// # Errors + /// [`DomainError`] — reserved for a future impl that validates VC evidence; + /// the v1 default is infallible (it echoes the input refs). + fn resolve( + &self, + ctx: &RecognitionContext<'_>, + ) -> Result<(Option, Option), DomainError>; +} + +/// v1 [`DeferralPolicyResolver`]: trusts the timing already on the input (R1/R2 +/// were decided upstream) and only fills a `STRAIGHT_LINE` schedule's missing +/// `first_period_id` from the invoice period. No network, no snapshot table. +/// A future precedence-aware impl replaces the body; the signature is unchanged. +#[domain_model] +#[derive(Clone, Copy, Debug, Default)] +pub struct DefaultDeferralPolicyResolver; + +impl DeferralPolicyResolver for DefaultDeferralPolicyResolver { + fn resolve(&self, ctx: &RecognitionContext<'_>) -> Result { + let timing = match &ctx.input.timing { + RecognitionTiming::PointInTime => RecognitionTiming::PointInTime, + RecognitionTiming::StraightLine { + periods, + first_period_id, + } => { + // Default the first segment period to the invoice period when the + // input left it unset (the common case — the upstream knows the + // term length, the ledger knows the period it is posting into). + let first = first_period_id + .clone() + .unwrap_or_else(|| ctx.invoice_period_id.to_owned()); + // Validate it is a usable YYYYMM up front (period_id_plus is the + // same validator the builder uses): an unparseable period is a + // policy-config defect, surfaced as a conflict rather than a panic + // deeper in segment layout. + if period_id_plus(&first, 0).is_none() { + return Err(DomainError::RecognitionPolicyConflict(format!( + "first_period_id `{first}` is not a valid YYYYMM period" + ))); + } + RecognitionTiming::StraightLine { + periods: *periods, + first_period_id: Some(first), + } + } + }; + Ok(ResolvedPolicy { + policy_ref: ctx.input.policy_ref.clone(), + timing, + }) + } +} + +/// v1 [`SspResolver`]: the per-post presence guard over the input ref. A +/// `multi_po` line with a missing/blank `ssp_snapshot_ref` blocks; otherwise the +/// (possibly `None`) ref is echoed back to stamp. No network, no snapshot table +/// (the inception-pinned value resolves locally in a later refinement). +#[domain_model] +#[derive(Clone, Copy, Debug, Default)] +pub struct DefaultSspResolver; + +impl SspResolver for DefaultSspResolver { + fn resolve(&self, ctx: &RecognitionContext<'_>) -> Result, DomainError> { + if ctx.input.ssp_snapshot_missing() { + return Err(DomainError::SspSnapshotRequired(format!( + "multi-PO line (stream `{}`) has no resolvable SSP snapshot ref", + ctx.revenue_stream + ))); + } + Ok(ctx.input.ssp_snapshot_ref.clone()) + } +} + +/// v1 [`VcResolver`]: echoes the input VC refs (carry-only — VC posting is OUT of +/// the MVP, N-revrec-4). Infallible. +#[domain_model] +#[derive(Clone, Copy, Debug, Default)] +pub struct DefaultVcResolver; + +impl VcResolver for DefaultVcResolver { + fn resolve( + &self, + ctx: &RecognitionContext<'_>, + ) -> Result<(Option, Option), DomainError> { + Ok(( + ctx.input.vc_estimate_ref.clone(), + ctx.input.vc_method_ref.clone(), + )) + } +} + +#[cfg(test)] +#[path = "ports_tests.rs"] +mod ports_tests; diff --git a/gears/bss/ledger/ledger/src/domain/recognition/ports_tests.rs b/gears/bss/ledger/ledger/src/domain/recognition/ports_tests.rs new file mode 100644 index 000000000..0ce1bbb01 --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/recognition/ports_tests.rs @@ -0,0 +1,137 @@ +//! Tests for the v1 default resolvers: the deferral resolver defaults a missing +//! `first_period_id` from the invoice period and rejects a malformed one; the SSP +//! resolver enforces the multi-PO presence gate; the VC resolver echoes the refs. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use crate::domain::error::DomainError; +use crate::domain::recognition::input::{RecognitionInput, RecognitionTiming}; + +fn input_with(timing: RecognitionTiming) -> RecognitionInput { + RecognitionInput { + policy_ref: "policy.straightline.v1".to_owned(), + timing, + po_allocation_group: Some("grp".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: None, + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + } +} + +fn ctx<'a>(input: &'a RecognitionInput, invoice_period: &'a str) -> RecognitionContext<'a> { + RecognitionContext { + input, + invoice_period_id: invoice_period, + item_amount_minor_ex_tax: 12_000, + invoice_total_minor: 12_000, + currency: "USD", + revenue_stream: "recurring", + } +} + +#[test] +fn default_resolver_fills_first_period_from_invoice_period() { + let input = input_with(RecognitionTiming::StraightLine { + periods: 12, + first_period_id: None, + }); + let resolved = DefaultDeferralPolicyResolver + .resolve(&ctx(&input, "202606")) + .unwrap(); + assert_eq!(resolved.policy_ref, "policy.straightline.v1"); + match resolved.timing { + RecognitionTiming::StraightLine { + periods, + first_period_id, + } => { + assert_eq!(periods, 12); + assert_eq!(first_period_id.as_deref(), Some("202606")); + } + RecognitionTiming::PointInTime => panic!("expected straight-line"), + } +} + +#[test] +fn default_resolver_keeps_explicit_first_period() { + let input = input_with(RecognitionTiming::StraightLine { + periods: 3, + first_period_id: Some("202701".to_owned()), + }); + let resolved = DefaultDeferralPolicyResolver + .resolve(&ctx(&input, "202606")) + .unwrap(); + match resolved.timing { + RecognitionTiming::StraightLine { + first_period_id, .. + } => assert_eq!(first_period_id.as_deref(), Some("202701")), + RecognitionTiming::PointInTime => panic!("expected straight-line"), + } +} + +#[test] +fn default_resolver_rejects_malformed_first_period() { + let input = input_with(RecognitionTiming::StraightLine { + periods: 3, + first_period_id: Some("nope".to_owned()), + }); + let err = DefaultDeferralPolicyResolver + .resolve(&ctx(&input, "202606")) + .unwrap_err(); + assert!(matches!(err, DomainError::RecognitionPolicyConflict(_))); +} + +#[test] +fn default_resolver_passes_point_in_time_through() { + let input = input_with(RecognitionTiming::PointInTime); + let resolved = DefaultDeferralPolicyResolver + .resolve(&ctx(&input, "202606")) + .unwrap(); + assert_eq!(resolved.timing, RecognitionTiming::PointInTime); +} + +#[test] +fn ssp_resolver_blocks_multi_po_without_ref() { + let input = RecognitionInput { + multi_po: true, + ssp_snapshot_ref: None, + ..input_with(RecognitionTiming::PointInTime) + }; + let err = DefaultSspResolver + .resolve(&ctx(&input, "202606")) + .unwrap_err(); + assert!(matches!(err, DomainError::SspSnapshotRequired(_))); +} + +#[test] +fn ssp_resolver_passes_multi_po_with_ref() { + let input = RecognitionInput { + multi_po: true, + ssp_snapshot_ref: Some("ssp.v9".to_owned()), + ..input_with(RecognitionTiming::PointInTime) + }; + let got = DefaultSspResolver.resolve(&ctx(&input, "202606")).unwrap(); + assert_eq!(got.as_deref(), Some("ssp.v9")); +} + +#[test] +fn ssp_resolver_passes_single_po_none() { + let input = input_with(RecognitionTiming::PointInTime); + let got = DefaultSspResolver.resolve(&ctx(&input, "202606")).unwrap(); + assert_eq!(got, None); +} + +#[test] +fn vc_resolver_echoes_refs() { + let input = RecognitionInput { + vc_estimate_ref: Some("vc.est.1".to_owned()), + vc_method_ref: Some("vc.method.1".to_owned()), + ..input_with(RecognitionTiming::PointInTime) + }; + let (est, method) = DefaultVcResolver.resolve(&ctx(&input, "202606")).unwrap(); + assert_eq!(est.as_deref(), Some("vc.est.1")); + assert_eq!(method.as_deref(), Some("vc.method.1")); +} diff --git a/gears/bss/ledger/ledger/src/domain/scale.rs b/gears/bss/ledger/ledger/src/domain/scale.rs new file mode 100644 index 000000000..5d10ae53b --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/scale.rs @@ -0,0 +1,38 @@ +//! ISO-4217 default minor-unit scales. A `(tenant, currency)` registry +//! row overrides these; a non-ISO currency with no row has no implicit +//! scale (resolution errors). This table is the fallback only. + +/// ISO-4217 minor-unit exponent for well-known currencies. `None` means +/// "not a known ISO code" — the caller must consult the registry. +#[must_use] +pub fn iso_default_scale(currency: &str) -> Option { + let scale = match currency { + // exponent 0 + "JPY" | "KRW" | "CLP" | "VND" | "ISK" | "HUF" => 0, + // exponent 3 + "BHD" | "IQD" | "JOD" | "KWD" | "LYD" | "OMR" | "TND" => 3, + // exponent 2 — the common case + "USD" | "EUR" | "GBP" | "CHF" | "CAD" | "AUD" | "CNY" | "SEK" | "NOK" | "DKK" | "PLN" + | "INR" | "BRL" | "ZAR" => 2, + _ => return None, + }; + Some(scale) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_iso_scales() { + assert_eq!(iso_default_scale("USD"), Some(2)); + assert_eq!(iso_default_scale("JPY"), Some(0)); + assert_eq!(iso_default_scale("BHD"), Some(3)); + } + + #[test] + fn unknown_currency_has_no_default() { + assert_eq!(iso_default_scale("XBT"), None); + assert_eq!(iso_default_scale("ZZZ"), None); + } +} diff --git a/gears/bss/ledger/ledger/src/domain/status.rs b/gears/bss/ledger/ledger/src/domain/status.rs new file mode 100644 index 000000000..1c0a5797d --- /dev/null +++ b/gears/bss/ledger/ledger/src/domain/status.rs @@ -0,0 +1,103 @@ +//! Canonical status / lifecycle wire literals, grouped by domain. +//! +//! These string tokens are part of the persisted contract (DB column values + +//! CHECK constraints + cross-gear payloads), so the **values never change** — +//! this module only gives the formerly-scattered duplicate definitions a single +//! source of truth. Each group below is ONE concept: same text under a different +//! group is a DIFFERENT constant on purpose (e.g. period `OPEN` ≠ account +//! `OPEN`, schedule `ACTIVE` ≠ AR `ACTIVE`), and must not be cross-referenced. +//! +//! State *machines* that already have a domain enum keep it (the enum is the +//! source of truth, e.g. [`crate::domain::approval::ApprovalState`], +//! [`crate::domain::payment::chargeback::DisputePhase`]); these bare consts are +//! for the columns that are a free token with a CHECK, not an enum. + +// --- AR sub-class (`ar_status` on an AR line; the chargeback reclass seam) --- + +/// AR sub-class stamped on the `DISPUTED` leg of a chargeback AR-reclass; the +/// projector routes a `DISPUTED`-tagged line's signed delta onto +/// `ar_invoice_balance.disputed_minor`. +pub(crate) const AR_STATUS_DISPUTED: &str = "DISPUTED"; +/// AR sub-class stamped on the `ACTIVE` (counter) leg of a chargeback +/// AR-reclass — the normal, undisputed receivable. +pub(crate) const AR_STATUS_ACTIVE: &str = "ACTIVE"; + +// --- Recognition schedule status (`recognition_schedule.status`) --- + +/// The `ACTIVE` schedule status — the one live state per business key (the +/// partial UNIQUE predicate). Only an ACTIVE schedule carries a reducible +/// deferred remainder. The terminal states (`COMPLETED`/`REPLACED`/`CANCELLED`) +/// are stamped by recognition completion / the Group H change path. +pub(crate) const SCHEDULE_STATUS_ACTIVE: &str = "ACTIVE"; + +/// The `COMPLETED` schedule status — a fully-recognized schedule (every segment +/// `DONE`, `recognized_minor == total_deferred_minor`) that has reached its +/// terminal state (design §4.6). Terminal: it leaves the partial +/// `UNIQUE … WHERE status='ACTIVE'` one-live slot free, drops out of the +/// runner's ACTIVE-only due-segment feed, and out of the +/// `ledger_schedule_active_total` gauge. +pub(crate) const SCHEDULE_STATUS_COMPLETED: &str = "COMPLETED"; + +/// The `REPLACED` schedule status — a schedule superseded by a new version (a +/// fresh `schedule_id`) via a Group H `replace` change (design §3.6). Terminal: +/// the runner does not release a `REPLACED` schedule's remaining segments. +pub(crate) const SCHEDULE_STATUS_REPLACED: &str = "REPLACED"; + +/// The `CANCELLED` schedule status — a schedule cancelled outright via a Group H +/// `cancel` change (design §3.6). Terminal: the runner does not release a +/// `CANCELLED` schedule's remaining segments (the unreleased deferred remainder +/// stays as `CONTRACT_LIABILITY`; no auto-reversal in v1). +pub(crate) const SCHEDULE_STATUS_CANCELLED: &str = "CANCELLED"; + +// --- Recognition segment status (`recognition_segment.status`) --- + +/// The `PENDING` segment status — a not-yet-released slice (the seed state, and +/// the state the `RecognitionRunner` releases from). +pub(crate) const SEGMENT_STATUS_PENDING: &str = "PENDING"; + +/// The `QUEUED` segment status — a due slice parked out-of-order by Group E +/// (its predecessor period was not yet `DONE`). A later run drains it; for the +/// stamp guard it is a second releasable-from state alongside `PENDING`. +pub(crate) const SEGMENT_STATUS_QUEUED: &str = "QUEUED"; + +/// The `DONE` segment status — a released slice (its `DR CL / CR Revenue` entry +/// posted, `recognized_at`/`run_id` stamped). Terminal; the stamp guard refuses +/// to re-flip it (the at-most-once release guard). +pub(crate) const SEGMENT_STATUS_DONE: &str = "DONE"; + +// --- Recognition run status (`recognition_run.status`) --- + +/// The `RUNNING` recognition-run status — a run in progress (bracketed by +/// `insert_run` → `finish_run`). The single-active-run guard is the `coord` +/// lease, not a `RUNNING`-row count; this status is purely the run-row +/// lifecycle marker. +pub(crate) const RUN_STATUS_RUNNING: &str = "RUNNING"; + +/// The `DONE` recognition-run status — a run that completed its pass. +pub(crate) const RUN_STATUS_DONE: &str = "DONE"; + +/// The `FAILED` recognition-run status — a run that aborted mid-pass. +pub(crate) const RUN_STATUS_FAILED: &str = "FAILED"; + +// --- Fiscal period status (`fiscal_period.status`) --- + +/// Fiscal-period status that admits posting (set at period-open; the +/// `FiscalPeriodGuard` requires it). +pub(crate) const PERIOD_STATUS_OPEN: &str = "OPEN"; +/// Fiscal-period status for a closed period (no posting; set by period close). +pub(crate) const PERIOD_STATUS_CLOSED: &str = "CLOSED"; + +// --- Account lifecycle (`tenant_account.lifecycle_state`) — NOT the period --- + +/// Account lifecycle state that admits posting — stamped on freshly-seeded +/// accounts and asserted by the posting / note surfaces before posting to an +/// account. A SEPARATE concept from the fiscal-period `OPEN` +/// ([`PERIOD_STATUS_OPEN`]): an account lifecycle, not a period status. +pub(crate) const LIFECYCLE_OPEN: &str = "OPEN"; + +// --- Payer lifecycle (`ledger_payer_state.lifecycle_state`) --- + +/// Payer lifecycle state stamped on a payer-closure upsert (VHP-1852 Phase 2). +/// Absence of a row means OPEN; the only persisted lifecycle literal is the +/// closed marker. A SEPARATE concept from the fiscal-period / account `OPEN`. +pub(crate) const PAYER_LIFECYCLE_CLOSED: &str = "CLOSED"; diff --git a/gears/bss/ledger/ledger/src/gts.rs b/gears/bss/ledger/ledger/src/gts.rs new file mode 100644 index 000000000..aab52b929 --- /dev/null +++ b/gears/bss/ledger/ledger/src/gts.rs @@ -0,0 +1,3 @@ +//! GTS instances declared by the BSS Ledger gear. + +pub mod permissions; diff --git a/gears/bss/ledger/ledger/src/gts/permissions.rs b/gears/bss/ledger/ledger/src/gts/permissions.rs new file mode 100644 index 000000000..41fb5d4e5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/gts/permissions.rs @@ -0,0 +1,262 @@ +//! BSS Ledger authorization permissions catalog. +//! +//! Declares every ledger-grantable permission as an [`AuthzPermissionV1`] GTS +//! instance via [`gts_instance!`]. Each invocation submits an +//! `InventoryInstance` entry; `types-registry::init()` aggregates and validates +//! them at startup — no registration code in [`crate::module`]. +//! +//! `resource_type` values are the authz labels from [`crate::authz`] — the same +//! strings the service paths pass to `PolicyEnforcer` at enforce time, so the +//! catalog and the enforcement path share one source of truth. All labels live +//! OUTSIDE `gts.cf.resources.*`, so only an explicit billing role covers them +//! (the `billing-setup` role grants `provision` + `read` on `ledger` + `close` +//! on `fiscal_period`). Each label is a real object (a noun), never an authz tier. +//! +//! Instance id layout (instance suffix needs ≥5 dot-separated tokens): +//! `gts.cf.toolkit.authz.permission.v1~cf.bss.ledger._.v1`. + +// The expected-id string literals (here and in the test below) trip DE0901 +// (`gts_string_pattern`, which hardcodes the allowed vendor set); they are +// legitimate catalog literals. Suppress file-wide, mirroring +// `rms/src/gts/permissions.rs`. +#![allow(unknown_lints)] +#![allow(de0901_gts_string_pattern)] + +use toolkit_gts::{AuthzPermissionV1, gts_instance}; + +use crate::authz::{actions, labels}; + +// ── entry — data plane (post / reverse / read) ─────────────────────────────── + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_post.v1", + resource_type: labels::ENTRY.to_owned(), + action: actions::POST.to_owned(), + display_name: "Post a ledger entry".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_reverse.v1", + resource_type: labels::ENTRY.to_owned(), + action: actions::REVERSE.to_owned(), + display_name: "Reverse a ledger entry".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_approve.v1", + resource_type: labels::ENTRY.to_owned(), + action: actions::APPROVE.to_owned(), + display_name: "Approve a dual-control ledger entry".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_read.v1", + resource_type: labels::ENTRY.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read ledger balances".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_annotate.v1", + resource_type: labels::ENTRY.to_owned(), + action: actions::ANNOTATE.to_owned(), + display_name: "Annotate an entry with a controlled non-financial note".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_audit_read.v1", + resource_type: labels::ENTRY.to_owned(), + action: actions::AUDIT_READ.to_owned(), + display_name: "Read the secured audit surface (incl. cross-tenant elevation)".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_erase.v1", + resource_type: labels::ENTRY.to_owned(), + action: actions::ERASE.to_owned(), + display_name: "Erase a payer's PII (GDPR right-to-erasure)".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_reidentify.v1", + resource_type: labels::ENTRY.to_owned(), + action: actions::REIDENTIFY.to_owned(), + display_name: "Re-identify a payer's PII reference (forensic)".to_owned(), + } +} + +// ── ledger — control plane: seed + read a seller's ledger ──────────────────── + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.ledger_provision.v1", + resource_type: labels::LEDGER.to_owned(), + action: actions::PROVISION.to_owned(), + display_name: "Provision a ledger".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.ledger_read.v1", + resource_type: labels::LEDGER.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read a ledger's chart of accounts".to_owned(), + } +} + +// ── fiscal_period — control plane: close a period ──────────────────────────── + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.fiscal_period_close.v1", + resource_type: labels::FISCAL_PERIOD.to_owned(), + action: actions::CLOSE.to_owned(), + display_name: "Close a fiscal period".to_owned(), + } +} + +// ── payment — data plane: settle / allocate + read allocations ─────────────── + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.payment_write.v1", + resource_type: labels::PAYMENT.to_owned(), + action: actions::WRITE.to_owned(), + display_name: "Settle or allocate a payment".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.payment_read.v1", + resource_type: labels::PAYMENT.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read payment allocations / unallocated".to_owned(), + } +} + +// ── credit_application — data plane: grant / apply reusable credit ─────────── + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.credit_application_write.v1", + resource_type: labels::CREDIT_APPLICATION.to_owned(), + action: actions::WRITE.to_owned(), + display_name: "Grant or apply reusable credit".to_owned(), + } +} + +// ── dispute — data plane: record a chargeback dispute phase ────────────────── + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.dispute_write.v1", + resource_type: labels::DISPUTE.to_owned(), + action: actions::WRITE.to_owned(), + display_name: "Record a chargeback dispute phase".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.dispute_read.v1", + resource_type: labels::DISPUTE.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read / list chargeback disputes".to_owned(), + } +} + +// ── dual_control_policy — config plane: read / write the threshold policy ───── + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.dual_control_policy_write.v1", + resource_type: labels::DUAL_CONTROL_POLICY.to_owned(), + action: actions::WRITE.to_owned(), + display_name: "Configure dual-control thresholds (D2/A6/TTL)".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.dual_control_policy_read.v1", + resource_type: labels::DUAL_CONTROL_POLICY.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read the effective dual-control policy".to_owned(), + } +} + +// ── ledger config plane: read / write tenant settings (posting policy + FX mode) ─ + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.config_write.v1", + resource_type: labels::LEDGER_CONFIG.to_owned(), + action: actions::WRITE.to_owned(), + display_name: "Configure ledger tenant settings (posting policy, FX revaluation mode)" + .to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.config_read.v1", + resource_type: labels::LEDGER_CONFIG.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read the effective ledger tenant settings".to_owned(), + } +} + +// ── recognition — revenue plane: trigger runs / change schedules + read ────── + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.recognition_write.v1", + resource_type: labels::RECOGNITION.to_owned(), + action: actions::WRITE.to_owned(), + display_name: "Trigger a recognition run or change a schedule".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.recognition_read.v1", + resource_type: labels::RECOGNITION.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read recognition runs / schedules / disaggregation".to_owned(), + } +} + +// ── reconciliation — Revenue Assurance: read the queue / trigger a recon run ── + +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.reconciliation_read.v1", + resource_type: labels::RECONCILIATION.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read the exception queue / reconciliation runs".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.reconciliation_run.v1", + resource_type: labels::RECONCILIATION.to_owned(), + action: actions::RUN.to_owned(), + display_name: "Trigger a reconciliation check".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.reconciliation_resolve.v1", + resource_type: labels::RECONCILIATION.to_owned(), + action: actions::RESOLVE.to_owned(), + display_name: "Resolve / acknowledge / approve a close-blocking exception".to_owned(), + } +} + +#[cfg(test)] +#[path = "permissions_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/gts/permissions_tests.rs b/gears/bss/ledger/ledger/src/gts/permissions_tests.rs new file mode 100644 index 000000000..e46959481 --- /dev/null +++ b/gears/bss/ledger/ledger/src/gts/permissions_tests.rs @@ -0,0 +1,110 @@ +//! Unit tests for the ledger GTS permission catalog (`gts::permissions`): every +//! `(resource_type, action)` instance is registered in inventory, the expected-id +//! set matches exactly, and the catalog's distinct `resource_type`s equal +//! `crate::authz::labels::ALL` (anti-drift). + +use toolkit_gts::InventoryInstance; + +const PERMISSION_TYPE_ID: &str = "gts.cf.toolkit.authz.permission.v1~"; +const INSTANCE_PREFIX: &str = "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger."; + +/// Every ledger permission instance id — one per `(resource_type, action)` +/// pair the ledger surfaces enforce. +const EXPECTED_PERMISSION_IDS: &[&str] = &[ + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_post.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_reverse.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_approve.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_read.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_annotate.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_audit_read.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_erase.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.entry_reidentify.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.ledger_provision.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.ledger_read.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.fiscal_period_close.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.payment_write.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.payment_read.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.credit_application_write.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.dispute_write.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.dispute_read.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.dual_control_policy_write.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.dual_control_policy_read.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.recognition_write.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.recognition_read.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.reconciliation_read.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.reconciliation_run.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.reconciliation_resolve.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.config_write.v1", + "gts.cf.toolkit.authz.permission.v1~cf.bss.ledger.config_read.v1", +]; + +fn ledger_permission_instances() -> Vec<&'static InventoryInstance> { + toolkit_gts::inventory::iter:: + .into_iter() + .filter(|e| e.instance_id.starts_with(INSTANCE_PREFIX)) + .collect() +} + +#[test] +fn all_ledger_permissions_are_registered_in_inventory() { + let entries = ledger_permission_instances(); + assert_eq!( + entries.len(), + EXPECTED_PERMISSION_IDS.len(), + "expected {} ledger permission instances; found {}: {:?}", + EXPECTED_PERMISSION_IDS.len(), + entries.len(), + entries.iter().map(|e| e.instance_id).collect::>() + ); + for entry in &entries { + assert_eq!( + entry.type_id, PERMISSION_TYPE_ID, + "instance {} derived wrong type_id", + entry.instance_id + ); + } +} + +#[test] +fn ledger_permission_inventory_covers_every_expected_id() { + let actual: std::collections::BTreeSet<&str> = ledger_permission_instances() + .iter() + .map(|e| e.instance_id) + .collect(); + for expected in EXPECTED_PERMISSION_IDS { + assert!( + actual.contains(expected), + "missing expected permission id: {expected}; got {actual:?}" + ); + } + assert_eq!( + actual.len(), + EXPECTED_PERMISSION_IDS.len(), + "inventory contains ledger permission ids not in the expected set" + ); +} + +/// Anti-drift: the distinct `resource_type`s this catalog grants MUST equal +/// `crate::authz::labels::ALL` — the set the gear registers stub type-schemas for +/// so RBAC role-definitions can target them. Add a permission with a new label (or +/// a label to `ALL`) without the other and this fails. +#[test] +fn catalog_resource_types_match_authz_labels_all() { + let catalog_types: std::collections::BTreeSet = ledger_permission_instances() + .iter() + .map(|e| { + (e.payload_fn)()["resource_type"] + .as_str() + .expect("AuthzPermissionV1 payload carries a resource_type string") + .to_owned() + }) + .collect(); + let labels_all: std::collections::BTreeSet = crate::authz::labels::ALL + .iter() + .map(|s| (*s).to_owned()) + .collect(); + assert_eq!( + catalog_types, labels_all, + "permission-catalog resource_types must equal crate::authz::labels::ALL" + ); +} diff --git a/gears/bss/ledger/ledger/src/infra.rs b/gears/bss/ledger/ledger/src/infra.rs new file mode 100644 index 000000000..959b839f8 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra.rs @@ -0,0 +1,28 @@ +//! Infrastructure layer. + +pub mod adjustment; +pub mod annotation; +pub mod approval; +pub mod audit; +pub mod authz; +pub mod control_feed; +pub mod currency_scale; +pub mod error_mapping; +pub mod events; +pub mod exception; +pub mod fx; +pub mod inquiry; +pub mod invoice_post; +pub mod jobs; +pub mod metrics; +pub mod payment; +pub mod period_close; +pub mod pii; +pub mod policy_version; +pub mod posting; +pub mod provisioning; +pub mod recognition; +pub mod reconciliation; +pub mod retention; +pub mod seller_guard; +pub mod storage; diff --git a/gears/bss/ledger/ledger/src/infra/adjustment.rs b/gears/bss/ledger/ledger/src/infra/adjustment.rs new file mode 100644 index 000000000..589ee6f3d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/adjustment.rs @@ -0,0 +1,48 @@ +//! Adjustments infra (Slice 3) — the in-transaction handlers that drive the pure +//! adjustment domain (`crate::domain::adjustment`) through the foundation engine. +//! +//! Phase 1 / Group C ships the [`credit_note_service::CreditNoteHandler`]: the +//! orchestrator that posts a credit note's balanced compensating entry (design +//! §4.2) and, in the SAME ACID txn (via a [`PostSidecar`](crate::infra::posting::service::PostSidecar)), +//! reduces the owning `recognition_schedule`'s deferred total, seeds + bumps the +//! `invoice_exposure` headroom counter (the authoritative cap CHECK), seeds the +//! reusable-credit wallet remainder, and persists the `credit_note` row. +//! +//! Phase 1 / Group D ships the [`debit_note_service::DebitNoteHandler`]: the +//! orchestrator for an *additional charge* against a posted invoice. It posts a +//! **direct-split** entry that mirrors the Slice-1 invoice-post (DR `AR` / CR +//! `REVENUE` / CR `CONTRACT_LIABILITY` / CR `TAX_PAYABLE`) — NOT a compensating +//! reduction — and in the SAME ACID txn: builds the releasing `recognition_schedule` +//! when the note defers (D4, reusing the invoice-post's +//! [`ScheduleBuilderSidecar`](crate::infra::recognition::sidecar)), **raises** the +//! `invoice_exposure` headroom (`debit_note_total_minor += amount`), and persists +//! the `debit_note` row. +//! +//! Phase 2 / Group B ships the [`refund_service::RefundHandler`]: the orchestrator +//! for a **money-OUT** refund against a settled receipt (design §4.4). It resolves +//! the origin `payment_settlement` (by `payment_id` + `currency`), routes by +//! `phase` to the two-leg shape (stage-1 `… · CR REFUND_CLEARING`; stage-2 `DR +//! REFUND_CLEARING · CR CASH_CLEARING`; or the single-step `… · CR CASH_CLEARING`), +//! and posts it atomically with the `refund` record row via the +//! [`refund_service::RefundPostSidecar`]. Pattern A draws down `UNALLOCATED`, +//! Pattern B re-opens `AR`; a refund NEVER restates revenue and NEVER debits +//! `CONTRACT_LIABILITY`. The cap increments (Group C), dual-control (Group D), +//! refund-of-refund (Group E), `unknown_final` disposition (Group F), and REST +//! (Group G) land in later groups. +//! +//! Phase 3 / Group 4 ships the +//! [`manual_adjustment_service::ManualAdjustmentHandler`]: the orchestrator for a +//! **governed** manual adjustment (design §4.6) — the ledger's escape hatch for +//! corrections the typed flows do not cover (rounding residue, suspense / +//! cash-clearing clean-up). It clears the pure +//! [`govern`](crate::domain::adjustment::manual::govern) gate, posts the balanced +//! entry through the engine (idempotent on `(tenant, MANUAL_ADJUSTMENT, +//! adjustment_id)`), and publishes `billing.ledger.manual_adjustment.posted` in the +//! post txn. A disguised bad-debt write-off is rejected (`MANUAL_ADJUSTMENT_NOT_ALLOWED`) +//! and additionally captured (`SecuredAuditSink`) + paged (the `AttemptedWriteOff` +//! alarm) out-of-band. Dual-control (`SoD` + threshold) is Group 5. + +pub mod credit_note_service; +pub mod debit_note_service; +pub mod manual_adjustment_service; +pub mod refund_service; diff --git a/gears/bss/ledger/ledger/src/infra/adjustment/credit_note_service.rs b/gears/bss/ledger/ledger/src/infra/adjustment/credit_note_service.rs new file mode 100644 index 000000000..b003b1a52 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/adjustment/credit_note_service.rs @@ -0,0 +1,1187 @@ +//! `CreditNoteHandler` — the Slice-3 credit-note orchestrator (design §4.2, Group +//! C). It posts a credit note's balanced compensating entry through the invariant +//! [`PostingService`] and, in the SAME serializable transaction (via a +//! [`PostSidecar`]), atomically updates every guarded counter/schedule the note +//! touches: +//! +//! 1. **read state** (out-of-txn, oldest-first like +//! [`CreditApplicationService`](crate::infra::payment::credit)): the targeted +//! obligation's ACTIVE per-stream `recognition_schedule` state + the invoice's +//! posted-AR-incl-tax (headroom seed basis) + current open AR (the AR-vs-wallet +//! credit-leg cap). The authoritative in-txn backstops (the headroom CHECK, the +//! schedule cap CHECK, the AR no-negative CHECK) cover a concurrent race the +//! lockless reads cannot see — exactly the Slice-2 credit-apply discipline. +//! 2. **split** the ex-tax revenue amount across the schedule state via the pure +//! [`RecognizedDeferredSplitter`] (block-on-ambiguous; never pro-rata). +//! 3. **build** the balanced leg plan ([`build_credit_note_legs`]): DR +//! `CONTRA_REVENUE` (or `GOODWILL`) + per-stream DR `CONTRACT_LIABILITY` + DR +//! `TAX_PAYABLE`; CR `AR` (capped at open AR) + CR `REUSABLE_CREDIT` (remainder, +//! K-2). +//! 4. **post** the entry via [`PostingService::post`] with the +//! [`CreditNotePostSidecar`], which in the post txn: **(a)** reduces each +//! touched schedule's `total_deferred_minor` (negative Δ; the +//! `recognized_minor <= total_deferred_minor` CHECK is the over-reduction +//! guard), **(b)** seeds + bumps the `invoice_exposure` headroom counter (the +//! `chk_ledger_invoice_exposure_headroom` CHECK is the over-cap guard → 400), +//! and **(c)** persists the `credit_note` row. The wallet remainder seed **(d)** +//! needs NO sidecar work: the `CR REUSABLE_CREDIT` leg carries +//! `credit_grant_event_type = CREDIT_NOTE`, so the engine's `BalanceProjector` +//! seeds the `reusable_credit_subbalance` sub-grain from the posted line itself. +//! +//! **Idempotency** is the engine's `(tenant, CREDIT_NOTE, credit_note_id)` claim: +//! the entry's `source_doc_type = CreditNote` + `source_business_id = +//! credit_note_id` make [`PostingService::post`]'s `Fresh` claim the at-most-once +//! gate (a replay returns before the sidecar — byte-identical to how +//! `InvoicePostService` keys invoice-post dedup). +//! +//! **Block-on-ambiguous** (C3): a [`DomainError::CreditNoteSplitAmbiguous`] from +//! the splitter propagates out unchanged (the REST layer maps it to +//! `CREDIT_NOTE_SPLIT_AMBIGUOUS`, Group E); the alarm + exception-queue routing is +//! marked here (Group F / Slice 7) but not emitted in this group. +//! +//! Lives in `infra` (not `domain`): it needs repo + posting access; the domain +//! modules it calls stay pure (dylint DE0301). Wraps the `pub` [`PostingService`] +//! and repos directly (like [`InvoicePostService`](crate::infra::invoice_post)) so +//! it is constructible from out-of-crate integration tests. + +use std::collections::HashMap; +use std::sync::Arc; + +use bss_ledger_sdk::{AccountClass, MappingStatus, PostingRef, Side, SourceDocType}; +use chrono::{Datelike, Utc}; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::adjustment::credit_note::{ + CreditNoteLegPlan, CreditNoteRequest, PlannedLeg, build_credit_note_legs, validate_shape, +}; +use crate::domain::adjustment::splitter::{ + RecognizedDeferredSplitter, ScheduleStreamState, SplitInput, +}; +use crate::domain::approval::ApprovalKind; +use crate::domain::approval::intent::{ApprovalIntent, CreditNoteIntent}; +use crate::domain::approval::policy::OperationFacts; +use crate::domain::error::DomainError; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::ports::metrics::{LedgerMetricsPort, NoteOutcome}; +use crate::domain::status::{LIFECYCLE_OPEN, SCHEDULE_STATUS_ACTIVE}; +use crate::infra::approval::service::ApprovalService; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::payloads::{ + AffectedItem, AlarmCategory, AlarmSeverity, CreditNotePosted, LedgerInvariantAlarm, +}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::exception::ExceptionRouter; +use crate::infra::posting::chart::load_chart; +use crate::infra::posting::idempotency::{ClaimOutcome, IdempotencyGate}; +use crate::infra::posting::projector::BalanceProjector; +use crate::infra::posting::service::{PostSidecar, PostedFacts, PostingService}; +use crate::infra::storage::entity::recognition_schedule; +use crate::infra::storage::repo::adjustment_repo::NewCreditNote; +use crate::infra::storage::repo::{AdjustmentRepo, JournalRepo, RecognitionRepo, ReferenceRepo}; + +/// Origin literal stamped on posts made through this service (mirrors the peer +/// orchestrators). +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// Orchestrates the credit-note domain over the foundation engine (design §4.2). +pub struct CreditNoteHandler { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + recognition: RecognitionRepo, + adjustment: AdjustmentRepo, + /// Append-only journal insert — used by the COMPOSITE path (Group G): the + /// `refund-with-credit-note` posts the credit-note entry as the SECOND entry + /// inside the refund's post txn, so the credit note inserts its header+lines + /// here directly rather than through `PostingService::post` (which would open + /// its own txn). A cheap clone of the same provider. + journal: JournalRepo, + /// The event publisher — threaded into the posting engine AND held so the + /// in-txn sidecar can publish `billing.ledger.credit_note.posted`, and so the + /// split-ambiguous path can raise the `CreditNoteSplitBlocked` alarm + /// out-of-band (Group F). + publisher: Arc, + /// Metrics sink (`ledger_credit_note_total{outcome}`, Group F): one increment + /// per attempt, labelled posted / replayed / `blocked_split` / `blocked_headroom` + /// / rejected. + metrics: Arc, + /// The dual-control engine (VHP-1852). `Some` ⇒ a credit note whose amount + /// crosses the tenant's D2 threshold is gated to the preparer→approver queue + /// ([`DomainError::DualControlRequired`]) instead of posting inline; `None` ⇒ + /// gating is disabled (the executor's approved replay, and the unit tests that + /// construct the handler without the engine). Wired in `module` via + /// [`Self::with_approval`]; mirrors the + /// [`RefundHandler`](super::refund_service::RefundHandler)'s `approval` seam. + approval: Option>, + // Slice 7 Phase 2: routes the `CREDIT_NOTE_SPLIT_AMBIGUOUS` stub to a durable + // close-blocking exception row (ADDITIVE beside the rejection/alarm). `None` + // until `with_exceptions` wires it (so existing constructions are unchanged). + exceptions: Option>, +} + +impl CreditNoteHandler { + /// Build the handler over one database provider + the event publisher + /// (threaded into the posting engine + the sidecar's in-txn publish + the + /// split-blocked alarm) + the metrics sink. Same `db` / `publisher` / + /// `metrics` deps as the peer + /// [`InvoicePostService`](crate::infra::invoice_post::InvoicePostService) / + /// [`SettlementReturnService`](crate::infra::payment::settlement_return::SettlementReturnService). + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + let recognition = RecognitionRepo::new(db.clone()); + let adjustment = AdjustmentRepo::new(db.clone()); + let journal = JournalRepo::new(db); + Self { + posting, + reference, + resolver, + recognition, + adjustment, + journal, + publisher, + metrics, + approval: None, + exceptions: None, + } + } + + /// Attach the exception router (Slice 7 Phase 2) so a `CREDIT_NOTE_SPLIT_AMBIGUOUS` + /// rejection also opens a durable close-blocking exception row. Additive — the + /// existing rejection/alarm is unchanged. + #[must_use] + pub fn with_exceptions(mut self, exceptions: Arc) -> Self { + self.exceptions = Some(exceptions); + self + } + + /// Attach the dual-control engine (VHP-1852): a credit note whose amount crosses + /// the tenant's D2 threshold is then gated to the preparer→approver queue + /// ([`DomainError::DualControlRequired`]) rather than posting inline. The approved + /// replay re-enters through [`Self::post_credit_note_approved`], which skips the + /// gate. Builder form (not a `new` arg) so the executor's un-gated handler and the + /// unit tests stay source-compatible; mirrors the + /// [`RefundHandler::with_approval`](super::refund_service::RefundHandler::with_approval). + #[must_use] + pub fn with_approval(mut self, approval: Arc) -> Self { + self.approval = Some(approval); + self + } + + /// Post a credit note (design §4.2). Validates the request shape, reads the + /// obligation's schedule state + the invoice's open AR, drives the + /// recognized-vs-deferred split, builds the balanced compensating legs, and + /// posts them with the in-txn [`CreditNotePostSidecar`] (schedule reduction + + /// headroom seed/bump + `credit_note` row). Idempotent on + /// `(tenant, CREDIT_NOTE, credit_note_id)`. + /// + /// # Errors + /// [`DomainError::AmountOutOfRange`] / [`DomainError::InvalidRequest`] (shape); + /// [`DomainError::CreditNoteSplitAmbiguous`] (indeterminable split — propagated + /// for the RFC 9457 `CREDIT_NOTE_SPLIT_AMBIGUOUS`, C3); + /// [`DomainError::CreditNoteExceedsHeadroom`] (the `invoice_exposure` headroom + /// CHECK rejected the bump → `CREDIT_NOTE_EXCEEDS_HEADROOM`); + /// [`DomainError::NegativeBalance`] (a goodwill / over-credit that would drive + /// AR negative — the Slice 1 AR floor, D3); any foundation rejection or + /// [`DomainError::Internal`] on an infra fault. + pub async fn post_credit_note( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: CreditNoteRequest, + ) -> Result { + let result = self + .post_credit_note_inner(ctx, scope, req, /* gate */ true) + .await; + // One `ledger_credit_note_total{outcome}` increment per attempt (Group F). + // The split-blocked alarm is raised at its source inside `_inner` (in + // addition to this metric); here we only classify the outcome. + self.metrics.credit_note(note_outcome(&result)); + result + } + + /// The approved-replay entry (VHP-1852): re-drive a held credit note WITHOUT the + /// dual-control gate. Called only by the `ApprovalExecutor` after a second actor + /// approves the PENDING credit-note approval — the threshold was already crossed + /// at gate time, so re-checking it would re-open a second approval (an infinite + /// loop). Idempotent on the engine's `(tenant, CREDIT_NOTE, credit_note_id)` + /// claim: a re-approve replays the post harmlessly (the dedup short-circuits a + /// committed entry before the sidecar), so execute-then-mark is safe. Mirrors + /// [`RefundHandler::post_refund_approved`](super::refund_service::RefundHandler::post_refund_approved). + /// + /// # Errors + /// As [`Self::post_credit_note`], minus the dual-control gate (never returns + /// [`DomainError::DualControlRequired`]). + pub async fn post_credit_note_approved( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: CreditNoteRequest, + ) -> Result { + let result = self + .post_credit_note_inner(ctx, scope, req, /* gate */ false) + .await; + self.metrics.credit_note(note_outcome(&result)); + result + } + + /// Build + post the credit note (no metrics — the public wrappers record + /// them). Carries the F4 link gate, the dual-control gate (over D2, `gate == + /// true` only), the F3 split-blocked alarm, and the in-txn `credit_note.posted` + /// event publish. + async fn post_credit_note_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: CreditNoteRequest, + gate: bool, + ) -> Result { + // 1. Pure shape gate (amounts, goodwill shape) — a clean 400 before any read. + validate_shape(&req)?; + + // A zero-amount note has no compensating effect and would fail the engine's + // empty-entry validation; reject up-front (inherited S1 / AC #4 forbids a + // zero placeholder entry just as it forbids a zero placeholder line). + if req.amount_minor == 0 { + return Err(DomainError::InvalidRequest( + "credit note amount_minor must be > 0".to_owned(), + )); + } + + // 1b. Originating-invoice link gate (F4, design §4.2 / §5): a credit note + // MUST link a posted invoice (an `INVOICE_POST` entry for the + // origin_invoice_id). No posted invoice ⇒ `NOTE_INVOICE_NOT_FOUND` + // (404), BEFORE any read/build/post (no orphan compensating entry). + // Scoped existence (SQL-level BOLA) — a foreign-tenant invoice reads as + // absent, the same 404, no existence leak. + if !self + .adjustment + .posted_invoice_exists_out_of_txn(scope, req.tenant_id, &req.origin_invoice_id) + .await + .map_err(|e| DomainError::Internal(format!("posted-invoice existence: {e}")))? + { + return Err(DomainError::NoteInvoiceNotFound(format!( + "credit note {} references invoice {} which has no posted INVOICE_POST entry", + req.credit_note_id, req.origin_invoice_id + ))); + } + + // 1c. Dual-control gate (VHP-1852, design §1.4 D2 / §4.2). Gated on the + // credit note's amount crossing the tenant's D2 threshold, AFTER the + // shape gate (a malformed request 400s) AND the originating-invoice link + // gate (an absent origin 404s) — so neither opens an approval — but + // BEFORE any read/split/build/post. Above the threshold ⇒ a PENDING + // approval is created and `DualControlRequired` is returned (the REST + // handler maps it to 409); at/under threshold ⇒ inline, unchanged. The + // approved replay (`gate == false`) skips this — the threshold was + // already crossed at gate time. Mirrors the refund gate. + if gate && let Some(approval) = &self.approval { + let intent = ApprovalIntent::CreditNote(CreditNoteIntent::from(&req)); + let facts = OperationFacts { + kind: ApprovalKind::CreditNote, + // FX-SIMPLIFICATION (DC10 / FX = Slice 5): transaction-currency minor, + // not USD-eq. Single-currency until the FX slice lands; mirrors the + // refund gate's comment. + amount_usd_eq_minor: Some(req.amount_minor), + effective_at: None, + has_outstanding_balance: false, + }; + if let Some(approval_id) = approval + .gate(ctx, scope, intent, facts, "credit_note".to_owned()) + .await? + { + return Err(DomainError::DualControlRequired(format!( + "credit note requires dual-control approval: {approval_id}" + ))); + } + } + + // 2. Read the targeted obligation's ACTIVE per-stream schedule state + // (out-of-txn; the schedule cap CHECK is the in-txn backstop). A goodwill + // credit reduces no obligation, so it runs the split over an EMPTY state + // set (and carries no deferred part — validate_shape guaranteed it), so + // the whole ex-tax amount is the recognized part the GOODWILL leg debits. + let streams = if req.goodwill { + Vec::new() + } else { + self.read_schedule_states(scope, &req).await? + }; + + // 3. Read the invoice's posted-AR-incl-tax (headroom seed basis) + current + // open AR (the AR-vs-wallet credit cap). Read out-of-txn here to build + // the entry; re-seeded/guarded in-txn by the sidecar (the headroom CHECK) + // and the AR no-negative CHECK (the floor), mirroring credit-apply. + let (posted_ar_incl_tax, open_ar) = self.read_ar_caps(scope, &req).await?; + + // 4. Headroom pre-check — the out-of-txn mirror of + // `chk_ledger_invoice_exposure_headroom` + // (credit_note_total + amount <= original_total + debit_note_total). Run + // BEFORE the split so a note that exceeds the invoice's remaining headroom + // surfaces as the canonical `CreditNoteExceedsHeadroom`, instead of being + // mis-attributed to `CreditNoteSplitAmbiguous` by the splitter's releasable + // cap: a fully-deferred over-cap note (requested_deferred == amount > + // releasable) would otherwise trip the split's releasable bound first. The + // in-txn CHECK in the sidecar stays the AUTHORITATIVE backstop (it owns the + // read-then-post race). With no `invoice_exposure` row yet (the invoice's + // first note — seeded in-txn), the headroom is the posted AR. + let remaining_headroom = match self + .adjustment + .read_exposure_out_of_txn(scope, req.tenant_id, &req.origin_invoice_id) + .await + .map_err(|e| DomainError::Internal(format!("read invoice_exposure: {e}")))? + { + Some(exp) => { + exp.original_total_minor + exp.debit_note_total_minor - exp.credit_note_total_minor + } + None => posted_ar_incl_tax, + }; + if req.amount_minor > remaining_headroom { + return Err(DomainError::CreditNoteExceedsHeadroom(format!( + "credit note {} incl-tax amount {} exceeds the invoice's remaining \ + headroom {remaining_headroom}", + req.credit_note_id, req.amount_minor + ))); + } + + // 5. Pure recognized-vs-deferred split. Block-on-ambiguous (C3): + // DomainError::CreditNoteSplitAmbiguous propagates UNCHANGED — the REST + // layer maps it to CREDIT_NOTE_SPLIT_AMBIGUOUS (Group E). + let split = RecognizedDeferredSplitter::split(&SplitInput { + source_invoice_item_ref: req.origin_invoice_item_ref.as_deref().unwrap_or(""), + po_allocation_group: req.po_allocation_group.as_deref(), + streams: &streams, + amount_minor_ex_tax: req.amount_minor_ex_tax(), + requested_deferred_minor: req.requested_deferred_minor, + }); + let split = match split { + Ok(s) => s, + Err(e @ DomainError::CreditNoteSplitAmbiguous(_)) => { + // exception stub (full exception_queue is Slice 7) + // Raise the `CreditNoteSplitBlocked` alarm out-of-band (Group F), + // IN ADDITION to the RFC 9457 `CREDIT_NOTE_SPLIT_AMBIGUOUS` reject: + // the note is still rejected (the error propagates as-is — the REST + // mapping already exists), but an operator gets a triage signal for + // the missing split basis. Fire-and-forget on its own committed + // connection (never fails the reject); no books effect occurred. + self.emit_split_blocked_alarm(ctx, &req, &e).await; + return Err(e); + } + Err(other) => return Err(other), + }; + + // 6. Build the balanced compensating leg plan (DR contra/goodwill + CL + + // tax; CR AR capped at open AR + REUSABLE_CREDIT remainder, K-2). + let plan = build_credit_note_legs(&req, &split, open_ar)?; + + // 7. Resolve chart accounts + scales, assemble the engine entry + lines. + let (entry, lines) = self.assemble_post(ctx, scope, &req, &plan).await?; + + // 8. Post via the invariant engine with the in-txn sidecar (schedule + // reduction + headroom seed/bump + credit_note row). The engine's Fresh + // claim on (tenant, CREDIT_NOTE, credit_note_id) is the idempotency gate. + // The sidecar is the SAME one the Group-G composite path builds (factored + // into `build_post_sidecar`), so the standalone and composite credit-note + // posts cannot drift. + let sidecar: Arc = + Arc::new(self.build_post_sidecar(ctx, &req, &plan, posted_ar_incl_tax)); + + self.posting + .post(ctx, scope, entry, lines, Some(sidecar)) + .await + } + + /// Prepare a credit note for the ATOMIC composite post (Group G, + /// `refund-with-credit-note`, K-3): do ALL the out-of-txn work — shape gate, + /// originating-invoice link gate, schedule-state + AR-cap reads, the + /// recognized-vs-deferred split, the leg build, chart/scale resolution, and the + /// `normal_side` load — and return a [`PreparedCreditNote`] the composite + /// orchestrator posts as the SECOND entry inside the refund's post txn (via + /// [`Self::apply_in_txn`]). No txn is opened here; the heavy reads run on fresh + /// scoped connections exactly as [`Self::post_credit_note_inner`] does. The + /// in-txn backstops (headroom CHECK, schedule cap CHECK, AR no-negative CHECK) + /// still apply when [`Self::apply_in_txn`] projects + writes. + /// + /// # Errors + /// As [`Self::post_credit_note`]'s validation/read/split errors (shape, + /// `NoteInvoiceNotFound`, `CreditNoteSplitAmbiguous`, infra). + pub(crate) async fn prepare( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &CreditNoteRequest, + ) -> Result { + validate_shape(req)?; + if req.amount_minor == 0 { + return Err(DomainError::InvalidRequest( + "credit note amount_minor must be > 0".to_owned(), + )); + } + if !self + .adjustment + .posted_invoice_exists_out_of_txn(scope, req.tenant_id, &req.origin_invoice_id) + .await + .map_err(|e| DomainError::Internal(format!("posted-invoice existence: {e}")))? + { + return Err(DomainError::NoteInvoiceNotFound(format!( + "credit note {} references invoice {} which has no posted INVOICE_POST entry", + req.credit_note_id, req.origin_invoice_id + ))); + } + let streams = if req.goodwill { + Vec::new() + } else { + self.read_schedule_states(scope, req).await? + }; + let split = RecognizedDeferredSplitter::split(&SplitInput { + source_invoice_item_ref: req.origin_invoice_item_ref.as_deref().unwrap_or(""), + po_allocation_group: req.po_allocation_group.as_deref(), + streams: &streams, + amount_minor_ex_tax: req.amount_minor_ex_tax(), + requested_deferred_minor: req.requested_deferred_minor, + }); + let split = match split { + Ok(s) => s, + Err(e @ DomainError::CreditNoteSplitAmbiguous(_)) => { + self.emit_split_blocked_alarm(ctx, req, &e).await; + return Err(e); + } + Err(other) => return Err(other), + }; + let (posted_ar_incl_tax, open_ar) = self.read_ar_caps(scope, req).await?; + let plan = build_credit_note_legs(req, &split, open_ar)?; + let (entry, lines) = self.assemble_post(ctx, scope, req, &plan).await?; + // Pre-load each DISTINCT account's normal_side (the composite's in-txn + // projection needs it; loaded out-of-txn on a fresh connection, exactly as + // `PostingService::run_post` does before opening its txn). + let normal_sides = self.load_normal_sides(scope, &lines).await?; + let sidecar = self.build_post_sidecar(ctx, req, &plan, posted_ar_incl_tax); + Ok(PreparedCreditNote { + entry, + lines, + normal_sides, + sidecar, + }) + } + + /// Apply a [`PreparedCreditNote`] as a SECOND entry inside an ALREADY-OPEN post + /// txn (Group G composite, K-3): claim the credit note's own + /// `(tenant, CREDIT_NOTE, credit_note_id)` dedup row, insert its header+lines, + /// project balances (the AR no-negative guard), run its in-txn sidecar (schedule + /// reduction + headroom seed/bump + `credit_note` row + `credit_note.posted` + /// event), and finalize the dedup row — ALL on the `txn` the refund opened, so + /// the credit-note entry commits atomically with the refund entry (AR is never + /// overstated between them). Returns the posted credit-note entry id. + /// + /// Idempotency: a re-claim that finds the credit note's dedup row already + /// present (a prior composite committed it) returns the prior entry id WITHOUT + /// re-posting — the composite is then a replay. The refund's own dedup (claimed + /// by the engine in the same txn) is the composite's primary idempotency grain. + /// + /// # Errors + /// [`DomainError::IdempotencyConflict`] on a same-id / different-payload reuse; + /// [`DomainError::CreditNoteExceedsHeadroom`] / [`DomainError::OverRecognition`] + /// / [`DomainError::NegativeBalance`] on an in-txn CHECK; [`DomainError::Internal`] + /// on an infra fault (any of which rolls the WHOLE composite — refund included — + /// back). + pub(crate) async fn apply_in_txn( + &self, + _ctx: &SecurityContext, + txn: &DbTx<'_>, + scope: &AccessScope, + prepared: &PreparedCreditNote, + ) -> Result { + // Borrow the prepared parts (taken by reference so a serializable RETRY of + // the outer refund post re-runs this safely — nothing is consumed). The + // entry + lines are cloned for the insert; the sidecar runs by reference. + let PreparedCreditNote { + entry, + lines, + normal_sides, + sidecar, + } = prepared; + let tenant = entry.tenant_id; + let business_id = entry.source_business_id.clone(); + let gate = IdempotencyGate::new(); + let payload_hash = IdempotencyGate::payload_hash(entry, lines); + + // Claim the credit note's OWN dedup row in the shared txn. A conflict is a + // replay: the credit note already posted on a prior composite (return its + // entry id, post nothing — the refund half short-circuits on its own dedup). + match gate + .claim( + txn, + tenant, + SourceDocType::CreditNote.as_str(), + &business_id, + &payload_hash, + ) + .await + .map_err(|e| DomainError::Internal(format!("composite credit-note claim: {e}")))? + { + ClaimOutcome::Claimed => {} + ClaimOutcome::Replay(row) => { + if row.payload_hash != payload_hash { + return Err(DomainError::IdempotencyConflict( + "credit_note_id reused with a different payload".to_owned(), + )); + } + let entry_id = row.result_entry_id.ok_or_else(|| { + DomainError::Internal( + "composite credit-note replay: dedup row not finalized".to_owned(), + ) + })?; + return Ok(CompositeCreditNoteOutcome { entry_id }); + } + } + + let entry_ref = self + .journal + .insert_entry_with_lines(txn, entry.clone(), lines.clone()) + .await + .map_err(|e| DomainError::Internal(format!("composite insert credit-note: {e}")))?; + + // Balance projection (AR no-negative guard) on the credit-note entry, in the + // shared txn — the same guard `PostingService::post` runs for a standalone + // credit note. + BalanceProjector::new() + .project( + txn, + scope, + entry, + lines, + normal_sides, + entry_ref.created_seq, + ) + .await + .map_err(map_project_err)?; + + // The credit-note in-txn sidecar (schedule reduction + headroom + record + + // `credit_note.posted` event), against the posted facts. + sidecar + .run( + txn, + scope, + &PostedFacts { + entry_id: entry_ref.entry_id, + created_seq: entry_ref.created_seq, + }, + ) + .await?; + + gate.finalize( + txn, + tenant, + SourceDocType::CreditNote.as_str(), + &business_id, + entry_ref.entry_id, + entry_ref.created_seq, + ) + .await + .map_err(|e| DomainError::Internal(format!("composite credit-note finalize: {e}")))?; + + Ok(CompositeCreditNoteOutcome { + entry_id: entry_ref.entry_id, + }) + } + + /// Build the in-txn [`CreditNotePostSidecar`] from the plan + the posted-AR seed + /// — factored out of [`Self::post_credit_note_inner`] so the composite path + /// ([`Self::prepare`]) builds the same sidecar. The standalone post path keeps + /// its inline construction (unchanged). + fn build_post_sidecar( + &self, + ctx: &SecurityContext, + req: &CreditNoteRequest, + plan: &CreditNoteLegPlan, + posted_ar_incl_tax: i64, + ) -> CreditNotePostSidecar { + CreditNotePostSidecar { + tenant_id: req.tenant_id, + origin_invoice_id: req.origin_invoice_id.clone(), + currency: req.currency.clone(), + posted_ar_incl_tax, + credit_note_amount_minor: req.amount_minor, + credit_note_id: req.credit_note_id.clone(), + recognized_part_minor: plan.recognized_part_minor, + deferred_part_minor: plan.deferred_part_minor, + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + schedule_reductions: plan + .legs + .iter() + .filter_map(planned_schedule_reduction) + .collect(), + credit_note_row: NewCreditNote { + tenant_id: req.tenant_id, + credit_note_id: req.credit_note_id.clone(), + origin_invoice_id: req.origin_invoice_id.clone(), + origin_invoice_item_ref: req.origin_invoice_item_ref.clone(), + revenue_stream: req.revenue_stream.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + recognized_part_minor: plan.recognized_part_minor, + deferred_part_minor: plan.deferred_part_minor, + split_basis_ref: Some(plan.split_basis_ref.clone()), + reason_code: req.reason_code.clone(), + created_at_utc: Utc::now(), + }, + } + } + + /// Load each DISTINCT account's `normal_side` for the credit-note lines, + /// asserting it is provisioned + `OPEN` (the composite's in-txn projection input + /// — mirrors `PostingService::load_normal_sides`, read out-of-txn). + async fn load_normal_sides( + &self, + scope: &AccessScope, + lines: &[NewLine], + ) -> Result, DomainError> { + let mut normal_sides: HashMap = HashMap::new(); + for line in lines { + if normal_sides.contains_key(&line.account_id) { + continue; + } + let account = self + .reference + .find_account(scope, line.account_id) + .await + .map_err(|e| DomainError::Internal(format!("composite find_account: {e}")))? + .ok_or_else(|| { + DomainError::AccountClosed(format!( + "account {} is not provisioned", + line.account_id + )) + })?; + if account.lifecycle_state != LIFECYCLE_OPEN { + return Err(DomainError::AccountClosed(format!( + "account {} is not OPEN", + line.account_id + ))); + } + let side = match account.normal_side.as_str() { + "DR" => Side::Debit, + "CR" => Side::Credit, + other => { + return Err(DomainError::Internal(format!( + "account {} has an invalid normal_side {other:?}", + line.account_id + ))); + } + }; + normal_sides.insert(line.account_id, side); + } + Ok(normal_sides) + } + + /// Read the targeted obligation's ACTIVE per-stream `recognition_schedule` + /// state — the splitter input (one entry per revenue stream, Slice 4 §4.5). + /// Narrowed to the request's `(origin_invoice_id, revenue_stream)` and filtered + /// to the targeted `origin_invoice_item_ref` (when set) + `ACTIVE` status. v1 + /// is one ACTIVE schedule per `(invoice, item, stream)`, so this yields 0 or 1 + /// state for the single requested stream. Out-of-txn (SQL-level BOLA); the + /// schedule cap CHECK is the authoritative in-txn guard. + async fn read_schedule_states( + &self, + scope: &AccessScope, + req: &CreditNoteRequest, + ) -> Result, DomainError> { + let (rows, truncated) = self + .recognition + .list_schedules( + scope, + req.tenant_id, + Some(&req.origin_invoice_id), + Some(&req.revenue_stream), + ) + .await + .map_err(|e| DomainError::Internal(format!("list recognition schedules: {e}")))?; + if truncated { + // A single (invoice, stream) result is tiny — a truncation here means a + // pathological schedule lineage; surface as Internal rather than risk a + // partial split basis (the splitter must see the full per-stream state). + return Err(DomainError::Internal(format!( + "recognition-schedule read for invoice {} / stream {} was truncated", + req.origin_invoice_id, req.revenue_stream + ))); + } + let states = rows + .into_iter() + .filter(|s| { + // Targeted item (when the request pins one) + only ACTIVE schedules + // carry a reducible deferred remainder (the splitter floors a + // non-ACTIVE one at 0 anyway; filtering keeps the basis clean). + req.origin_invoice_item_ref + .as_deref() + .is_none_or(|item| s.source_invoice_item_ref == item) + && s.status == SCHEDULE_STATUS_ACTIVE + }) + .map(map_schedule_state) + .collect(); + Ok(states) + } + + /// Read the invoice's posted-AR-incl-tax (the headroom seed basis) and its + /// current open AR (the AR-vs-wallet credit cap). Both are scoped reads; the + /// posted-AR is the original receivable from the journal (the headroom basis, + /// independent of payments), the open AR is the payment-reduced cache. Run + /// out-of-txn to build the entry (the headroom + AR-no-negative CHECKs are the + /// in-txn backstops). + async fn read_ar_caps( + &self, + scope: &AccessScope, + req: &CreditNoteRequest, + ) -> Result<(i64, i64), DomainError> { + // Out-of-txn scoped reads on a fresh connection (no active post txn yet), + // mirroring how the engine reads `normal_sides` before the post txn and how + // `CreditApplicationService` reads its open-AR candidates pre-txn — the + // headroom + AR no-negative CHECKs are the authoritative in-txn backstops. + let posted_ar = self + .adjustment + .read_posted_ar_incl_tax_out_of_txn(scope, req.tenant_id, &req.origin_invoice_id) + .await + .map_err(|e| DomainError::Internal(format!("read posted AR: {e}")))?; + let open_ar = self + .adjustment + .read_open_ar_for_invoice_out_of_txn(scope, req.tenant_id, &req.origin_invoice_id) + .await + .map_err(|e| DomainError::Internal(format!("read open AR: {e}")))?; + Ok((posted_ar, open_ar)) + } + + /// Resolve each planned leg's chart `account_id` + currency scale and assemble + /// the engine [`NewEntry`] + [`NewLine`] vector. Per-stream classes + /// (`CONTRA_REVENUE`/`CONTRACT_LIABILITY`) resolve on their stream; the rest + /// resolve stream-less. The header is `source_doc_type = CreditNote` + + /// `source_business_id = credit_note_id` (the engine's idempotency key). + async fn assemble_post( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &CreditNoteRequest, + plan: &CreditNoteLegPlan, + ) -> Result<(NewEntry, Vec), DomainError> { + let chart = load_chart(&self.reference, scope, req.tenant_id).await?; + let scale = self + .resolver + .resolve(scope, req.tenant_id, &req.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + + let eff_date = Utc::now().date_naive(); + let period_id = format!("{:04}{:02}", eff_date.year(), eff_date.month()); + + let mut lines: Vec = Vec::with_capacity(plan.legs.len()); + for leg in &plan.legs { + let account_id = chart + .resolve( + leg.account_class, + &req.currency, + leg.revenue_stream.as_deref(), + ) + .ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + leg.account_class.as_str(), + leg.revenue_stream, + req.currency + )) + })?; + lines.push(Self::mk_line(req, leg, account_id, scale)); + } + + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: req.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: req.tenant_id, + period_id, + entry_currency: req.currency.clone(), + source_doc_type: SourceDocType::CreditNote, + // The engine's `(tenant, CREDIT_NOTE, credit_note_id)` idempotency key. + source_business_id: req.credit_note_id.clone(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: eff_date, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: ctx.subject_id(), + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + // Slice 5: same-currency in v1 (no FX lock on this path). + rate_snapshot_ref: None, + }; + Ok((entry, lines)) + } + + /// Map one [`PlannedLeg`] + its resolved chart account/scale to the engine + /// [`NewLine`]. AR / `REUSABLE_CREDIT` carry `payer_tenant_id` + `invoice_id` + /// (their cache grains key on them); the `CR REUSABLE_CREDIT` leg carries + /// `credit_grant_event_type` so the projector seeds the wallet sub-grain. + fn mk_line(req: &CreditNoteRequest, leg: &PlannedLeg, account_id: Uuid, scale: u8) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: req.payer_tenant_id, + seller_tenant_id: Some(req.tenant_id), + resource_tenant_id: None, + account_id, + account_class: leg.account_class, + gl_code: None, + side: leg.side, + amount_minor: leg.amount_minor, + currency: req.currency.clone(), + currency_scale: scale, + invoice_id: Some(req.origin_invoice_id.clone()), + due_date: None, + revenue_stream: leg.revenue_stream.clone(), + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + // Tax dims (jurisdiction / filing-period / rate) carry the posted + // TaxBreakdown evidence the leg routed: `Some` on a per-component + // TAX_PAYABLE leg (so the projector disaggregates `tax_subbalance` per + // (jurisdiction, filing), §4.5), `None` on every other leg + the legacy + // single dimensionless tax leg. The amount is the reversed tax (never + // recomputed here). + tax_jurisdiction: leg.tax_jurisdiction.clone(), + tax_filing_period: leg.tax_filing_period.clone(), + tax_rate_ref: leg.tax_rate_ref.clone(), + legal_entity_id: None, + invoice_item_ref: req.origin_invoice_item_ref.clone(), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: req.po_allocation_group.clone(), + credit_grant_event_type: leg.credit_grant_event_type.clone(), + ar_status: None, + } + } + + /// Raise the `CreditNoteSplitBlocked` invariant alarm out-of-band on the + /// split-ambiguous reject (Group F / Slice 6 catalog). Fire-and-forget on the + /// publisher's own committed connection (the note was rejected with NO books + /// effect, so there is no post txn to ride); `Warn` severity (a config-gap + /// triage signal, not an integrity breach). Ids only, no PII — the + /// `origin_invoice_item_ref` is an internal ref, mirrored into the `affected` + /// list so an operator can locate the obligation whose split basis is missing. + async fn emit_split_blocked_alarm( + &self, + ctx: &SecurityContext, + req: &CreditNoteRequest, + err: &DomainError, + ) { + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::CreditNoteSplitBlocked, + severity: AlarmSeverity::Warn, + tenant_id: req.tenant_id, + scope: format!( + "tenant:{}/flow:CREDIT_NOTE/business:{}", + req.tenant_id, req.credit_note_id + ), + code: "CREDIT_NOTE_SPLIT_AMBIGUOUS".to_owned(), + detail: err.to_string(), // internal diagnostic — no PII + affected: vec![AffectedItem { + id: format!( + "invoice:{}/stream:{}/item:{}", + req.origin_invoice_id, + req.revenue_stream, + req.origin_invoice_item_ref.as_deref().unwrap_or("") + ), + currency: req.currency.clone(), + expected_minor: 0, + actual_minor: 0, + }], + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + + // Slice 7 Phase 2: ADDITIVELY open a durable close-blocking exception row + // beside the alarm above (the split-ambiguous note is still rejected; this + // only makes the condition block the next close until resolved). + if let Some(ex) = &self.exceptions { + ex.route( + req.tenant_id, + crate::domain::exception::ExceptionType::SplitAmbiguous, + &req.credit_note_id, + Some(serde_json::json!({ + "credit_note_id": req.credit_note_id, + "origin_invoice_id": req.origin_invoice_id, + })), + ) + .await; + } + } +} + +/// Classify a credit-note attempt result into its `ledger_credit_note_total` +/// `outcome` label (Group F): the two block reasons get their own labels (the +/// split-ambiguous + headroom-cap rejects), a replay is `replayed`, a fresh post +/// is `posted`, and every other rejection (shape / not-found / negative-balance / +/// infra) folds into `rejected`. +fn note_outcome(result: &Result) -> NoteOutcome { + match result { + Ok(r) if r.replayed => NoteOutcome::Replayed, + Ok(_) => NoteOutcome::Posted, + Err(DomainError::CreditNoteSplitAmbiguous(_)) => NoteOutcome::BlockedSplit, + Err(DomainError::CreditNoteExceedsHeadroom(_)) => NoteOutcome::BlockedHeadroom, + Err(_) => NoteOutcome::Rejected, + } +} + +/// A credit note prepared out-of-txn for the ATOMIC composite post (Group G, +/// `refund-with-credit-note`): the engine entry + lines, the pre-loaded +/// `normal_side`s for the in-txn projection, and the in-txn +/// [`CreditNotePostSidecar`] (schedule reduction + headroom + record + event). The +/// composite orchestrator posts this as the SECOND entry inside the refund's post +/// txn via [`CreditNoteHandler::apply_in_txn`]. Carried by value (moved into the +/// refund's composite sidecar). +pub(crate) struct PreparedCreditNote { + entry: NewEntry, + lines: Vec, + normal_sides: std::collections::HashMap, + sidecar: CreditNotePostSidecar, +} + +/// The outcome of applying a [`PreparedCreditNote`] in the composite txn +/// ([`CreditNoteHandler::apply_in_txn`]): the posted (or idempotently replayed) +/// credit-note entry id. +pub(crate) struct CompositeCreditNoteOutcome { + pub entry_id: Uuid, +} + +/// Map a [`ProjectError`](crate::infra::posting::projector::ProjectError) from the +/// composite credit-note projection into the handler's [`DomainError`]: a negative +/// AR balance is the Slice-1 floor (`NEGATIVE_BALANCE`, D3); everything else is an +/// infra fault that rolls the WHOLE composite (refund included) back. Mirrors the +/// `project_to_db` mapping `PostingService` uses for a standalone post. +fn map_project_err(e: crate::infra::posting::projector::ProjectError) -> DomainError { + use crate::infra::posting::projector::ProjectError; + match e { + ProjectError::NegativeBalance { + account_id, + balance_minor, + } => DomainError::NegativeBalance(format!( + "balance for account {account_id} would go negative ({balance_minor})" + )), + ProjectError::MissingNormalSide(id) => { + DomainError::AccountClosed(format!("missing normal_side for account {id}")) + } + ProjectError::MissingCreditEventType(id) => DomainError::Internal(format!( + "REUSABLE_CREDIT line {id} missing credit_grant_event_type" + )), + ProjectError::Overflow { + account_id, + currency, + field, + } => DomainError::AmountOutOfRange(format!( + "coalesced money delta overflowed i64 for account {account_id} ({currency}, {field})" + )), + ProjectError::Db(e) => DomainError::Internal(format!("composite projector: {e}")), + } +} + +/// One in-txn schedule deferred reduction the sidecar applies: the schedule id + +/// the ex-tax deferred amount to subtract from its `total_deferred_minor`. Derived +/// from a planned `DR CONTRACT_LIABILITY` leg (which carries the owning +/// `schedule_id`). +#[derive(Clone, Debug)] +struct ScheduleReduction { + schedule_id: String, + amount_minor: i64, +} + +/// Project a planned `DR CONTRACT_LIABILITY` leg into its schedule reduction (it +/// carries the owning `schedule_id`); every other leg yields `None`. +fn planned_schedule_reduction(leg: &PlannedLeg) -> Option { + match (leg.account_class, leg.side, leg.schedule_id.as_ref()) { + (AccountClass::ContractLiability, Side::Debit, Some(schedule_id)) => { + Some(ScheduleReduction { + schedule_id: schedule_id.clone(), + amount_minor: leg.amount_minor, + }) + } + _ => None, + } +} + +/// Map a stored `recognition_schedule` row into the splitter's pure +/// [`ScheduleStreamState`] input. +fn map_schedule_state(s: recognition_schedule::Model) -> ScheduleStreamState { + ScheduleStreamState { + revenue_stream: s.revenue_stream, + schedule_id: s.schedule_id, + total_deferred_minor: s.total_deferred_minor, + recognized_minor: s.recognized_minor, + status: s.status, + version: s.version, + } +} + +/// The in-transaction [`PostSidecar`] for a credit note: runs AFTER balance +/// projection and BEFORE the dedup finalize (fresh-claim path only — a replay +/// returns before the sidecar), so all its writes commit atomically with the +/// journal entry or roll back with it (design §4.2 / §4.7). It performs, in the +/// §4.7 lock order (`recognition_schedule`, then `invoice_exposure)`: +/// +/// 1. **Schedule reduction** (rank 6): for each touched schedule, decrement +/// `total_deferred_minor` by the deferred ex-tax part. The +/// `recognized_minor <= total_deferred_minor` CHECK is the over-reduction guard +/// (a later run cannot re-recognize the credited-back amount); a breach surfaces +/// as `MoneyOutCapExceeded`, refined here to `OverRecognition`. +/// 2. **Headroom** (rank 10): first-touch seed `invoice_exposure` +/// (`original_total_minor` = posted AR), then bump `credit_note_total_minor` by +/// the note's incl-tax amount. The `chk_ledger_invoice_exposure_headroom` CHECK +/// is the authoritative over-cap guard; a breach surfaces as `MoneyOutCapExceeded`, +/// refined here to `CreditNoteExceedsHeadroom` (→ `CREDIT_NOTE_EXCEEDS_HEADROOM`). +/// 3. **Persist** the `credit_note` row. +/// +/// **Wallet remainder (d)** needs no work here: the `CR REUSABLE_CREDIT` leg +/// carries `credit_grant_event_type = CREDIT_NOTE`, so the engine's `BalanceProjector` +/// already seeded the `reusable_credit_subbalance` sub-grain from the posted line +/// (this sidecar runs after projection). +pub struct CreditNotePostSidecar { + tenant_id: Uuid, + origin_invoice_id: String, + currency: String, + /// The posted AR incl. tax — the `invoice_exposure.original_total_minor` seed. + posted_ar_incl_tax: i64, + /// The note's incl-tax amount — the `credit_note_total_minor` bump delta + + /// the published event's `amount_minor`. + credit_note_amount_minor: i64, + /// The note's business id — the published event's `credit_note_id`. + credit_note_id: String, + /// The ex-tax recognized part — the published event's `recognized_part_minor`. + recognized_part_minor: i64, + /// The ex-tax deferred part — the published event's `deferred_part_minor`. + deferred_part_minor: i64, + /// The event publisher: `billing.ledger.credit_note.posted` is published IN + /// this post txn (the transactional outbox) so it commits atomically with the + /// entry + counters, or rolls back with them. Mirrors + /// [`SettlementReturnSidecar`](crate::infra::payment::sidecar::SettlementReturnSidecar). + publisher: Arc, + /// The security context for the in-txn outbox publish (cloned by the handler). + ctx: SecurityContext, + /// The per-schedule deferred reductions (one per touched `CONTRACT_LIABILITY` + /// leg); empty for a goodwill / fully-recognized note (no schedule touch). + schedule_reductions: Vec, + /// The `credit_note` record to persist. + credit_note_row: NewCreditNote, +} + +#[async_trait::async_trait] +impl PostSidecar for CreditNotePostSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + // 1. Schedule reduction (rank 6) — reduce each touched schedule's deferred + // total over its unreleased remainder. The CHECK is the over-reduction + // backstop (refined to OverRecognition). + for r in &self.schedule_reductions { + RecognitionRepo::reduce_deferred( + txn, + scope, + self.tenant_id, + &r.schedule_id, + r.amount_minor, + ) + .await + .map_err(map_schedule_repo_err)?; + } + + // 2. Headroom (rank 10) — first-touch seed then bump. The headroom CHECK is + // the authoritative over-cap guard, refined to CreditNoteExceedsHeadroom + // (→ CREDIT_NOTE_EXCEEDS_HEADROOM, 400). + AdjustmentRepo::seed_exposure_first_touch( + txn, + scope, + self.tenant_id, + &self.origin_invoice_id, + &self.currency, + self.posted_ar_incl_tax, + ) + .await + .map_err(|e| DomainError::Internal(format!("seed invoice_exposure: {e}")))?; + AdjustmentRepo::add_credit_note_total( + txn, + scope, + self.tenant_id, + &self.origin_invoice_id, + self.credit_note_amount_minor, + ) + .await + .map_err(map_headroom_repo_err)?; + + // 3. Persist the credit_note record row. + AdjustmentRepo::insert_credit_note(txn, scope, &self.credit_note_row) + .await + .map_err(|e| DomainError::Internal(format!("insert credit_note: {e}")))?; + + // 4. Publish `billing.ledger.credit_note.posted` into the SAME post txn + // (transactional outbox): the event row commits atomically with the + // entry + the schedule/headroom/record writes, or a publish failure + // rolls the whole post back. Never on replay (a replay returns before + // the sidecar). Ids + amount + split parts only (no PII). + self.publisher + .publish_credit_note_posted( + &self.ctx, + txn, + CreditNotePosted { + tenant_id: self.tenant_id, + credit_note_id: self.credit_note_id.clone(), + origin_invoice_id: self.origin_invoice_id.clone(), + entry_id: posted.entry_id, + currency: self.currency.clone(), + amount_minor: self.credit_note_amount_minor, + recognized_part_minor: self.recognized_part_minor, + deferred_part_minor: self.deferred_part_minor, + posted_at_utc: Utc::now(), + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("publish credit_note_posted: {e}")))?; + + Ok(()) + } +} + +/// Map a schedule-counter [`RepoError`](crate::domain::model::RepoError) into the +/// sidecar's [`DomainError`]: the `recognized_minor <= total_deferred_minor` CHECK +/// violation (an over-reduction of an in-flight schedule) becomes +/// [`DomainError::OverRecognition`] (the `OVER_RECOGNITION` 409 — the credited-back +/// amount cannot exceed the unreleased remainder, mirroring the recognition stamp +/// sidecar); every other repo failure is an infrastructure fault that rolls the +/// post back. +fn map_schedule_repo_err(e: crate::domain::model::RepoError) -> DomainError { + use crate::domain::model::RepoError; + match e { + RepoError::MoneyOutCapExceeded(m) => DomainError::OverRecognition(format!( + "credit-note deferred reduction exceeds the schedule's releasable remainder: {m}" + )), + other => DomainError::Internal(format!("credit-note schedule reduction: {other}")), + } +} + +/// Map a headroom-counter [`RepoError`](crate::domain::model::RepoError) into the +/// sidecar's [`DomainError`]: the `invoice_exposure` headroom CHECK violation +/// becomes [`DomainError::CreditNoteExceedsHeadroom`] (the `CREDIT_NOTE_EXCEEDS_HEADROOM` +/// 400 — design §4.2 / AC #24; over-cap routes via goodwill/non-revenue, never +/// silently through S3); every other repo failure is an infra fault. +fn map_headroom_repo_err(e: crate::domain::model::RepoError) -> DomainError { + use crate::domain::model::RepoError; + match e { + RepoError::MoneyOutCapExceeded(m) => DomainError::CreditNoteExceedsHeadroom(format!( + "credit note would exceed the invoice's remaining headroom: {m}" + )), + other => DomainError::Internal(format!("credit-note headroom bump: {other}")), + } +} diff --git a/gears/bss/ledger/ledger/src/infra/adjustment/debit_note_service.rs b/gears/bss/ledger/ledger/src/infra/adjustment/debit_note_service.rs new file mode 100644 index 000000000..4deb15c94 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/adjustment/debit_note_service.rs @@ -0,0 +1,793 @@ +//! `DebitNoteHandler` — the Slice-3 debit-note orchestrator (design §4.3, Group D). +//! A debit note is an **additional charge** against an already-posted invoice; it +//! is a **DIRECT split that mirrors the Slice-1 invoice-post** (`crate::infra::invoice_post`), +//! NOT a compensating reduction — so it does NOT use the +//! [`RecognizedDeferredSplitter`](crate::domain::adjustment::splitter) (that is the +//! credit-note's reducer). It books fresh: +//! +//! | Line | Side | Account class | +//! |------|------|---------------| +//! | Additional AR (incl. tax) | DR | `AR` | +//! | Revenue recognized at post (ex-tax) | CR | `REVENUE` | +//! | Contract liability deferred per PO (ex-tax, if any) | CR | `CONTRACT_LIABILITY` | +//! | Tax | CR | `TAX_PAYABLE` | +//! +//! and, in the SAME serializable transaction (via a [`PostSidecar`]), atomically: +//! +//! 1. **Schedule build (D4)** — when the note defers (`deferred_part_minor > 0`), +//! it runs the SAME recognition [`ScheduleBuilder`] path the invoice-post uses +//! (`crate::infra::invoice_post::InvoicePostService::derive_recognition`), +//! yielding a [`PlannedScheduleMaterialization`], and reuses the **identical** +//! [`ScheduleBuilderSidecar`] to insert the `recognition_schedule` + segments — +//! so the new deferred Contract-liability balance is immediately recognizable by +//! a later S6 run (no stuck liability), exactly the rule Slice 1 applies. +//! 2. **Headroom raise** — first-touch seed `invoice_exposure` (`original_total_minor` +//! = the invoice's posted AR) then bump `debit_note_total_minor += amount`. A +//! debit note **raises** the headroom (the RHS of the +//! `credit_note_total_minor <= original_total_minor + debit_note_total_minor` +//! CHECK), so this can never trip the cap — it only widens the room for later +//! credit notes. +//! 3. **Persist** the `debit_note` row. +//! +//! **Lock order (§4.7).** The sidecar takes `recognition_schedule` / +//! `recognition_segment` (the schedule build) BEFORE `invoice_exposure` (the +//! headroom) — the global rank order, so it never inverts vs the credit-note / +//! recognition / payment paths. +//! +//! **Idempotency** is the engine's `(tenant, DEBIT_NOTE, debit_note_id)` claim: +//! the entry's `source_doc_type = DebitNote` + `source_business_id = debit_note_id` +//! make [`PostingService::post`]'s `Fresh` claim the at-most-once gate (a replay +//! returns before the sidecar — byte-identical to how `InvoicePostService` keys +//! invoice-post dedup, and to the peer `CreditNoteHandler`). +//! +//! **Payer-close gate (A-2, design §7).** A debit note inherits the Foundation +//! §4.2 payer-close gate exactly as the invoice-post does: a `payer_open = false` +//! rejects with [`DomainError::PayerClosed`] (`PAYER_CLOSED`, 409 — `failed_precondition`) before any +//! ledger effect (you cannot charge a closed payer). The gate is resolved by the +//! caller (the Group E REST seam, mirroring `journal_entries.rs`'s `payer_open` +//! seam); the foundation account-lifecycle invariant (a closed AR account) is the +//! authoritative backstop at post time. +//! +//! Lives in `infra` (not `domain`): it needs repo + posting access; the domain +//! modules it calls stay pure (dylint DE0301). Wraps the `pub` [`PostingService`] + +//! repos directly (like [`InvoicePostService`](crate::infra::invoice_post) / +//! [`CreditNoteHandler`](super::credit_note_service)) so it is constructible from +//! out-of-crate integration tests. + +use std::sync::Arc; + +use bss_ledger_sdk::{MappingStatus, PostingRef, SourceDocType}; +use chrono::{Datelike, Utc}; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::config::RecognitionConfig; +use crate::domain::adjustment::debit_note::{ + DebitNoteLegPlan, DebitNoteRequest, PlannedLeg, build_debit_note_legs, validate_shape, +}; +use crate::domain::approval::ApprovalKind; +use crate::domain::approval::intent::{ApprovalIntent, DebitNoteIntent}; +use crate::domain::approval::policy::OperationFacts; +use crate::domain::error::DomainError; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::ports::metrics::{LedgerMetricsPort, NoteOutcome}; +use crate::domain::recognition::builder::{ScheduleBuilder, ScheduleOutcome}; +use crate::domain::recognition::input::RecognitionInput; +use crate::domain::recognition::ports::{ + DefaultDeferralPolicyResolver, DefaultSspResolver, DefaultVcResolver, RecognitionContext, +}; +use crate::infra::approval::service::ApprovalService; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::payloads::DebitNotePosted; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::exception::ExceptionRouter; +use crate::infra::posting::chart::load_chart; +use crate::infra::posting::idempotency::IdempotencyGate; +use crate::infra::posting::service::{PostSidecar, PostedFacts, PostingService}; +use crate::infra::recognition::sidecar::{PlannedScheduleMaterialization, ScheduleBuilderSidecar}; +use crate::infra::storage::repo::adjustment_repo::NewDebitNote; +use crate::infra::storage::repo::{AdjustmentRepo, ReferenceRepo}; + +/// Origin literal stamped on posts made through this service (mirrors the peer +/// orchestrators). +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// Orchestrates the debit-note domain over the foundation engine (design §4.3). +pub struct DebitNoteHandler { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + adjustment: AdjustmentRepo, + /// ASC 606 recognition tunables (Slice 4): the per-schedule segment ceiling the + /// pure [`ScheduleBuilder`] enforces when the note defers (D4). + recognition_config: RecognitionConfig, + /// The event publisher — threaded into the posting engine AND held so the + /// in-txn sidecar can publish `billing.ledger.debit_note.posted` (Group F). + publisher: Arc, + /// Metrics sink (`ledger_debit_note_total{outcome}`, Group F): one increment + /// per attempt, labelled posted / replayed / rejected. + metrics: Arc, + /// The dual-control engine (VHP-1852). `Some` ⇒ a debit note whose amount + /// crosses the tenant's D2 threshold is gated to the preparer→approver queue + /// ([`DomainError::DualControlRequired`]) instead of posting inline; `None` ⇒ + /// gating is disabled (the executor's approved replay, and the unit tests that + /// construct the handler without the engine). Wired in `module` via + /// [`Self::with_approval`]; mirrors the + /// [`RefundHandler`](super::refund_service::RefundHandler)'s `approval` seam. + approval: Option>, + // Slice 7 Phase 2: routes the `RECOGNITION_POLICY_CONFLICT` stub to a durable + // close-blocking exception row (ADDITIVE beside the rejection). `None` until + // `with_exceptions` wires it (so existing constructions are unchanged). + exceptions: Option>, +} + +impl DebitNoteHandler { + /// Build the handler over one database provider + the event publisher + /// (threaded into the posting engine + the sidecar's in-txn publish) + the + /// metrics sink + the recognition config (the segment ceiling the schedule + /// build enforces when the note defers). Same `db` / `publisher` / `metrics` + /// deps as the peer + /// [`InvoicePostService`](crate::infra::invoice_post::InvoicePostService) / + /// [`CreditNoteHandler`](super::credit_note_service::CreditNoteHandler), plus + /// the [`RecognitionConfig`] the invoice-post also takes (a deferred debit note + /// runs the same derivation). + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + recognition_config: RecognitionConfig, + ) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + let adjustment = AdjustmentRepo::new(db); + Self { + posting, + reference, + resolver, + adjustment, + recognition_config, + publisher, + metrics, + approval: None, + exceptions: None, + } + } + + /// Attach the exception router (Slice 7 Phase 2) so a `RECOGNITION_POLICY_CONFLICT` + /// rejection also opens a durable close-blocking exception row. Additive — the + /// existing rejection is unchanged. + #[must_use] + pub fn with_exceptions(mut self, exceptions: Arc) -> Self { + self.exceptions = Some(exceptions); + self + } + + /// Attach the dual-control engine (VHP-1852): a debit note whose amount crosses + /// the tenant's D2 threshold is then gated to the preparer→approver queue + /// ([`DomainError::DualControlRequired`]) rather than posting inline. The approved + /// replay re-enters through [`Self::post_debit_note_approved`], which skips the + /// gate. Builder form (not a `new` arg) so the executor's un-gated handler and the + /// unit tests stay source-compatible; mirrors the + /// [`RefundHandler::with_approval`](super::refund_service::RefundHandler::with_approval). + #[must_use] + pub fn with_approval(mut self, approval: Arc) -> Self { + self.approval = Some(approval); + self + } + + /// Post a debit note (design §4.3). Validates the request shape, derives the + /// deferred split + (when deferring) the schedule plan via the SAME recognition + /// [`ScheduleBuilder`] path the invoice-post uses, builds the balanced + /// direct-split legs, and posts them with the in-txn [`DebitNotePostSidecar`] + /// (schedule build + headroom seed/raise + `debit_note` row). Idempotent on + /// `(tenant, DEBIT_NOTE, debit_note_id)`. + /// + /// `payer_open` is the payer's lifecycle decision (resolved by the caller, A-2): + /// `false` ⇒ the post is rejected with [`DomainError::PayerClosed`] before any + /// ledger effect (a closed payer cannot be charged). + /// + /// # Errors + /// [`DomainError::PayerClosed`] when `!payer_open`; + /// [`DomainError::AmountOutOfRange`] / [`DomainError::InvalidRequest`] (shape); + /// any recognition-derivation block when the note defers + /// ([`DomainError::SspSnapshotRequired`], [`DomainError::RecognitionPolicyConflict`], + /// [`DomainError::ScheduleTooLong`], [`DomainError::RecognitionWithoutInvoiceLink`]); + /// any foundation rejection (unbalanced/empty/period-closed/account-closed/…) or + /// [`DomainError::Internal`] on an infra fault. + pub async fn post_debit_note( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: DebitNoteRequest, + payer_open: bool, + ) -> Result { + let result = self + .post_debit_note_inner(ctx, scope, req, payer_open, /* gate */ true) + .await; + // One `ledger_debit_note_total{outcome}` increment per attempt (Group F). + self.metrics.debit_note(note_outcome(&result)); + result + } + + /// The approved-replay entry (VHP-1852): re-drive a held debit note WITHOUT the + /// dual-control gate. Called only by the `ApprovalExecutor` after a second actor + /// approves the PENDING debit-note approval — the threshold was already crossed + /// at gate time, so re-checking it would re-open a second approval (an infinite + /// loop). Idempotent on the engine's `(tenant, DEBIT_NOTE, debit_note_id)` claim: + /// a re-approve replays the post harmlessly (the dedup short-circuits a committed + /// entry before the sidecar), so execute-then-mark is safe. Mirrors + /// [`RefundHandler::post_refund_approved`](super::refund_service::RefundHandler::post_refund_approved). + /// + /// # Errors + /// As [`Self::post_debit_note`], minus the dual-control gate (never returns + /// [`DomainError::DualControlRequired`]). + pub async fn post_debit_note_approved( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: DebitNoteRequest, + payer_open: bool, + ) -> Result { + let result = self + .post_debit_note_inner(ctx, scope, req, payer_open, /* gate */ false) + .await; + self.metrics.debit_note(note_outcome(&result)); + result + } + + /// Build + post the debit note (no metrics — the public wrappers record + /// them). Carries the payer-close gate, the F4 link gate, the dual-control gate + /// (over D2, `gate == true` only), and the in-txn `debit_note.posted` event + /// publish. + async fn post_debit_note_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: DebitNoteRequest, + payer_open: bool, + gate: bool, + ) -> Result { + // Payer-close gate (A-2, §7) — before any read/effect, mirroring + // `InvoicePostService::post_invoice`. A closed payer cannot be charged. + if !payer_open { + return Err(DomainError::PayerClosed(format!( + "payer {} is closed", + req.payer_tenant_id + ))); + } + + // 1. Pure shape gate (amounts, deferral/recognition shape) — a clean 400 + // before any read. + validate_shape(&req)?; + + // A zero-amount note has no charge and would fail the engine's empty-entry + // validation; reject up-front (inherited S1 / AC #4 — no zero placeholder). + if req.amount_minor == 0 { + return Err(DomainError::InvalidRequest( + "debit note amount_minor must be > 0".to_owned(), + )); + } + + // 1b. Originating-invoice link gate (F4, design §4.3 / §5): a debit note + // MUST link a posted invoice (an `INVOICE_POST` entry for the + // origin_invoice_id). No posted invoice ⇒ `NOTE_INVOICE_NOT_FOUND` + // (404), BEFORE any read/build/post (no orphan charge entry). Scoped + // existence (SQL-level BOLA) — a foreign-tenant invoice reads as + // absent, the same 404, no existence leak. + if !self + .adjustment + .posted_invoice_exists_out_of_txn(scope, req.tenant_id, &req.origin_invoice_id) + .await + .map_err(|e| DomainError::Internal(format!("posted-invoice existence: {e}")))? + { + return Err(DomainError::NoteInvoiceNotFound(format!( + "debit note {} references invoice {} which has no posted INVOICE_POST entry", + req.debit_note_id, req.origin_invoice_id + ))); + } + + // 1c. Dual-control gate (VHP-1852, design §1.4 D2 / §4.3). Gated on the debit + // note's amount crossing the tenant's D2 threshold, AFTER the payer-close + // gate + shape gate (a malformed request 400s) AND the originating-invoice + // link gate (an absent origin 404s) — so none of those open an approval — + // but BEFORE any recognition-derivation/build/post. Above the threshold ⇒ + // a PENDING approval is created and `DualControlRequired` is returned (the + // REST handler maps it to 409); at/under threshold ⇒ inline, unchanged. The + // approved replay (`gate == false`) skips this — the threshold was already + // crossed at gate time. Mirrors the refund gate. + if gate && let Some(approval) = &self.approval { + let intent = ApprovalIntent::DebitNote(DebitNoteIntent::from(&req)); + let facts = OperationFacts { + kind: ApprovalKind::DebitNote, + // FX-SIMPLIFICATION (DC10 / FX = Slice 5): transaction-currency minor, + // not USD-eq. Single-currency until the FX slice lands; mirrors the + // refund gate's comment. + amount_usd_eq_minor: Some(req.amount_minor), + effective_at: None, + has_outstanding_balance: false, + }; + if let Some(approval_id) = approval + .gate(ctx, scope, intent, facts, "debit_note".to_owned()) + .await? + { + return Err(DomainError::DualControlRequired(format!( + "debit note requires dual-control approval: {approval_id}" + ))); + } + } + + // 2. Recognition derivation (D4) — when the note defers, run the SAME pure + // ScheduleBuilder path the invoice-post uses to build the schedule that + // will release the deferred Contract-liability. Returns the schedule plan + // (empty when fully recognized). A derivation block (SSP / policy / + // segment-ceiling) propagates → the post fails, so no orphan deferral. + // + // Slice 7 Phase 2: the policy-conflict block surfaces here from the sync + // `derive_schedule` helper (where `.await` is not reachable); ADDITIVELY open + // a durable close-blocking exception row beside the rejection before + // propagating it (the note is still rejected; this only makes the + // recognition-policy conflict block the next close until resolved). + let schedules = match self.derive_schedule(&req) { + Ok(s) => s, + Err(e @ DomainError::RecognitionPolicyConflict(_)) => { + if let Some(ex) = &self.exceptions { + ex.route( + req.tenant_id, + crate::domain::exception::ExceptionType::RecognitionPolicyConflict, + &req.debit_note_id, + Some(serde_json::json!({ + "debit_note_id": req.debit_note_id, + "origin_invoice_id": req.origin_invoice_id, + })), + ) + .await; + } + return Err(e); + } + Err(other) => return Err(other), + }; + + // 3. Build the balanced direct-split leg plan (DR AR / CR REVENUE / CR CL / + // CR TAX) — a mirror of the S1 invoice-post split for one charge line. + let plan = build_debit_note_legs(&req)?; + + // 4. Resolve chart accounts + scale, assemble the engine entry + lines. + let (entry, lines) = self.assemble_post(ctx, scope, &req, &plan).await?; + + // The posted AR incl. tax this note raises the headroom against — the + // first-touch seed basis (out-of-txn; the row is seeded/raised in-txn by the + // sidecar). A no-op on a re-seed (a prior note on this invoice), so the + // original_total stays the invoice's posted AR; the debit note only bumps + // debit_note_total_minor. + let posted_ar_incl_tax = self.read_posted_ar(scope, &req).await?; + + // 5. Post via the invariant engine with the in-txn sidecar (schedule build + + // headroom seed/raise + debit_note row). The engine's Fresh claim on + // (tenant, DEBIT_NOTE, debit_note_id) is the idempotency gate. + let schedule_sidecar = if schedules.is_empty() { + None + } else { + Some(ScheduleBuilderSidecar { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + source_invoice_id: req.origin_invoice_id.clone(), + schedules, + idempotency: IdempotencyGate::new(), + // A debit note EXTENDS the live schedule for its key — discriminate + // its SCHEDULE_BUILD claim by the note id so it does not replay (and + // skip) the base build. + build_discriminator: Some(req.debit_note_id.clone()), + }) + }; + let sidecar: Arc = Arc::new(DebitNotePostSidecar { + tenant_id: req.tenant_id, + origin_invoice_id: req.origin_invoice_id.clone(), + currency: req.currency.clone(), + posted_ar_incl_tax, + debit_note_amount_minor: req.amount_minor, + // The event payload's identity + recognized/deferred split parts (the + // posted entry id is filled in-txn from `PostedFacts`). + debit_note_id: req.debit_note_id.clone(), + recognized_part_minor: plan.recognized_part_minor, + deferred_part_minor: plan.deferred_part_minor, + // Published in-txn (transactional outbox) so the event commits + // atomically with the entry + counters, or rolls back with them. + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + schedule_sidecar, + debit_note_row: NewDebitNote { + tenant_id: req.tenant_id, + debit_note_id: req.debit_note_id.clone(), + origin_invoice_id: req.origin_invoice_id.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + recognized_part_minor: plan.recognized_part_minor, + deferred_part_minor: plan.deferred_part_minor, + created_at_utc: Utc::now(), + }, + }); + + self.posting + .post(ctx, scope, entry, lines, Some(sidecar)) + .await + } + + /// Run the pure recognition derivation for a deferring debit note, returning the + /// schedule(s) to materialize (empty when the note is fully recognized). Mirrors + /// [`InvoicePostService::derive_recognition`](crate::infra::invoice_post) for a + /// single charge line: build a [`RecognitionContext`] over the note's ex-tax + /// amount + the post period, call [`ScheduleBuilder::derive`], and on a + /// [`ScheduleOutcome::Schedule`] pair it with the targeted invoice-item ref + /// (§4.7) into a [`PlannedScheduleMaterialization`]. + /// + /// The note's own `deferred_minor` is the authority on HOW MUCH defers (the leg + /// builder uses it directly); the derivation here owns the schedule SHAPE + /// (segments / `policy_ref` / refs) and the trigger gates. A non-deferred note + /// (`deferred_minor == 0`) yields no schedule even if it carried a spec; a + /// deferring note that the policy resolves to point-in-time is an invalid mix + /// the derivation surfaces as an `AmountOutOfRange` / policy block. + /// + /// # Errors + /// Any block the derivation raises ([`DomainError::SspSnapshotRequired`], + /// [`DomainError::RecognitionPolicyConflict`], [`DomainError::ScheduleTooLong`], + /// [`DomainError::AmountOutOfRange`]), or the §4.7 invoice-item-link + /// [`DomainError::RecognitionWithoutInvoiceLink`] gate (a deferred note must + /// carry the ref its schedule draws down). + fn derive_schedule( + &self, + req: &DebitNoteRequest, + ) -> Result, DomainError> { + // Fully-recognized note ⇒ no schedule (validate_shape already guaranteed a + // deferring note carries a spec; a non-deferring note ignores any spec). + if req.deferred_minor == 0 { + return Ok(Vec::new()); + } + let Some(input) = &req.recognition else { + // Unreachable once validate_shape passed (a deferring note has a spec); + // guard rather than unwrap. + return Err(DomainError::InvalidRequest( + "deferred debit note missing recognition spec".to_owned(), + )); + }; + + let policy = DefaultDeferralPolicyResolver; + let ssp = DefaultSspResolver; + let vc = DefaultVcResolver; + let builder = ScheduleBuilder::new(&policy, &ssp, &vc, &self.recognition_config); + + let ex_tax = req.amount_minor_ex_tax(); + let period_id = current_period_id(); + let ctx = RecognitionContext { + input, + invoice_period_id: &period_id, + // Only the DEFERRED part defers — the recognized part books to REVENUE + // now. The builder lays `item_amount_minor_ex_tax` out across the + // schedule segments, so pass the note's `deferred_minor` (clamped), NOT + // the whole ex-tax amount, else the schedule over-defers (total_deferred + // would be the full ex-tax, not the deferred split). Matches the CL leg + // `build_debit_note_legs` books (it defers the same clamped amount). + item_amount_minor_ex_tax: req.deferred_minor.clamp(0, ex_tax), + // A debit note is a single charge line; its own ex-tax is the + // R4-materiality denominator (no surrounding invoice total here). + invoice_total_minor: ex_tax, + currency: &req.currency, + revenue_stream: &req.revenue_stream, + }; + match builder.derive(&ctx)? { + ScheduleOutcome::NoDeferral => { + // The note asked to defer (`deferred_minor > 0`) but the policy + // resolved point-in-time — a contradictory request. Reject rather + // than silently posting a deferred CL with no releasing schedule. + Err(DomainError::RecognitionPolicyConflict(format!( + "debit note requests a deferred part ({}) but its recognition policy `{}` \ + resolves point-in-time", + req.deferred_minor, input.policy_ref + ))) + } + ScheduleOutcome::Schedule(schedule) => { + // §4.7 invoice-item-link: the schedule's NOT-NULL + // source_invoice_item_ref must resolve to a non-empty ref (the + // Contract-liability line this post creates). Block before the post + // with the specific RecognitionWithoutInvoiceLink (no orphan). + let item_ref = req + .origin_invoice_item_ref + .as_deref() + .filter(|r| !r.is_empty()) + .ok_or_else(|| { + DomainError::RecognitionWithoutInvoiceLink(format!( + "deferred debit note (stream `{}`) must carry an \ + origin_invoice_item_ref to anchor its contract-liability schedule", + req.revenue_stream + )) + })?; + Ok(vec![PlannedScheduleMaterialization { + schedule, + source_invoice_item_ref: item_ref.to_owned(), + }]) + } + } + } + + /// Read the invoice's posted-AR-incl-tax — the `invoice_exposure.original_total_minor` + /// first-touch seed basis (design §4.7). Out-of-txn scoped read on a fresh + /// connection (the headroom row is seeded in-txn by the sidecar); identical to + /// the basis the [`CreditNoteHandler`](super::credit_note_service) seeds. A + /// debit note on an invoice with no posted AR line reads `0` — the headroom then + /// floors on the debit-note total the bump adds. + async fn read_posted_ar( + &self, + scope: &AccessScope, + req: &DebitNoteRequest, + ) -> Result { + self.adjustment + .read_posted_ar_incl_tax_out_of_txn(scope, req.tenant_id, &req.origin_invoice_id) + .await + .map_err(|e| DomainError::Internal(format!("read posted AR: {e}"))) + } + + /// Resolve each planned leg's chart `account_id` + currency scale and assemble + /// the engine [`NewEntry`] + [`NewLine`] vector. The per-stream classes + /// (`REVENUE` / `CONTRACT_LIABILITY`) resolve on their stream; the rest resolve + /// stream-less. The header is `source_doc_type = DebitNote` + + /// `source_business_id = debit_note_id` (the engine's idempotency key). Mirrors + /// [`CreditNoteHandler::assemble_post`](super::credit_note_service). + async fn assemble_post( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &DebitNoteRequest, + plan: &DebitNoteLegPlan, + ) -> Result<(NewEntry, Vec), DomainError> { + let chart = load_chart(&self.reference, scope, req.tenant_id).await?; + let scale = self + .resolver + .resolve(scope, req.tenant_id, &req.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + + let eff_date = Utc::now().date_naive(); + let period_id = format!("{:04}{:02}", eff_date.year(), eff_date.month()); + + let mut lines: Vec = Vec::with_capacity(plan.legs.len()); + for leg in &plan.legs { + let account_id = chart + .resolve( + leg.account_class, + &req.currency, + leg.revenue_stream.as_deref(), + ) + .ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + leg.account_class.as_str(), + leg.revenue_stream, + req.currency + )) + })?; + lines.push(Self::mk_line(req, leg, account_id, scale)); + } + + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: req.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: req.tenant_id, + period_id, + entry_currency: req.currency.clone(), + source_doc_type: SourceDocType::DebitNote, + // The engine's `(tenant, DEBIT_NOTE, debit_note_id)` idempotency key. + source_business_id: req.debit_note_id.clone(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: eff_date, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: ctx.subject_id(), + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + // Slice 5: same-currency in v1 (no FX lock on this path). + rate_snapshot_ref: None, + }; + Ok((entry, lines)) + } + + /// Map one [`PlannedLeg`] + its resolved chart account/scale to the engine + /// [`NewLine`]. The DR `AR` carries `payer_tenant_id` + `invoice_id` (its cache + /// grain keys on them); the per-stream `REVENUE` / `CONTRACT_LIABILITY` legs + /// carry their `revenue_stream` (the per-stream account + the DB CHECK need it). + fn mk_line(req: &DebitNoteRequest, leg: &PlannedLeg, account_id: Uuid, scale: u8) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: req.payer_tenant_id, + seller_tenant_id: Some(req.tenant_id), + resource_tenant_id: None, + account_id, + account_class: leg.account_class, + gl_code: None, + side: leg.side, + amount_minor: leg.amount_minor, + currency: req.currency.clone(), + currency_scale: scale, + invoice_id: Some(req.origin_invoice_id.clone()), + due_date: None, + revenue_stream: leg.revenue_stream.clone(), + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + // Tax dims (jurisdiction / filing-period / rate) carry the posted + // TaxBreakdown evidence the leg routed: `Some` on a per-component + // TAX_PAYABLE leg (so the projector disaggregates `tax_subbalance` per + // (jurisdiction, filing), §4.5), `None` on every other leg + the legacy + // single dimensionless tax leg. The amount is the posted tax (never + // recomputed here, §4.3). + tax_jurisdiction: leg.tax_jurisdiction.clone(), + tax_filing_period: leg.tax_filing_period.clone(), + tax_rate_ref: leg.tax_rate_ref.clone(), + legal_entity_id: None, + // The deferred CL line's schedule draws down this ref (§4.7); carried on + // every leg for lineage (the schedule was built against it). + invoice_item_ref: req.origin_invoice_item_ref.clone(), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: req.recognition.as_ref().and_then(po_group), + credit_grant_event_type: None, + ar_status: None, + } + } +} + +/// The PO/allocation group of a recognition spec (the line dim the deferred CL +/// books under, §4.7) — `None` for a spec with no group. +fn po_group(input: &RecognitionInput) -> Option { + input.po_allocation_group.clone() +} + +/// Classify a debit-note attempt result into its `ledger_debit_note_total` +/// `outcome` label (Group F): a replay is `replayed`, a fresh post is `posted`, +/// and every rejection (payer-closed / shape / not-found / recognition block / +/// foundation / infra) folds into `rejected`. A debit note has no split-ambiguous +/// or headroom-cap block (it raises the headroom), so it never reports +/// `blocked_*` — unlike the credit-note classifier. +fn note_outcome(result: &Result) -> NoteOutcome { + match result { + Ok(r) if r.replayed => NoteOutcome::Replayed, + Ok(_) => NoteOutcome::Posted, + Err(_) => NoteOutcome::Rejected, + } +} + +/// The current fiscal `period_id` (`YYYYMM`) — the period the debit note posts +/// into + the schedule's first-segment default when the spec leaves it open. The +/// post itself derives the same value in `assemble_post`; the schedule build reads +/// it here as the derivation's invoice-period. +fn current_period_id() -> String { + let now = Utc::now().date_naive(); + format!("{:04}{:02}", now.year(), now.month()) +} + +/// The in-transaction [`PostSidecar`] for a debit note: runs AFTER balance +/// projection and BEFORE the dedup finalize (fresh-claim path only — a replay +/// returns before the sidecar), so all its writes commit atomically with the +/// journal entry or roll back with it (design §4.3 / §4.7). It performs, in the +/// §4.7 lock order (`recognition_schedule` / `recognition_segment` BEFORE +/// `invoice_exposure`): +/// +/// 1. **Schedule build (D4)** — when the note defers, delegates to the SAME +/// [`ScheduleBuilderSidecar`] the invoice-post uses (held as +/// [`Self::schedule_sidecar`]): claims `SCHEDULE_BUILD` idempotency, mints the +/// `schedule_id`, inserts the `recognition_schedule` + segments. A +/// fully-recognized note carries `None` here (no schedule touch). +/// 2. **Headroom raise** — first-touch seed `invoice_exposure` +/// (`original_total_minor` = posted AR), then bump `debit_note_total_minor` by +/// the note's incl-tax amount. This RAISES the cap (the RHS of the headroom +/// CHECK), so it can never be rejected by the headroom guard. +/// 3. **Persist** the `debit_note` row. +pub struct DebitNotePostSidecar { + tenant_id: Uuid, + origin_invoice_id: String, + currency: String, + /// The posted AR incl. tax — the `invoice_exposure.original_total_minor` seed + /// (a no-op on a re-seed; the running counters are never reset). + posted_ar_incl_tax: i64, + /// The note's incl-tax amount — the `debit_note_total_minor` raise delta + + /// the published event's `amount_minor`. + debit_note_amount_minor: i64, + /// The note's business id — the published event's `debit_note_id`. + debit_note_id: String, + /// The ex-tax recognized part — the published event's `recognized_part_minor`. + recognized_part_minor: i64, + /// The ex-tax deferred part — the published event's `deferred_part_minor`. + deferred_part_minor: i64, + /// The event publisher: `billing.ledger.debit_note.posted` is published IN + /// this post txn (the transactional outbox) so it commits atomically with the + /// entry + counters, or rolls back with them. Mirrors the credit-note sidecar. + publisher: Arc, + /// The security context for the in-txn outbox publish (cloned by the handler). + ctx: SecurityContext, + /// The schedule-build sidecar to run FIRST (lock order) when the note defers; + /// `None` for a fully-recognized note. The SAME type the invoice-post threads, + /// reused 1:1 (no duplication of the build/idempotency logic). + schedule_sidecar: Option, + /// The `debit_note` record to persist. + debit_note_row: NewDebitNote, +} + +#[async_trait::async_trait] +impl PostSidecar for DebitNotePostSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + // 1. Schedule build (D4) — FIRST in the lock order (recognition_schedule / + // recognition_segment, ranks before invoice_exposure). Delegate to the + // identical invoice-post sidecar: it claims SCHEDULE_BUILD, mints the + // schedule id, and inserts schedule + segments in THIS txn (or rolls the + // whole post back). `None` ⇒ fully-recognized note, no schedule. + if let Some(sc) = &self.schedule_sidecar { + sc.run(txn, scope, posted).await?; + } + + // 2. Headroom raise (rank after the recognition rows). First-touch seed the + // invoice_exposure row (original_total = posted AR), then RAISE + // debit_note_total_minor by the note's incl-tax amount. Raising the RHS + // of the headroom CHECK can never trip it (it only widens the cap), so + // this is a plain bump with no cap-refinement mapping. + AdjustmentRepo::seed_exposure_first_touch( + txn, + scope, + self.tenant_id, + &self.origin_invoice_id, + &self.currency, + self.posted_ar_incl_tax, + ) + .await + .map_err(|e| DomainError::Internal(format!("seed invoice_exposure: {e}")))?; + AdjustmentRepo::add_debit_note_total( + txn, + scope, + self.tenant_id, + &self.origin_invoice_id, + self.debit_note_amount_minor, + ) + .await + .map_err(|e| DomainError::Internal(format!("raise debit_note_total: {e}")))?; + + // 3. Persist the debit_note record row. + AdjustmentRepo::insert_debit_note(txn, scope, &self.debit_note_row) + .await + .map_err(|e| DomainError::Internal(format!("insert debit_note: {e}")))?; + + // 4. Publish `billing.ledger.debit_note.posted` into the SAME post txn + // (transactional outbox): the event row commits atomically with the + // entry + the schedule/headroom/record writes, or a publish failure + // rolls the whole post back. Never on replay (a replay returns before + // the sidecar). Ids + amount + split parts only (no PII). + self.publisher + .publish_debit_note_posted( + &self.ctx, + txn, + DebitNotePosted { + tenant_id: self.tenant_id, + debit_note_id: self.debit_note_id.clone(), + origin_invoice_id: self.origin_invoice_id.clone(), + entry_id: posted.entry_id, + currency: self.currency.clone(), + amount_minor: self.debit_note_amount_minor, + recognized_part_minor: self.recognized_part_minor, + deferred_part_minor: self.deferred_part_minor, + posted_at_utc: Utc::now(), + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("publish debit_note_posted: {e}")))?; + + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/adjustment/manual_adjustment_service.rs b/gears/bss/ledger/ledger/src/infra/adjustment/manual_adjustment_service.rs new file mode 100644 index 000000000..7bed0a891 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/adjustment/manual_adjustment_service.rs @@ -0,0 +1,607 @@ +//! `ManualAdjustmentHandler` — the Slice-3 Phase-3 governed manual-adjustment +//! orchestrator (design §4.6, Group 4). It posts a governed manual adjustment's +//! balanced entry through the invariant [`PostingService`] after clearing the pure +//! [`govern`] gate, and publishes `billing.ledger.manual_adjustment.posted` in the +//! SAME post txn (via a [`PostSidecar`]). It is the ledger's governed escape hatch +//! for corrections the typed flows (invoice / settle / allocate / S3 notes / S4 +//! recognition) do not cover — rounding residue, suspense / cash-clearing clean-up. +//! +//! **Flow.** +//! 1. **govern** ([`govern`], pure, out-of-txn): a [`ManualAdjustmentReject`] +//! short-circuits the post. A generic [`ManualAdjustmentReject::NotAllowed`] +//! becomes [`DomainError::ManualAdjustmentNotAllowed`] (a 400, no books effect); +//! a [`ManualAdjustmentReject::AttemptedWriteOff`] additionally fires a +//! [`SecuredAuditSink`] capture + the `AttemptedWriteOff` page out-of-band +//! (Group 4 / §6 A4) before the same 400 — a disguised bad-debt is a +//! deliberate-misuse signal, not a benign typo. +//! 2. **payer gate**: an `AR` / `UNALLOCATED` leg posts against a payer-scoped +//! balance, so `payer_tenant_id` MUST be present (the projector grains AR / +//! UNALLOCATED per payer); otherwise the adjustment is a payer-less internal +//! move attributed to the tenant itself. +//! 3. **assemble + post**: resolve each leg's chart account + currency scale, build +//! the balanced engine entry (`source_doc_type = MANUAL_ADJUSTMENT`, +//! `source_business_id = adjustment_id`), and post it with the +//! [`ManualAdjustmentPostSidecar`] (publishes the event in the post txn). +//! +//! **Idempotency** is the engine's `(tenant, MANUAL_ADJUSTMENT, adjustment_id)` +//! claim: the entry's `source_doc_type = ManualAdjustment` + `source_business_id = +//! adjustment_id` make [`PostingService::post`]'s `Fresh` claim the at-most-once +//! gate (a replay returns before the sidecar — byte-identical to the notes / refund +//! handlers). +//! +//! **Dual-control (Group 5 / Phase 3).** `SoD` (`preparer ≠ approver`) + the lifecycle +//! are the `ApprovalService`'s concern; this handler holds an OPTIONAL +//! [`ApprovalService`](crate::infra::approval::service::ApprovalService) seam +//! ([`ManualAdjustmentHandler::with_approval`]) and, when wired, gates a governed +//! manual adjustment whose gross (`Σ DR`) crosses the tenant's D2 threshold to the +//! preparer→approver queue ([`DomainError::DualControlRequired`]) BEFORE the post +//! (mirroring the refund gate). The approved replay re-enters through +//! [`ManualAdjustmentHandler::post_manual_adjustment_approved`] (gate-skipped, +//! executor-only). The un-gated handler (`None` approval) is the executor's replay +//! handler; the REST surface wires the gated one. There is also NO schedule / +//! headroom / record sidecar row +//! (unlike the credit-note sidecar): a governed manual adjustment touches no +//! `recognition_schedule` / `invoice_exposure` and writes no per-adjustment record +//! table in this MVP — its durable trail is the journal entry + the published event +//! (+ the secured-audit capture on the write-off path). +//! +//! Lives in `infra` (not `domain`): it needs repo + posting access; the +//! [`manual`](crate::domain::adjustment::manual) domain it calls stays pure (dylint +//! DE0301). Wraps the `pub` [`PostingService`] + repos directly (like +//! [`CreditNoteHandler`](super::credit_note_service::CreditNoteHandler)) so it is +//! constructible from out-of-crate integration tests. + +use std::sync::Arc; + +use bss_ledger_sdk::{AccountClass, MappingStatus, PostingRef, Side, SourceDocType}; +use chrono::{Datelike, Utc}; +use sea_orm::DbErr; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::adjustment::manual::{ + ManualAdjustmentReject, ManualAdjustmentRequest, ManualLeg, govern, +}; +use crate::domain::approval::ApprovalKind; +use crate::domain::approval::intent::{ApprovalIntent, ManualAdjustmentIntent}; +use crate::domain::approval::policy::OperationFacts; +use crate::domain::error::DomainError; +use crate::domain::model::{NewEntry, NewLine}; +use crate::infra::approval::service::ApprovalService; +use crate::infra::audit::secured_audit_sink::{AuditEventType, SecuredAuditSink}; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::payloads::{ + AlarmCategory, AlarmSeverity, LedgerInvariantAlarm, ManualAdjustmentPosted, +}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::posting::chart::load_chart; +use crate::infra::posting::service::{PostSidecar, PostedFacts, PostingService}; +use crate::infra::storage::repo::ReferenceRepo; + +/// Origin literal stamped on posts made through this service (mirrors the peer +/// orchestrators). +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// The ledger error code stamped on the `AttemptedWriteOff` page + the +/// secured-audit capture (the canonical 400 the handler maps the reject to). +const CODE_MANUAL_ADJUSTMENT_NOT_ALLOWED: &str = "MANUAL_ADJUSTMENT_NOT_ALLOWED"; + +/// Orchestrates the governed manual-adjustment domain over the foundation engine +/// (design §4.6). +pub struct ManualAdjustmentHandler { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + /// The event publisher — threaded into the posting engine AND held so the in-txn + /// sidecar can publish `billing.ledger.manual_adjustment.posted`, and so the + /// write-off path can raise the `AttemptedWriteOff` alarm out-of-band. + publisher: Arc, + /// The Slice-6 secured-audit port — the write-off path captures the actor on its + /// OWN committed transaction (the post was rejected, so there is no post txn to + /// ride). No-op until Slice 6 (VHP-1858) merges its real store. + audit: Arc, + /// Provider held to open the out-of-band write-off capture transaction (the + /// reject has no post txn to ride). A cheap clone of the same provider the + /// posting engine + repos wrap. + db: DBProvider, + /// The dual-control engine (VHP-1852, Group 5 / Phase 3). `Some` ⇒ a governed + /// manual adjustment whose gross crosses the tenant's D2 threshold is gated to the + /// preparer→approver queue ([`DomainError::DualControlRequired`]) instead of + /// posting inline; `None` ⇒ gating is disabled (the executor's approved replay, + /// and the Group-4 unit tests that construct the handler without the engine). + /// Wired in `module` (Group 6) via [`Self::with_approval`]; mirrors the + /// [`RefundHandler`](super::refund_service::RefundHandler)'s `approval` seam. + approval: Option>, +} + +impl ManualAdjustmentHandler { + /// Build the handler over one database provider, the event publisher (threaded + /// into the posting engine, the sidecar's in-txn publish, and the write-off + /// alarm), and the secured-audit sink (the write-off capture). Same `db` / + /// `publisher` deps as the peer + /// [`CreditNoteHandler`](super::credit_note_service::CreditNoteHandler); the + /// `audit` sink mirrors the + /// [`RefundHandler`](super::refund_service::RefundHandler)'s `unknown_final` + /// disposition wiring. No metrics: a write-off is observed through the + /// `AttemptedWriteOff` alarm, and there is no §9 manual-adjustment counter. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + audit: Arc, + ) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + Self { + posting, + reference, + resolver, + publisher, + audit, + db, + approval: None, + } + } + + /// Attach the dual-control engine (Group 5 / Phase 3): a governed manual + /// adjustment whose gross (`Σ DR`) crosses the tenant's D2 threshold is then gated + /// to the preparer→approver queue ([`DomainError::DualControlRequired`]) rather + /// than posting inline. The approved replay re-enters through + /// [`Self::post_manual_adjustment_approved`], which skips the gate. Builder form + /// (not a `new` arg) so the executor's un-gated handler and the unit tests stay + /// source-compatible; mirrors the + /// [`RefundHandler::with_approval`](super::refund_service::RefundHandler::with_approval). + #[must_use] + pub fn with_approval(mut self, approval: Arc) -> Self { + self.approval = Some(approval); + self + } + + /// Post a governed manual adjustment (design §4.6). Runs the pure [`govern`] + /// gate, the payer gate, the dual-control gate (over the D2 threshold), assembles + /// the balanced legs, and posts them with the in-txn [`ManualAdjustmentPostSidecar`] + /// (event publish). Idempotent on `(tenant, MANUAL_ADJUSTMENT, adjustment_id)`. + /// + /// # Errors + /// [`DomainError::ManualAdjustmentNotAllowed`] for a governance reject (a generic + /// [`ManualAdjustmentReject::NotAllowed`] OR the + /// [`ManualAdjustmentReject::AttemptedWriteOff`] — both map to the canonical 400, + /// the latter additionally captured + paged), or for a missing `payer_tenant_id` + /// on an `AR` / `UNALLOCATED` leg; [`DomainError::DualControlRequired`] when the + /// gross crosses the tenant's D2 threshold (a PENDING approval is created — the + /// REST handler maps it to 409); [`DomainError::AccountClosed`] when a leg's class + /// has no provisioned account; any foundation rejection or + /// [`DomainError::Internal`] on an infra fault. + pub async fn post_manual_adjustment( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: ManualAdjustmentRequest, + ) -> Result { + self.post_manual_adjustment_inner(ctx, scope, req, /* gate */ true) + .await + } + + /// The approved-replay entry (Group 5 / Phase 3): re-drive a held manual + /// adjustment WITHOUT the dual-control gate. Called only by the `ApprovalExecutor` + /// after a second actor approves the PENDING approval — the threshold was already + /// crossed at gate time, so re-checking it would re-open a second approval (an + /// infinite loop). Idempotent on the engine's + /// `(tenant, MANUAL_ADJUSTMENT, adjustment_id)` claim: a re-approve replays the + /// post harmlessly (the dedup short-circuits a committed entry before the + /// sidecar), so execute-then-mark is safe. Mirrors + /// [`RefundHandler::post_refund_approved`](super::refund_service::RefundHandler::post_refund_approved). + /// + /// # Errors + /// As [`Self::post_manual_adjustment`], minus the dual-control gate (never returns + /// [`DomainError::DualControlRequired`]). + pub async fn post_manual_adjustment_approved( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: ManualAdjustmentRequest, + ) -> Result { + self.post_manual_adjustment_inner(ctx, scope, req, /* gate */ false) + .await + } + + /// Shared body for [`Self::post_manual_adjustment`] (gated) + + /// [`Self::post_manual_adjustment_approved`] (the approved replay). `gate` ⇒ a + /// governed manual adjustment over the D2 threshold routes to dual-control instead + /// of posting. Order: govern → payer gate → dual-control gate → assemble → post. + async fn post_manual_adjustment_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: ManualAdjustmentRequest, + gate: bool, + ) -> Result { + // 1. Pure governance gate (design §4.6). A Reject short-circuits the post. + match govern(&req) { + Ok(()) => {} + Err(ManualAdjustmentReject::NotAllowed(d)) => { + // A plain governance violation: the canonical 400, no books effect, + // no alarm / capture. + return Err(DomainError::ManualAdjustmentNotAllowed(d)); + } + Err(ManualAdjustmentReject::AttemptedWriteOff(d)) => { + // A disguised bad-debt write-off: capture the actor + page Revenue + // Assurance out-of-band (the post is rejected with NO books effect), + // then return the SAME canonical 400 as the generic reject. + self.capture_and_page_write_off(ctx, scope, &req, &d).await; + return Err(DomainError::ManualAdjustmentNotAllowed(d)); + } + } + + // 2. Payer gate: an AR / UNALLOCATED leg posts against a payer-scoped balance + // (the projector grains AR / UNALLOCATED per payer), so payer_tenant_id + // MUST be present. A payer-less internal clean-up (SUSPENSE / CASH_CLEARING + // / GOODWILL only) is attributed to the tenant itself. + let touches_payer_class = req.legs.iter().any(|l| { + matches!( + l.account_class, + AccountClass::Ar | AccountClass::Unallocated + ) + }); + if touches_payer_class && req.payer_tenant_id.is_none() { + return Err(DomainError::ManualAdjustmentNotAllowed( + "AR/UNALLOCATED leg requires payer_tenant_id".to_owned(), + )); + } + let payer = req.payer_tenant_id.unwrap_or(req.tenant_id); + + // 2b. Dual-control gate (VHP-1852, Group 5 / Phase 3 / design §1.4 D2 / §4.6). + // A governed manual adjustment is a money-affecting governed posting, gated + // BEFORE the post (the journal entry does not exist at gate time — like a + // refund). Above the tenant's D2 threshold ⇒ a PENDING approval is created + // and `DualControlRequired` is returned (the REST handler maps it to 409); + // at/under threshold ⇒ inline, unchanged. The approved replay + // (`gate == false`) skips this — the threshold was already crossed at gate + // time. `assemble_post` re-derives the gross independently below (both = + // Σ DR), which is fine. + if gate && let Some(approval) = &self.approval { + // Gross = Σ DR (== Σ CR; govern balanced the legs). `i128` accumulate then + // i64 cast (govern rejected an out-of-i64 set) — the D2 comparand. + let gross_i128: i128 = req + .legs + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| i128::from(l.amount_minor)) + .sum(); + let gross = i64::try_from(gross_i128).map_err(|_| { + DomainError::Internal("manual adjustment gross overflows i64".to_owned()) + })?; + let intent = ApprovalIntent::ManualAdjustment(ManualAdjustmentIntent::from(&req)); + let facts = OperationFacts { + kind: ApprovalKind::ManualAdjustment, + // FX-SIMPLIFICATION (DC10 / FX = Slice 5): the comparand is the + // adjustment's TRANSACTION-currency minor, not a USD-eq translation. + // The ledger is single-currency until the FX slice lands (transaction + // == functional currency), so the D2 compare is currency-correct today; + // when FX lands this MUST source the operation's rate snapshot. Mirrors + // the refund gate's comment. + amount_usd_eq_minor: Some(gross), + effective_at: None, + has_outstanding_balance: false, + }; + if let Some(approval_id) = approval + .gate(ctx, scope, intent, facts, "manual_adjustment".to_owned()) + .await? + { + return Err(DomainError::DualControlRequired(format!( + "manual adjustment requires dual-control approval: {approval_id}" + ))); + } + } + + // 3. Resolve chart accounts + scales, assemble the engine entry + lines, and + // compute the gross amount (Σ DR == Σ CR; govern guaranteed the balance). + let (entry, lines, amount_minor) = self.assemble_post(ctx, scope, &req, payer).await?; + + // 4. Post via the invariant engine with the in-txn event sidecar. The + // engine's Fresh claim on (tenant, MANUAL_ADJUSTMENT, adjustment_id) is the + // idempotency gate; the sidecar publishes the event in the post txn (fresh + // post only — a replay returns before the sidecar). + let sidecar: Arc = Arc::new(ManualAdjustmentPostSidecar { + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + event_template: ManualAdjustmentPosted { + tenant_id: req.tenant_id, + adjustment_id: req.adjustment_id.clone(), + // Placeholder — the sidecar substitutes the posted entry id. + entry_id: Uuid::nil(), + action: req.action.as_str().to_owned(), + reason_code: req.reason_code.clone(), + actor_ref: req.preparer_actor_id.to_string(), + amount_minor, + currency: req.currency.clone(), + }, + }); + + self.posting + .post(ctx, scope, entry, lines, Some(sidecar)) + .await + } + + /// Resolve each leg's chart `account_id` + currency scale and assemble the engine + /// [`NewEntry`] + [`NewLine`] vector, returning the gross adjustment amount + /// (`Σ DR`, == `Σ CR`) alongside. Per-stream classes resolve on their stream; the + /// parking / clearing classes resolve stream-less (the chart keys them so). The + /// header is `source_doc_type = ManualAdjustment` + `source_business_id = + /// adjustment_id` (the engine's idempotency key). + async fn assemble_post( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &ManualAdjustmentRequest, + payer: Uuid, + ) -> Result<(NewEntry, Vec, i64), DomainError> { + let chart = load_chart(&self.reference, scope, req.tenant_id).await?; + let scale = self + .resolver + .resolve(scope, req.tenant_id, &req.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + + let eff_date = Utc::now().date_naive(); + let period_id = format!("{:04}{:02}", eff_date.year(), eff_date.month()); + + let mut lines: Vec = Vec::with_capacity(req.legs.len()); + let mut dr: i128 = 0; + for leg in &req.legs { + let account_id = chart + .resolve( + leg.account_class, + &req.currency, + leg.revenue_stream.as_deref(), + ) + .ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + leg.account_class.as_str(), + leg.revenue_stream, + req.currency + )) + })?; + if leg.side == Side::Debit { + dr += i128::from(leg.amount_minor); + } + lines.push(Self::mk_line(req, leg, account_id, scale, payer)); + } + // govern already balanced the legs in i128 and rejected an out-of-i64 set, so + // the DR total fits i64; guard the cast defensively rather than truncate. + let amount_minor = i64::try_from(dr).map_err(|_| { + DomainError::Internal(format!( + "manual adjustment gross amount {dr} overflows i64 (govern should have rejected)" + )) + })?; + + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: req.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: req.tenant_id, + period_id, + entry_currency: req.currency.clone(), + source_doc_type: SourceDocType::ManualAdjustment, + // The engine's (tenant, MANUAL_ADJUSTMENT, adjustment_id) idempotency key. + source_business_id: req.adjustment_id.clone(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: eff_date, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: ctx.subject_id(), + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + // Slice 5: same-currency in v1 (no FX lock on this path). + rate_snapshot_ref: None, + }; + Ok((entry, lines, amount_minor)) + } + + /// Map one [`ManualLeg`] + its resolved chart account/scale to the engine + /// [`NewLine`]. Every leg carries `payer_tenant_id` (the resolved payer — the + /// request's `payer_tenant_id`, or the tenant itself for a payer-less internal + /// move) so an `AR` / `UNALLOCATED` grain attributes correctly. The MVP governed + /// actions move no tax, so the tax dims are `None` and there is no invoice / SKU / + /// PO linkage. + fn mk_line( + req: &ManualAdjustmentRequest, + leg: &ManualLeg, + account_id: Uuid, + scale: u8, + payer: Uuid, + ) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: payer, + seller_tenant_id: Some(req.tenant_id), + resource_tenant_id: None, + account_id, + account_class: leg.account_class, + gl_code: None, + side: leg.side, + amount_minor: leg.amount_minor, + currency: req.currency.clone(), + currency_scale: scale, + invoice_id: None, + due_date: None, + revenue_stream: leg.revenue_stream.clone(), + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + // The MVP governed actions (rounding / suspense clean-up) move no tax, so + // a manual-adjustment leg carries no tax dimensions. + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } + } + + /// Capture + page an attempted (disguised bad-debt) write-off (design §4.6 / §6 + /// A4) — out-of-band, on the REJECTED post (no books effect occurred): + /// + /// - **page** the `AttemptedWriteOff` alarm (`Critical`) so Revenue Assurance / + /// Finance Ops is notified of the deliberate-misuse attempt. Fire-and-forget on + /// the publisher's own committed connection (mirrors + /// [`CreditNoteHandler::emit_split_blocked_alarm`](super::credit_note_service)). + /// - **capture** the actor in a `SecuredAuditSink` record on a SEPARATE committed + /// transaction (the reject has no post txn to ride — mirrors the standalone + /// `db.transaction` shape in + /// [`allocate`](crate::infra::payment::allocate) with the + /// `DbError::Sea(DbErr::Custom(...))` error encoding). The `before_after` payload + /// is PII-free (ids + enum codes + amounts only). A capture failure is logged + /// and SWALLOWED: it must never mask the original reject (and until Slice 6 the + /// no-op sink writes nothing durable anyway). + async fn capture_and_page_write_off( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &ManualAdjustmentRequest, + detail: &str, + ) { + // (page) The Critical write-off alarm — fire-and-forget on its own committed + // connection (the post was rejected with NO books effect, so there is no post + // txn to ride). + self.publisher + .emit_invariant_alarm( + ctx, + LedgerInvariantAlarm { + category: AlarmCategory::AttemptedWriteOff, + severity: AlarmSeverity::Critical, + tenant_id: req.tenant_id, + scope: format!( + "tenant:{}/flow:MANUAL_ADJUSTMENT/business:{}", + req.tenant_id, req.adjustment_id + ), + code: CODE_MANUAL_ADJUSTMENT_NOT_ALLOWED.to_owned(), + detail: detail.to_owned(), // internal diagnostic — no PII + affected: vec![], + }, + ) + .await; + + // (capture) The secured-audit record on a SEPARATE committed transaction. The + // before_after is PII-free: ids + the action / reason codes + the per-leg + // (class, side, amount) — no names / free text. + let before_after = serde_json::json!({ + "attempted": "MANUAL_ADJUSTMENT_WRITE_OFF", + "adjustment_id": req.adjustment_id, + "action": req.action.as_str(), + "reason_code": req.reason_code, + "legs": req + .legs + .iter() + .map(|l| serde_json::json!({ + "account_class": l.account_class.as_str(), + "side": l.side.as_str(), + "amount_minor": l.amount_minor, + })) + .collect::>(), + }); + let preparer_actor_str = req.preparer_actor_id.to_string(); + + // Own everything the `'static` move closure needs (the closure error type is + // fixed to DbError, so a sink error is encoded as DbErr::Custom and surfaced + // after the transaction — mirrors infra/payment/allocate.rs). + let scope_owned = scope.clone(); + let audit = Arc::clone(&self.audit); + let tenant = req.tenant_id; + let result = self + .db + .transaction(move |txn| { + Box::pin(async move { + audit + .append( + txn, + &scope_owned, + tenant, + AuditEventType::ManualAdjustment, + Some(preparer_actor_str.as_str()), + Some(CODE_MANUAL_ADJUSTMENT_NOT_ALLOWED), + &before_after, + None, + None, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + Ok::<(), DbError>(()) + }) + }) + .await; + if let Err(e) = result { + // Best-effort: the capture must NOT mask the original reject (and the + // no-op sink writes nothing durable until Slice 6). Log + swallow. + tracing::error!( + tenant_id = %req.tenant_id, + adjustment_id = %req.adjustment_id, + error = %e, + "bss-ledger: attempted-write-off secured-audit capture failed (swallowed; the \ + reject is unaffected)" + ); + } + } +} + +/// The in-transaction [`PostSidecar`] for a governed manual adjustment: runs AFTER +/// balance projection and BEFORE the dedup finalize (fresh-claim path only — a +/// replay returns before the sidecar), so the published event commits atomically +/// with the journal entry or rolls back with it (design §4.6). Unlike the +/// credit-note sidecar it does NO schedule / headroom / record write (a manual +/// adjustment touches none) — it ONLY publishes +/// `billing.ledger.manual_adjustment.posted` into the post txn (the transactional +/// outbox). Mirrors [`RefundPostSidecar`](super::refund_service::RefundPostSidecar)'s +/// publish-only tail. +pub struct ManualAdjustmentPostSidecar { + /// The event publisher: `billing.ledger.manual_adjustment.posted` is published IN + /// this post txn (the transactional outbox) so it commits atomically with the + /// entry, or a publish failure rolls the post back. + publisher: Arc, + /// The security context for the in-txn outbox publish (cloned by the handler). + ctx: SecurityContext, + /// The event payload assembled by the handler (tenant / `adjustment_id` / action / + /// `reason_code` / `actor_ref` / `amount_minor` / currency). Its `entry_id` is a nil + /// placeholder — [`Self::run`] substitutes the posted entry id from + /// [`PostedFacts`]. + event_template: ManualAdjustmentPosted, +} + +#[async_trait::async_trait] +impl PostSidecar for ManualAdjustmentPostSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + _scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + // Publish `billing.ledger.manual_adjustment.posted` into the SAME post txn + // (transactional outbox): the event row commits atomically with the entry, or + // a publish failure rolls the whole post back. Never on replay (a replay + // returns before the sidecar). Ids + enum codes + amount only (no PII). The + // posted entry id is stamped here (the template carried a nil placeholder). + self.publisher + .publish_manual_adjustment_posted( + &self.ctx, + txn, + ManualAdjustmentPosted { + entry_id: posted.entry_id, + ..self.event_template.clone() + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("publish manual_adjustment.posted: {e}")))?; + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/adjustment/refund_service.rs b/gears/bss/ledger/ledger/src/infra/adjustment/refund_service.rs new file mode 100644 index 000000000..d38ea7ab7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/adjustment/refund_service.rs @@ -0,0 +1,4130 @@ +//! `RefundHandler` — the Slice-3 Phase-2 refund orchestrator (design §4.4, Groups +//! B + C). It posts a refund stage's balanced two-leg entry through the invariant +//! [`PostingService`] and, in the SAME serializable transaction (via a +//! [`PostSidecar`]), maintains the `payment_settlement` / +//! `payment_allocation_refund` money-out CAPS and persists the `refund` record +//! row: +//! +//! 1. **shape gate** ([`validate_shape`]): amounts + the Pattern-A/B `invoice_id` +//! rule + the single-step/`confirmed` rule — a clean 400 before any read. +//! 2. **resolve the origin settlement** (out-of-txn, scoped): a refund unwinds a +//! settled receipt, so the origin `payment_settlement` MUST exist for +//! `(tenant, payment_id)` and its currency MUST match the refund's +//! (`RefundOriginNotFound` / `CurrencyMismatch`). The settlement counters are +//! the cap basis Group C guards; the lockless read is the pre-flight, the in-txn +//! CHECKs are the authoritative backstop. +//! 3. **route by phase**: +//! - `initiated` / `confirmed` → **forward post** ([`Self::post_forward`]): +//! build the two-leg plan ([`build_refund_legs`]), post it with the +//! [`RefundPostSidecar`] in [`CapMode::Initiate`] (stage-1, both patterns) / +//! [`CapMode::None`] (stage-2 `confirmed` — the cash was already capped at +//! stage-1; stage-2 only drains `REFUND_CLEARING`). +//! - `rejected` / `voided` → **stage-1 reversal** ([`Self::post_reversal`], +//! Group C): resolve the stage-1 `initiated` entry, post its STRICT +//! line-negation (`reverses_entry_id = `; the legs are the +//! stage-1 legs with sides inverted — `DR REFUND_CLEARING` against the +//! restored `UNALLOCATED`(A) / `AR`(B)), and run the sidecar in +//! [`CapMode::Release`] to DECREMENT the same counters stage-1 reserved + drain +//! the `REFUND_CLEARING` balance to zero. +//! - `unknown_final` → the terminal **dual-control disposition** (Group F, +//! [`Self::post_unknown_final`]): PARKS the stuck `REFUND_CLEARING` on the +//! SUSPENSE holding account (`DR REFUND_CLEARING · CR SUSPENSE` — not a +//! premature loss/gain, the outcome is unknown) and writes a +//! `secured_audit_record` (via the Slice-6 [`SecuredAuditSink`] port) in the +//! same txn. Gated like a stage-1 cash commitment. +//! +//! **Caps (design §4.4 / §4.7, Group C).** A refund is money-OUT; the cap is +//! reserved at stage-1 initiation (before the cash leaves) and released on a +//! stage-1 reversal, ALL under the rank-1 `payment_settlement` lock with the +//! post-delta CHECKs as the authoritative over-refund backstop: +//! - **both patterns** bump `payment_settlement.refunded_minor` (total money-out: +//! `refunded + clawed_back <= settled`); +//! - **Pattern A** additionally bumps `refunded_unallocated_minor` (spendable +//! headroom: `allocated + refunded_unallocated <= settled` — refunded on-account +//! cash can no longer be allocated); +//! - **Pattern B** additionally bumps `payment_allocation_refund.refunded_minor` +//! per `(payment, invoice)` (`refunded <= allocated`). +//! +//! A cap-CHECK violation surfaces as [`RepoError::MoneyOutCapExceeded`], refined to +//! [`DomainError::RefundExceedsSettled`] (the settlement caps) / +//! [`DomainError::RefundExceedsAllocated`] (the per-invoice cap). +//! +//! **Idempotency** is the engine's `(tenant, REFUND, source_business_id)` claim +//! with `source_business_id = "{psp_refund_id}:{phase}"` (design §7: one PSP refund +//! advances through several phase rows — `initiated`, `confirmed`, OR +//! `rejected`/`voided` — each idempotent on `(psp_refund_id, phase)`). The `refund` +//! row's surrogate `(tenant, refund_id)` PK + the natural `UNIQUE (tenant, +//! psp_refund_id, phase)` index are the durable backstop. +//! +//! **Scope.** Groups B–F are in this handler: the two-leg posts + caps (B/C), +//! dual-control over the D2 threshold (D), refund-of-refund + the out-of-order / +//! underflow defer (E), and the `unknown_final` loss-clearing disposition + +//! secured-audit (F). REST (Group G) is the remaining surface. +//! +//! Lives in `infra` (not `domain`): it needs repo + posting access; the +//! [`refund`](crate::domain::adjustment::refund) domain it calls stays pure (dylint +//! DE0301). Wraps the `pub` [`PostingService`] + repos directly (like +//! [`CreditNoteHandler`](super::credit_note_service::CreditNoteHandler)) so it is +//! constructible from out-of-crate integration tests. + +use std::sync::Arc; + +use bss_ledger_sdk::{AccountClass, MappingStatus, PostingRef, Side, SourceDocType}; +use chrono::{Datelike, Duration, Utc}; +use sea_orm::DbErr; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::adjustment::refund::{ + CLEARING_STATE_PENDING, CLEARING_STATE_REVERSED, CLEARING_STATE_SETTLED, PlannedLeg, + RefundDirection, RefundLegPlan, RefundPattern, RefundPhase, RefundRequest, build_refund_legs, + validate_shape, +}; +use crate::domain::approval::intent::{ApprovalIntent, RefundIntent, RefundWithCreditNoteIntent}; +use crate::domain::approval::policy::OperationFacts; +use crate::domain::error::DomainError; +use crate::domain::fx::realized::carried_relief; +use crate::domain::model::{NewEntry, NewLine, RepoError}; +use crate::domain::payment::chargeback::DisputePhase; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::adjustment::credit_note_service::{ + CompositeCreditNoteOutcome, CreditNoteHandler, PreparedCreditNote, +}; +use crate::infra::approval::service::ApprovalService; +use crate::infra::audit::secured_audit_sink::{ + AuditEventType, NoopSecuredAuditSink, SecuredAuditSink, +}; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::payloads::{ + AffectedItem, AlarmCategory, AlarmSeverity, LedgerInvariantAlarm, RefundRecorded, +}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::exception::ExceptionRouter; +use crate::infra::posting::chart::{ChartIndex, load_chart}; +use crate::infra::posting::idempotency::{ + ClaimOutcome, IdempotencyGate, STATUS_POSTED, STATUS_QUEUED, +}; +use crate::infra::posting::service::{PostSidecar, PostedFacts, PostingService}; +use crate::infra::storage::entity::pending_event_queue; +use crate::infra::storage::repo::adjustment_repo::NewRefund; +use crate::infra::storage::repo::{ + AdjustmentRepo, DisputeRepo, JournalRepo, NewQueueRow, PaymentRepo, PendingQueueRepo, + ReferenceRepo, +}; + +/// The WORK-STATE queue `flow` for a refund-of-refund claw-back that DEFERRED on an +/// out-of-order / would-underflow money-out decrement (Group E, design §4.4). This +/// partitions the `ledger_pending_event_queue` ROWS so [`RefundHandler::drain_clawbacks`] +/// (and the periodic sweep) claim ONLY claw-back rows — the chargeback / allocation +/// sweeps never pick one up, and vice-versa. Reuses the unconstrained `flow +/// varchar(64)` — no new DDL (mirrors how `CHARGEBACK` reused it). +/// +/// NOTE: this is the QUEUE-row flow, NOT the engine dedup flow. The deferred apply +/// re-drives the claw-back through [`PostingService::post_queued_apply`], whose +/// dedup lookup keys on the ENTRY's source-doc — `REFUND` ([`FLOW_REFUND_ENGINE`]) — +/// so the DEDUP row is claimed under `REFUND` (matching the post), while the +/// work-state row lives under `REFUND_CLAWBACK`. A claw-back carries its OWN +/// `psp_refund_id` (distinct from the outbound it claws back), so the +/// `(tenant, REFUND, clawback_psp:initiated)` engine claim never collides with the +/// outbound stage-1's `(tenant, REFUND, outbound_psp:initiated)`. +const FLOW_REFUND_CLAWBACK: &str = "REFUND_CLAWBACK"; + +/// The WORK-STATE queue `flow` for a QUARANTINED refund-before-payment (Group G, +/// design §4.4 / PRD L668 / Rev2 E-11). Distinct from every other flow so the +/// allocation / chargeback / claw-back sweeps never pick a quarantined refund up, +/// and the de-quarantine drain ([`RefundHandler::drain_quarantine`]) claims ONLY +/// these rows. A quarantined refund is NEVER posted from the queue blindly: +/// de-quarantine RE-VALIDATES the origin settlement + all §4.7 caps + the +/// THEN-CURRENT D2 threshold + the dispute state before any post — over-threshold +/// routes to approval, never auto-posts (this is the ESSENTIAL difference from the +/// queue-and-apply `REFUND_CLAWBACK` / allocation flows, which DO auto-apply on +/// drain). Reuses the unconstrained `flow varchar(64)` — no new DDL. +const FLOW_REFUND_QUARANTINE: &str = "REFUND_QUARANTINE"; + +/// The `QUEUED` dedup/queue-row status the quarantine drain targets (lockstep with +/// the engine `STATUS_QUEUED`). A quarantined refund's work-state row sits `QUEUED` +/// until de-quarantine posts it (→ the row is left for the post path, which keys on +/// the refund's own `(tenant, REFUND, psp_refund_id:phase)` engine dedup) or gives +/// it up. +const QUARANTINE_AGING_SECS: i64 = 14 * 24 * 60 * 60; + +/// The WORK-STATE queue `flow` for a refund HELD because its origin payment has an +/// OPEN dispute (Z5-2, design §5). DISTINCT from every other flow so the allocation +/// / chargeback / claw-back / quarantine sweeps never pick a dispute-held refund up, +/// and the dispute-hold drain ([`RefundHandler::drain_dispute_hold`]) claims ONLY +/// these rows. A dispute-held refund is NEVER posted from the queue blindly: the +/// drain RE-READS the dispute and only re-drives the refund post once the dispute +/// resolves WON (the payment stands); a LOST resolution CANCELS the hold (a lost +/// chargeback already returned the money — posting the refund too would double-pay). +/// Reuses the unconstrained `flow varchar(64)` — no new DDL (mirrors how +/// `REFUND_QUARANTINE` reused it). The held payload re-drives through the refund's +/// OWN `(tenant, REFUND, psp_refund_id:phase)` engine claim, so the post path +/// finalizes that dedup row while the work-state row lives under +/// `REFUND_DISPUTE_HOLD`. +const FLOW_REFUND_DISPUTE_HOLD: &str = "REFUND_DISPUTE_HOLD"; + +/// Aging horizon for a dispute-held refund (design §5 / §13 — a dispute that never +/// resolves should not strand a refund silently). A hold row that has sat `QUEUED` +/// longer than this with its dispute STILL `OPENED` is CANCELLED at the next drain + +/// escalated to the exception stub + a `RefundQuarantined` Critical alarm (no +/// dispute-specific category exists in the vendored schema; `RefundQuarantined` is +/// the closest refund-held-out-of-band signal — see the alarm raise). 30 days — +/// card-network dispute lifecycles routinely run weeks, so this is generous enough +/// that only a genuinely stuck dispute escalates. A const for now (wire to a +/// `jobs.dispute_hold_aging_secs` config when per-deployment tuning is needed — +/// deferred, mirrors `QUARANTINE_AGING_SECS`). +const DISPUTE_HOLD_AGING_SECS: i64 = 30 * 24 * 60 * 60; + +/// The ENGINE idempotency-dedup `flow` for a claw-back — the `REFUND` source-doc +/// literal (lockstep with [`SourceDocType::Refund`]). The defer intake claims the +/// `QUEUED` dedup row under THIS flow (so `post_queued_apply`, which derives the +/// flow from the entry's `source_doc_type = Refund`, reads + finalizes the SAME +/// row). `as_str` is not `const`, so it can't be derived in a `const` initializer. +const FLOW_REFUND_ENGINE: &str = "REFUND"; + +/// Aging horizon for a deferred claw-back (design §4.4 — "never hard-fail; ESCALATE +/// when it never reconciles"). A claw-back row that has sat `QUEUED` longer than +/// this without its matching outbound refund stage-1 landing is CANCELLED at the +/// next drain + escalated to the exception stub + a `CLAWBACK_UNDERFLOW` alarm. +/// 7 days — generous relative to the minutes-to-hours a normal outbound/claw-back +/// pair takes to reconcile, so only a genuinely orphaned claw-back escalates. A +/// const for now (wire to `jobs.clawback_aging_secs` config when per-deployment +/// tuning is needed — deferred, mirrors `AgedAlarmJob`). +const CLAWBACK_AGING_SECS: i64 = 7 * 24 * 60 * 60; + +/// Origin literal stamped on posts made through this service (mirrors the peer +/// orchestrators). +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// The account class the `unknown_final` disposition PARKS the stuck +/// `REFUND_CLEARING` amount onto (design §4.4 / K-1). `unknown_final` means the PSP +/// could produce NO final state — so the outcome (paid → cash, cancelled → release, +/// or genuinely lost → write-off) is UNKNOWN. Booking it straight to a loss (or a +/// gain) would assert an outcome we do not yet know, so the disposition PARKS the +/// amount on the `SUSPENSE` clearing account — the standard "known amount, unknown +/// attribution" holding account — pending reconciliation. The terminal disposition +/// (loss / release / paid) is a separate governed step once the status resolves +/// (the `exception_queue`, Slice 7 / VHP-1859). `SUSPENSE` already exists in the +/// chart (no SDK enum / provisioning change), and the park carries no P&L sign, so +/// it neither prematurely recognizes a loss nor a gain. +const UNKNOWN_FINAL_PARK_CLASS: AccountClass = AccountClass::Suspense; + +/// The closed reason code stamped on the `unknown_final` disposition's secured +/// audit record (design §4.4 — a governed write-off carries a mandatory reason +/// code + actor, AC #14). A stable literal (NOT free text). +const REASON_REFUND_UNKNOWN_FINAL: &str = "REFUND_UNKNOWN_FINAL"; + +/// The closed reason code stamped on the secured-audit record captured when a +/// refund intake hits an idempotency-key reuse with a DIFFERENT payload (AC #19, +/// Z14-1 — the secured-audit trail records the attempted conflicting reuse). A +/// stable literal (NOT free text). +const REASON_IDEMPOTENCY_CONFLICT: &str = "IDEMPOTENCY_CONFLICT"; + +/// Whether (and how) the [`RefundPostSidecar`] moves the per-payment money-out cap +/// counters (design §4.4 / §4.7, Group C). The cap is reserved at stage-1 +/// initiation and released on a stage-1 reversal; stage-2 `confirmed` (the cash was +/// already capped at stage-1) leaves the counters untouched. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CapMode { + /// Stage-1 initiation (both patterns): INCREMENT the caps (positive Δ). The + /// post-delta CHECKs are the over-refund backstop. Used by an OUTBOUND refund — + /// a plain stage-1 OR an additional-outbound refund-of-refund (cash out again), + /// which rides the same money-out cap. + Initiate, + /// Stage-1 reversal (PSP `rejected`/`voided`): DECREMENT the caps (negative Δ) + /// by exactly the stage-1 amount — releasing the reservation. A decrement never + /// trips a cap CHECK and cannot underflow the nonneg CHECK (it backs out the + /// matching initiation). + Release, + /// Refund-of-refund CLAW-BACK stage-1 (Group E, design §4.4 / Rev3): DECREMENT + /// the origin money-out counters (negative Δ) so the total money-out cap + /// reflects the NET refunded, mirroring [`CapMode::Release`]'s arithmetic. The + /// DIFFERENCE from `Release` is the GUARD: a claw-back's decrement is NOT + /// guaranteed to back out a matching prior increment (the PSP claw-back may + /// arrive out-of-order / over-claw), so the handler PRE-CHECKS the underflow + /// under the rank-1 lock BEFORE constructing this mode and DEFERS instead of + /// applying when `current - amount < 0` (the `refunded_minor >= 0` CHECK stays a + /// backstop that must never fire). By the time this mode is built the underflow + /// pre-check has passed, so the decrement is safe. + Clawback, + /// Stage-2 `confirmed`: NO counter movement (the cash was capped at stage-1; + /// stage-2 only drains `REFUND_CLEARING`). Also the claw-back stage-2 (the + /// counters moved at the claw-back stage-1). + None, +} + +/// Orchestrates the refund domain (design §4.4) over the foundation engine. +pub struct RefundHandler { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + /// Reads the origin `payment_settlement` row (existence + currency + the cap + /// basis the §4.7 counters guard; AND the claw-back underflow pre-read under the + /// rank-1 lock, Group E). + payment: PaymentRepo, + /// Resolves the stage-1 `initiated` journal entry id a stage-1 reversal negates + /// (`reverses_entry_id`), by its `(tenant, REFUND, psp_refund_id:initiated)` + /// business id (Group C). + journal: JournalRepo, + /// Reads the OPEN dispute on the origin payment, if any — the refund + /// dispute-hold pre-read (Z5-2, design §5). A refund must NOT move cash on a + /// payment with an OPEN dispute (the disputed funds are sub judice); the handler + /// holds the cash leg until the dispute resolves. Also re-read by the hold drain + /// to decide WON (re-drive) vs LOST (cancel) vs still-OPEN (back off). + dispute: DisputeRepo, + /// Reads the live `refund` row by its `(tenant, psp_refund_id, phase)` grain — + /// the `unknown_final` disposition's REAL clearing-state pre-read (Z5-4). The + /// disposition writes off the STAGE-1 (`initiated`) row's actual open clearing, + /// not a hardcoded `PENDING` / request amount; an already-resolved + /// (`SETTLED`/`REVERSED`) stage-1 makes the disposition a no-op rather than an + /// over-DR. + adjustment: AdjustmentRepo, + /// The deferred-apply queue (work-state SoT): a refund-of-refund claw-back whose + /// money-out decrement would underflow (out-of-order PSP claw-back) is enqueued + /// here at intake (Group E, design §4.4) and drained later by + /// [`Self::drain_clawbacks`] when the matching outbound refund stage-1 lands. + pending_queue: PendingQueueRepo, + /// One database provider, retained so the claw-back defer intake + the drain + /// claim can open their own `db.transaction` (mirrors `ChargebackService`). + db: DBProvider, + /// The event publisher — retained so the never-reconcile escalation can raise + /// the out-of-band `ClawbackUnderflow` alarm (Group E). The posting engine also + /// holds its own clone (threaded at `new`). + publisher: Arc, + /// The dual-control engine (VHP-1852, Group D). `Some` ⇒ a forward refund whose + /// cash crosses the tenant's D2 threshold is gated to the preparer→approver + /// queue (`DualControlRequired`) instead of posting inline; `None` ⇒ gating is + /// disabled (the executor's approved replay, and the Group-B unit tests that + /// construct the handler without the engine). Wired in `module` (Group G). + approval: Option>, + /// The secured-audit sink (Slice 6 seam, Group F). The `unknown_final` + /// disposition writes one `secured_audit_record` IN the post txn (atomic with + /// the loss-clearing entry) via [`SecuredAuditSink::append`]. Until Slice 6 + /// merges this is the [`NoopSecuredAuditSink`] (logs + metric, nothing + /// durable); the real `SecuredAuditStore` binds here at merge. Defaulted at + /// `new` (so the Group-B callers/tests stay source-compatible) and overridable + /// via [`Self::with_audit_sink`]. + audit: Arc, + /// Metrics sink (Group F + G): `ledger_refund_unknown_final_total` on a + /// disposition, `ledger_refund_total{phase,pattern}` on a fresh post, and + /// `ledger_refund_quarantine_depth` on the quarantine sweep. Defaulted to the + /// no-op at `new`; the wired meter binds via [`Self::with_metrics`]. + metrics: Arc, + /// The credit-note orchestrator (Group G composite). `Some` ⇒ the + /// `refund-with-credit-note` atomic composite can post the paired S3 credit + /// note as the SECOND entry inside the refund's post txn; `None` ⇒ the composite + /// is unavailable (the executor's un-gated handler, and the Group-B/E unit tests + /// that construct the handler without it). Wired in `module` (Group G) via + /// [`Self::with_credit_note_handler`]. + credit_note: Option>, + // Slice 7 Phase 2: routes the `CLAWBACK_UNDERFLOW` escalation stub to a durable + // close-blocking exception row (ADDITIVE beside the alarm). `None` until + // `with_exceptions` wires it (so existing constructions are unchanged). + exceptions: Option>, +} + +impl RefundHandler { + /// Build the handler over one database provider + the event publisher + /// (threaded into the posting engine — the engine publishes its own + /// post-committed facts). The publisher is ALSO retained (Group E) so the + /// never-reconcile claw-back escalation can raise the out-of-band + /// `ClawbackUnderflow` alarm; Group G re-threads it for + /// `billing.ledger.refund.recorded`. + #[must_use] + pub fn new(db: DBProvider, publisher: Arc) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + let payment = PaymentRepo::new(db.clone()); + let journal = JournalRepo::new(db.clone()); + let dispute = DisputeRepo::new(db.clone()); + let adjustment = AdjustmentRepo::new(db.clone()); + let pending_queue = PendingQueueRepo::new(db.clone()); + Self { + posting, + reference, + resolver, + payment, + journal, + dispute, + adjustment, + pending_queue, + db, + publisher, + approval: None, + // Default to the pre-Slice-6 no-op sink + the no-op metrics. `module` + // overrides via `with_audit_sink` / `with_metrics` (Group G); the + // Group-B/F unit + integration tests inject a spy sink to assert the + // `unknown_final` disposition's secured-audit append. + audit: Arc::new(NoopSecuredAuditSink::new()), + metrics: Arc::new(crate::domain::ports::metrics::NoopLedgerMetrics), + credit_note: None, + exceptions: None, + } + } + + /// Attach the exception router (Slice 7 Phase 2) so a `CLAWBACK_UNDERFLOW` + /// escalation also opens a durable close-blocking exception row. Additive — the + /// existing alarm is unchanged. + #[must_use] + pub fn with_exceptions(mut self, exceptions: Arc) -> Self { + self.exceptions = Some(exceptions); + self + } + + /// Attach the credit-note orchestrator (Group G): enables the + /// `refund-with-credit-note` atomic composite ([`Self::post_refund_with_credit_note`]), + /// which posts the paired S3 credit note as the SECOND entry inside the refund's + /// post txn (K-3 — both commit or neither, AR never overstated between them). + /// Builder form (defaults to `None` at `new`) so the executor's un-gated handler + /// and the unit tests stay source-compatible; `module` wires it onto the REST + /// handler. + #[must_use] + pub fn with_credit_note_handler( + mut self, + credit_note: Arc, + ) -> Self { + self.credit_note = Some(credit_note); + self + } + + /// Bind the secured-audit sink (Slice 6 seam, Group F). The `unknown_final` + /// disposition appends a `secured_audit_record` through it in the post txn. + /// Builder form (defaults to [`NoopSecuredAuditSink`] at `new`) so the + /// Group-B callers + unit tests stay source-compatible; a postgres test + /// injects a spy sink to assert the append fired. + #[must_use] + pub fn with_audit_sink(mut self, audit: Arc) -> Self { + self.audit = audit; + self + } + + /// Bind the metrics sink (Group F): `ledger_refund_unknown_final_total` on a + /// disposition. Builder form (defaults to the no-op at `new`). + #[must_use] + pub fn with_metrics(mut self, metrics: Arc) -> Self { + self.metrics = metrics; + self + } + + /// Attach the dual-control engine (Group D): a forward refund whose returned + /// cash crosses the tenant's D2 threshold is then gated to the preparer→approver + /// queue ([`DomainError::DualControlRequired`]) rather than posting inline. The + /// approved replay re-enters through [`Self::post_refund_approved`], which skips + /// the gate. Builder form (not a `new` arg) so the Group-B `post_refund` callers + /// and the unit tests stay source-compatible. + #[must_use] + pub fn with_approval(mut self, approval: Arc) -> Self { + self.approval = Some(approval); + self + } + + /// Post one refund phase (design §4.4 / §4.7). Validates the request shape, + /// resolves the origin `payment_settlement` (existence + currency), then routes + /// by phase: the `initiated`/`confirmed` forward post (with the stage-1 cap + /// reservation) or the `rejected`/`voided` stage-1 reversal (line-negation + cap + /// release + `REFUND_CLEARING` drain). Idempotent on + /// `(tenant, REFUND, psp_refund_id:phase)`. + /// + /// # Errors + /// [`DomainError::AmountOutOfRange`] / [`DomainError::InvalidRequest`] (shape); + /// [`DomainError::RefundOriginNotFound`] (no settled origin payment, 404); + /// [`DomainError::CurrencyMismatch`] (refund currency ≠ the origin settlement's, + /// 400); [`DomainError::RefundExceedsSettled`] / [`DomainError::RefundExceedsAllocated`] + /// (a stage-1 cap CHECK rejected the reservation); [`DomainError::AccountClosed`] + /// when a required class (`UNALLOCATED` / `AR` / `REFUND_CLEARING` / + /// `CASH_CLEARING`) is not provisioned; any foundation rejection or + /// [`DomainError::Internal`] on an infra fault. + pub async fn post_refund( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: RefundRequest, + ) -> Result { + self.post_refund_inner(ctx, scope, req, /* gate */ true) + .await + } + + /// The approved-replay entry (Group D): re-drive a held refund WITHOUT the + /// dual-control gate. Called only by the `ApprovalExecutor` after a second actor + /// approves the PENDING refund approval — the threshold was already crossed at + /// gate time, so re-checking it would re-open a second approval (an infinite + /// loop). Idempotent on the engine's `(tenant, REFUND, psp_refund_id:phase)` + /// claim: a re-approve replays the post harmlessly (the dedup short-circuits a + /// committed entry before the sidecar), so execute-then-mark is safe. + /// + /// # Errors + /// As [`Self::post_refund`], minus the dual-control gate (never returns + /// [`DomainError::DualControlRequired`]). + pub async fn post_refund_approved( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: RefundRequest, + ) -> Result { + self.post_refund_inner(ctx, scope, req, /* gate */ false) + .await + } + + /// The REST entry for `POST /refunds` (Group G): record one refund phase, + /// QUARANTINING a refund-before-payment instead of 404-ing it (design §4.4 / + /// PRD L668 / Rev2 E-11). Resolves the origin `payment_settlement` out-of-txn + /// FIRST: + /// - **origin resolvable** ⇒ post via the gated [`Self::post_refund`] path + /// (which itself routes by phase, reserves/releases caps, and gates over D2); + /// returns [`RefundOutcome::Posted`]. + /// - **origin NOT resolvable** (no settled receipt for `(tenant, payment_id)`) + /// ⇒ DURABLY QUARANTINE the payload on the `REFUND_QUARANTINE` queue + + /// out-of-band `RefundQuarantined` alarm, and return + /// [`RefundOutcome::Quarantined`] (the REST handler maps it to a 202 + + /// `refund-quarantined` body token). NEVER posts. De-quarantine + /// ([`Self::drain_quarantine`]) re-validates everything before any post. + /// + /// This DIFFERS from [`Self::post_refund`] (used by the approved replay), which + /// 404s an absent origin (`RefundOriginNotFound`): the REST surface quarantines, + /// the executor's approved replay does not (its origin existed at gate time). + /// + /// # Errors + /// As [`Self::post_refund`] for the posted path (shape, currency-mismatch, cap, + /// dual-control 409); [`DomainError::Internal`] on a quarantine-intake infra + /// fault. A `currency`-mismatch on a resolvable origin is still a 400 (not a + /// quarantine — the payment exists, the request is malformed). + pub async fn record_refund( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: RefundRequest, + ) -> Result { + // Shape gate up-front (a malformed request is a clean 400, never a + // quarantine). A zero amount is rejected here too (mirrors `post_refund_inner`). + validate_shape(&req)?; + if req.amount_minor == 0 { + return Err(DomainError::InvalidRequest( + "refund amount_minor must be > 0".to_owned(), + )); + } + + // Resolve the origin settlement (out-of-txn, scoped). ABSENT ⇒ quarantine + // (refund-before-payment), NOT a 404. A foreign-tenant payment reads as + // absent (the same quarantine, no existence leak). + let settlement = self + .payment + .read_settlement(scope, req.tenant_id, &req.payment_id) + .await + .map_err(|e| DomainError::Internal(format!("read origin settlement: {e}")))?; + let Some(settlement) = settlement else { + return self.quarantine_refund(ctx, scope, &req).await; + }; + // The payment EXISTS but the currency disagrees: a malformed request, not a + // quarantine — surface the 400 (mirrors `post_refund_inner`). + if settlement.currency != req.currency { + return Err(DomainError::CurrencyMismatch(format!( + "refund {} currency {} does not match the origin payment {} settlement currency {}", + req.refund_id, req.currency, req.payment_id, settlement.currency + ))); + } + + // Origin resolvable ⇒ the gated post path (it re-resolves the settlement + // in-txn under the rank-1 lock; the out-of-txn read above is the quarantine + // pre-flight only). A forward money-OUT post on a payment with an OPEN dispute + // is HELD inside `post_refund_inner` (Z5-2): the hold intake durably enqueues + // the payload + signals `RefundDisputeHeld`, which we surface here as a + // `DisputeHeld` 202 (mirroring the `Quarantined` 202) rather than a raw error. + let held_at = Utc::now(); + match self.post_refund(ctx, scope, req).await { + Ok(posting) => Ok(RefundOutcome::Posted(posting)), + Err(DomainError::RefundDisputeHeld(business_id)) => { + Ok(RefundOutcome::DisputeHeld(DisputeHoldHandle { + flow: FLOW_REFUND_DISPUTE_HOLD.to_owned(), + business_id, + held_at, + })) + } + Err(other) => Err(other), + } + } + + /// The `POST /refund-with-credit-note` ATOMIC composite (Group G / Rev2 K-3): + /// post a S5 refund AND its paired S3 credit note in ONE transaction as TWO + /// linked entries — both commit or neither, so AR is NEVER overstated between + /// them. The refund is the OUTER post ([`PostingService::post`]); its composite + /// sidecar (a) does the normal refund work (caps + `refund` row + + /// `refund.recorded` event) AND (b) posts the credit-note entry inline via + /// [`CreditNoteHandler::apply_in_txn`] — both inside the refund's serializable + /// post txn. The refund's `(tenant, REFUND, psp_refund_id:phase)` engine claim + /// is the composite's primary idempotency grain (a replay returns before the + /// sidecar, so neither entry re-posts); the credit note's own + /// `(tenant, CREDIT_NOTE, credit_note_id)` claim is the secondary guard. + /// + /// The refund leg is GATED over D2 like a plain stage-1 (the credit note rides + /// the same approval — a high-value composite routes to the queue as a unit; the + /// approved replay re-drives this composite). The refund's origin settlement + /// MUST resolve (a composite is a real refund of a real payment — it is NOT a + /// quarantine path; an absent origin 404s `RefundOriginNotFound`). + /// + /// # Errors + /// [`DomainError::Internal`] if the credit-note handler is not wired + /// (`with_credit_note_handler`); the union of [`Self::post_refund`]'s and + /// [`CreditNoteHandler::post_credit_note`]'s rejections — any of which rolls the + /// WHOLE composite back (both entries). + pub async fn post_refund_with_credit_note( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + refund: RefundRequest, + credit_note: crate::domain::adjustment::credit_note::CreditNoteRequest, + ) -> Result { + self.post_refund_with_credit_note_inner( + ctx, + scope, + refund, + credit_note, + /* gate */ true, + ) + .await + } + + /// Approved-replay entry for an over-D2 composite (Z5-1 fix): the executor + /// re-drives the WHOLE composite (refund + credit note) from a + /// [`ApprovalIntent::RefundWithCreditNote`] snapshot — NOT a bare refund — so both + /// entries post atomically exactly as the gated path would (the prior bug gated + /// the composite as a plain `Refund` intent, dropping the credit note on replay → + /// AR overstated). Skips the gate (the threshold was crossed at gate time); the + /// refund + credit-note engine claims make the replay at-most-once. + /// + /// # Errors + /// The union of [`Self::post_refund`]'s and [`CreditNoteHandler::post_credit_note`]'s + /// rejections — any of which rolls the WHOLE composite back (both entries); and + /// [`DomainError::Internal`] if the credit-note handler is not wired. Identical to + /// [`Self::post_refund_with_credit_note`] (the approved replay shares the same + /// `_inner`, only skipping the gate). + pub async fn post_refund_with_credit_note_approved( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + refund: RefundRequest, + credit_note: crate::domain::adjustment::credit_note::CreditNoteRequest, + ) -> Result { + self.post_refund_with_credit_note_inner( + ctx, + scope, + refund, + credit_note, + /* gate */ false, + ) + .await + } + + async fn post_refund_with_credit_note_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + refund: RefundRequest, + credit_note: crate::domain::adjustment::credit_note::CreditNoteRequest, + gate: bool, + ) -> Result { + let credit_handler = self.credit_note.as_ref().ok_or_else(|| { + DomainError::Internal( + "refund-with-credit-note composite requires a wired CreditNoteHandler".to_owned(), + ) + })?; + + // 1. Pure refund shape gate + zero guard (a clean 400 before any read). + validate_shape(&refund)?; + if refund.amount_minor == 0 { + return Err(DomainError::InvalidRequest( + "refund amount_minor must be > 0".to_owned(), + )); + } + // The composite is a money-out refund; only a forward stage-1 (or a + // single-step `initiated`) makes sense paired with a fresh credit note. A + // reversal / disposition composite is out of scope. + if !matches!( + refund.phase, + RefundPhase::Initiated | RefundPhase::Confirmed + ) { + return Err(DomainError::InvalidRequest(format!( + "refund-with-credit-note composite supports only initiated/confirmed refund \ + phases, got {}", + refund.phase.as_str() + ))); + } + + // 2. Resolve the origin settlement (out-of-txn). A composite refunds a REAL + // settled receipt — an absent origin is a hard 404 here (NOT a quarantine: + // the credit note half has no meaning without the refund's origin). + let settlement = self + .payment + .read_settlement(scope, refund.tenant_id, &refund.payment_id) + .await + .map_err(|e| DomainError::Internal(format!("read origin settlement: {e}")))? + .ok_or_else(|| { + DomainError::RefundOriginNotFound(format!( + "refund {} references payment {} which has no settlement", + refund.refund_id, refund.payment_id + )) + })?; + if settlement.currency != refund.currency { + return Err(DomainError::CurrencyMismatch(format!( + "refund {} currency {} does not match the origin payment {} settlement currency {}", + refund.refund_id, refund.currency, refund.payment_id, settlement.currency + ))); + } + + // 2c. Dispute-hold (Z5-2, composite path). A forward composite refund moves + // cash OUT, so — like a plain refund (`post_refund_inner`) — it must NOT + // post on a payment with an OPEN dispute (double-payout if the dispute + // later resolves LOST). Unlike a plain refund it is NOT enqueued on + // `FLOW_REFUND_DISPUTE_HOLD`: that queue replays a PLAIN refund on + // WON-resolution, which would DROP the paired credit note (the Z5-1 class). + // So the WHOLE composite is REFUSED up front (both-or-neither — neither + // entry posts) with a `RefundDisputeHeld` abort (409); the caller resubmits + // the composite once the dispute resolves. Checked before the gate so a + // held composite never opens a then-blocked approval. `is_dispute_holdable` + // is true for the forward initiated/confirmed a composite supports (a + // composite is never a claw-back). + if Self::is_dispute_holdable(&refund) + && let Some(open) = self + .dispute + .read_open_dispute_for_payment(scope, refund.tenant_id, &refund.payment_id) + .await? + { + return Err(DomainError::RefundDisputeHeld(format!( + "refund-with-credit-note {} held: origin payment {} has an OPEN dispute \ + {} (cycle {}); resubmit the composite after it resolves", + refund.refund_id, refund.payment_id, open.dispute_id, open.cycle + ))); + } + + // 3. Dual-control gate (the composite) — the same D2 gate `post_refund_inner` + // runs for a plain stage-1, valued at the LARGER of the two legs so a + // high-value credit note cannot ride under the threshold behind a small + // refund. The standalone credit-note path gates on the note's own amount, so + // the composite MUST consider it too (segregation of duties — else a + // >D2 credit note paired with a CapMode::Initiate, + // `confirmed` (the cash was already capped at stage-1) moves no + // counters; every other phase is guarded out above, so the wildcard + // shares the `confirmed` body (no counter movement). + _ => CapMode::None, + }; + let business_id = refund_business_id(&refund.psp_refund_id, refund.phase.as_str()); + let (entry, lines) = self + .assemble_post(ctx, scope, &refund, &plan, &business_id, None) + .await?; + + // The composite outcome is filled in-txn by the sidecar (the credit-note + // entry id) and read back after the post commits. + let cn_entry_id: Arc>> = + Arc::new(std::sync::Mutex::new(None)); + let sidecar: Arc = Arc::new(RefundWithCreditNoteSidecar { + refund: RefundPostSidecar { + cap_mode, + cap: RefundCap::for_request(&refund), + refund_row: Self::refund_row(&refund, plan.clearing_state, None), + payment: self.payment.clone(), + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }, + credit_note: credit_handler.clone(), + prepared: Arc::new(prepared), + ctx: ctx.clone(), + outcome: Arc::clone(&cn_entry_id), + }); + + let refund_posting = self + .posting + .post(ctx, scope, entry, lines, Some(sidecar)) + .await?; + + // `ledger_refund_total{phase,pattern}` on a fresh composite refund post. + if !refund_posting.replayed { + self.metrics + .refund(refund.phase.as_str(), refund.pattern.as_str()); + } + + // The credit-note entry id the sidecar recorded in-txn. On a refund REPLAY + // the sidecar never ran, so re-read the prior credit-note entry id by its + // business id (the composite committed both on the original post). + let cn = cn_entry_id + .lock() + .map_err(|_| DomainError::Internal("composite outcome mutex poisoned".to_owned()))? + .take(); + let credit_note_entry_id = match cn { + Some(o) => o.entry_id, + None => { + self.credit_note_entry_id_for_replay(scope, &credit_note) + .await? + } + }; + Ok(RefundWithCreditNoteOutcome { + refund_entry_id: refund_posting.entry_id, + credit_note_entry_id, + replayed: refund_posting.replayed, + }) + } + + /// On a composite REPLAY (the refund's engine claim short-circuited the post, so + /// the sidecar never ran), resolve the paired credit note's entry id by its + /// `(tenant, CREDIT_NOTE, credit_note_id)` business id (the original composite + /// committed it). Exactly one such entry exists. + async fn credit_note_entry_id_for_replay( + &self, + scope: &AccessScope, + credit_note: &crate::domain::adjustment::credit_note::CreditNoteRequest, + ) -> Result { + let mut ids = self + .journal + .entry_ids_for_business_id(scope, credit_note.tenant_id, &credit_note.credit_note_id) + .await + .map_err(|e| { + DomainError::Internal(format!("resolve composite credit-note entry: {e}")) + })?; + ids.pop().ok_or_else(|| { + DomainError::Internal(format!( + "composite replay: no committed credit-note entry for {}", + credit_note.credit_note_id + )) + }) + } + + /// Shared body for [`Self::post_refund`] (gated) + [`Self::post_refund_approved`] + /// (the approved replay). `gate` ⇒ a forward refund over the D2 threshold routes + /// to dual-control instead of posting. + async fn post_refund_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: RefundRequest, + gate: bool, + ) -> Result { + // 1. Pure shape gate — a clean 400 before any read. + validate_shape(&req)?; + + // A zero-amount refund moves no cash and would fail the engine's empty-entry + // validation; reject up-front (inherited S1 / AC #4 forbids a zero + // placeholder entry just as it forbids a zero placeholder line). + if req.amount_minor == 0 { + return Err(DomainError::InvalidRequest( + "refund amount_minor must be > 0".to_owned(), + )); + } + + // 2. Resolve the origin settlement (out-of-txn, scoped). A refund unwinds a + // settled receipt: the `payment_settlement` row MUST exist for + // `(tenant, payment_id)` and its currency MUST match the refund's. Scoped + // existence (SQL-level BOLA) — a foreign-tenant payment reads as absent, + // the same 404, no existence leak. (The settlement *counters* are the cap + // basis; Group C re-reads + guards them in-txn under the rank-1 lock.) + // Resolved BEFORE the dual-control gate, so a bad-origin refund 404s + // without ever opening an approval. + let settlement = self + .payment + .read_settlement(scope, req.tenant_id, &req.payment_id) + .await + .map_err(|e| DomainError::Internal(format!("read origin settlement: {e}")))? + .ok_or_else(|| { + DomainError::RefundOriginNotFound(format!( + "refund {} references payment {} which has no settlement", + req.refund_id, req.payment_id + )) + })?; + if settlement.currency != req.currency { + return Err(DomainError::CurrencyMismatch(format!( + "refund {} currency {} does not match the origin payment {} settlement currency {}", + req.refund_id, req.currency, req.payment_id, settlement.currency + ))); + } + + // 2a. Dispute-hold gate (Z5-2, design §5 — the missing control). A refund + // must NOT move cash on a payment with an OPEN dispute: the disputed + // funds are sub judice (held in `DISPUTE_HOLD` for a `CASH_HOLD` dispute, + // or reclassed `DISPUTED` for `AR_RECLASS`), so paying the refund out now + // would double-spend funds the chargeback may claw back. Held on the CASH + // LEG only — a forward money-OUT post (`Initiated` non-clawback stage-1 / + // single-step, OR `Confirmed` stage-2 where the cash actually leaves). A + // `rejected`/`voided` reversal RELEASES caps (it does not pay the + // customer), a claw-back is money-IN (it returns cash), and the + // `unknown_final` disposition PARKS the stuck clearing on SUSPENSE (it + // never reaches the customer) — none of those move cash OUT to the payer, + // so none are dispute-held. Checked BEFORE the dual-control gate so a + // held refund never opens a (then-bypassed) approval; the hold drain + // re-drives through this gated path once the dispute resolves WON, which + // re-runs the (now-passing) check. Independent of `gate` — a hold is a + // hard money-movement block, not a governance decision. + if Self::is_dispute_holdable(&req) + && let Some(open) = self + .dispute + .read_open_dispute_for_payment(scope, req.tenant_id, &req.payment_id) + .await? + { + let token = self.hold_for_dispute(ctx, scope, &req, &open).await?; + return Err(DomainError::RefundDisputeHeld(token)); + } + + // 2b. Dual-control gate (VHP-1852, Group D / design §1.4 D2 / §4.4). Gated + // on the STAGE-1 cash commitment ONLY (`initiated` — where the money-out + // cap is reserved and the human decision belongs). `confirmed` is the + // mechanical stage-2 drain of an ALREADY-approved disbursement (no fresh + // human decision), so it is NOT re-gated — re-gating it would force a + // redundant second approval per refund. A `rejected`/`voided` reversal + // RELEASES caps + unwinds the stage-1 post: it must never be blocked + // behind an approval (that would strand the REFUND_CLEARING balance). A + // single-step refund (`two_stage == false`) is one `initiated` entry, so + // it is gated here exactly once. The approved replay (`gate == false`) + // skips this. Above the tenant's D2 threshold ⇒ a PENDING approval is + // created and `DualControlRequired` is returned (the REST handler maps it + // to 409); at/under threshold ⇒ inline, unchanged. + // The `unknown_final` disposition is ALSO gated here (Group F): it is a + // terminal, ledger-side GOVERNED disposition of a stuck `REFUND_CLEARING` + // (parked to SUSPENSE; design §4.4 / K-1 — "a dual-control disposition"), so it + // crosses the same preparer→approver gate as a stage-1 cash commitment. It + // rides the SAME D2 policy row as a forward refund (gated on the open + // clearing amount it writes off), reusing Group D's gate verbatim — no + // bespoke always-gate path. The approved replay (`gate == false`) skips it. + // Z5-3: a claw-back (refund-of-refund money-IN decrement) is NOT gated at + // intake. It reduces net money-out (the safe direction), and an out-of-order + // claw-back that defers is drained by the UN-gated `REFUND_CLAWBACK` sweep — so + // gating it here would strand a PENDING approval the sweep then bypasses. Gate + // only a forward money-OUT stage-1 (`Initiated` non-clawback — a first-order + // refund or an additional-outbound refund-of-refund) and the `unknown_final` + // governed disposition. + if gate + && (matches!(req.phase, RefundPhase::UnknownFinal) + || (matches!(req.phase, RefundPhase::Initiated) && !req.is_clawback())) + && let Some(approval) = &self.approval + { + let intent = ApprovalIntent::Refund(RefundIntent::from(&req)); + let facts = OperationFacts { + kind: crate::domain::approval::ApprovalKind::Refund, + // DC10 / FX: pass the refund's TRANSACTION-currency minor; the + // dual-control gate translates it to the tenant's FUNCTIONAL + // (reporting) currency at the current rate before the threshold + // compare (it reads the operation currency off the intent via + // `ApprovalIntent::transaction_currency`). Single-currency tenants + // compare unchanged. + amount_usd_eq_minor: Some(req.amount_minor), + effective_at: None, + has_outstanding_balance: false, + }; + if let Some(approval_id) = approval + .gate(ctx, scope, intent, facts, "refund".to_owned()) + .await? + { + return Err(DomainError::DualControlRequired(format!( + "refund requires dual-control approval: {approval_id}" + ))); + } + } + + // 3. Route by phase (+ direction). A refund-of-refund CLAW-BACK `initiated` + // takes the defer-aware path (Group E): its money-out DECREMENT may + // underflow if the PSP claw-back arrived before/without the matching + // outbound refund stage-1, in which case it is DEFERRED, never failed. + // Everything else (outbound initiated/confirmed, claw-back confirmed) is + // a plain forward post; reject/void is the stage-1 reversal (Group C). + let result = match req.phase { + RefundPhase::Initiated if req.is_clawback() => { + self.post_clawback(ctx, scope, &req).await + } + RefundPhase::Initiated | RefundPhase::Confirmed => { + self.post_forward(ctx, scope, &req).await + } + RefundPhase::Rejected | RefundPhase::Voided => { + self.post_reversal(ctx, scope, &req).await + } + // The terminal `unknown_final` disposition (Group F, design §4.4 / + // K-1): park the open `REFUND_CLEARING` on SUSPENSE + + // write a secured-audit record. Governed (gated above). + RefundPhase::UnknownFinal => self.post_unknown_final(ctx, scope, &req).await, + }; + + // `ledger_refund_total{phase,pattern}` (design §9 / Group G): one increment + // per FRESH refund post — never on a replay (which re-returns the prior + // handle but applied nothing). The `unknown_final` disposition already bumps + // its own dedicated `ledger_refund_unknown_final_total` in `post_unknown_final` + // (in ADDITION to this generic per-phase counter). A claw-back DEFER + // (`RefundClawbackDeferred`) is not a post and is not counted here. + if let Ok(posting) = &result + && !posting.replayed + { + self.metrics + .refund(req.phase.as_str(), req.pattern.as_str()); + } + result + } + + /// Does this refund phase move cash OUT to the payer (so an OPEN dispute on the + /// origin payment must HOLD it, Z5-2 / design §5)? `true` ONLY for a forward + /// (OUTBOUND) money-OUT cash post: + /// - a forward stage-1 `Initiated` non-clawback (a first-order refund or an + /// additional-outbound refund-of-refund) — covers the single-step shape too + /// (single-step is one `Initiated` entry that posts straight to `CASH_CLEARING`); + /// - a forward stage-2 `Confirmed` (the drain where the cash actually leaves — + /// `DR REFUND_CLEARING · CR CASH_CLEARING`). + /// + /// `false` (NOT held — these do NOT pay the customer, so an open dispute must not + /// block them) for: + /// - `Rejected` / `Voided` — a stage-1 reversal that RELEASES caps + drains + /// `REFUND_CLEARING`; blocking it would strand the clearing balance; + /// - ANY CLAW-BACK phase — money-IN (cash returns to the merchant: a claw-back + /// stage-1 `DR REFUND_CLEARING · CR pattern.debit`, stage-2 `DR CASH_CLEARING · + /// CR REFUND_CLEARING`); holding one would deadlock (a dispute can only resolve + /// AFTER the claw-back it depends on lands); + /// - `UnknownFinal` — a governed disposition that parks the stuck clearing on + /// SUSPENSE (the cash never reaches the customer). + /// + /// CRITICAL SAFETY: the hold is on the OUTBOUND CASH leg only. `is_clawback()` + /// (direction == `Clawback` AND a `relates_to_refund_id` link) excludes BOTH + /// claw-back stages — a `Confirmed` claw-back is money-IN, never held. + fn is_dispute_holdable(req: &RefundRequest) -> bool { + match req.phase { + // Forward stage-1 / single-step / stage-2 drain (money OUT) — but NEVER a + // claw-back, in either stage (money IN). The single direction predicate + // gates both forward phases symmetrically. + RefundPhase::Initiated | RefundPhase::Confirmed => !req.is_clawback(), + // Reversal (release), disposition (park to SUSPENSE): never held. + RefundPhase::Rejected | RefundPhase::Voided | RefundPhase::UnknownFinal => false, + } + } + + /// Durably HOLD a refund whose origin payment has an OPEN dispute (Z5-2, design + /// §5): claim the `(tenant, REFUND_DISPUTE_HOLD, psp_refund_id:phase)` dedup row + /// as `QUEUED` and insert the work-state queue row carrying the PII-free + /// [`DisputeHeldRefundPayload`] (+ the dispute id/cycle the drain re-reads), in + /// ONE `db.transaction` (mirrors [`Self::quarantine_refund`]). Raises the + /// out-of-band `RefundQuarantined` alarm at `Warn` (no dispute-specific category + /// exists in the vendored schema — `RefundQuarantined` is the closest + /// refund-held-out-of-band signal; the code/detail name the dispute). Returns the + /// kebab queue token (the REST 202 `refund-dispute-held` handle). NEVER posts — + /// [`Self::drain_dispute_hold`] is the only path that can later post it, and only + /// after the dispute resolves WON. + async fn hold_for_dispute( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + open: &crate::infra::storage::entity::dispute::Model, + ) -> Result { + let now = Utc::now(); + let business_id = refund_business_id(&req.psp_refund_id, req.phase.as_str()); + let payload = DisputeHeldRefundPayload::from_request(req, &open.dispute_id, open.cycle); + let payload_json = serde_json::to_value(&payload) + .map_err(|e| DomainError::Internal(format!("serialize dispute-hold payload: {e}")))?; + let payload_hash = { + let canonical = serde_json::to_string(&payload).map_err(|e| { + DomainError::Internal(format!("canonicalize dispute-hold payload: {e}")) + })?; + IdempotencyGate::content_hash(&canonical) + }; + + let tenant = req.tenant_id; + let gate = IdempotencyGate::new(); + let scope_owned = scope.clone(); + let business_id_owned = business_id.clone(); + // The closure captures by `move`; keep an un-moved copy of the incoming hash + // for the AC #19 conflict capture after the txn (Z14-1). + let incoming_hash = payload_hash.clone(); + let outcome = self + .db + .transaction(move |txn| { + Box::pin(async move { + // Claim the DISPUTE_HOLD-flow dedup row as QUEUED. A re-hold of the + // SAME key is idempotent (Replay); a different payload is a + // conflict (mirrors the quarantine intake). + let claim = gate + .claim_queued( + txn, + tenant, + FLOW_REFUND_DISPUTE_HOLD, + &business_id_owned, + &payload_hash, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + match claim { + ClaimOutcome::Claimed => { + PendingQueueRepo::insert_queued( + txn, + &scope_owned, + &NewQueueRow { + tenant_id: tenant, + flow: FLOW_REFUND_DISPUTE_HOLD.to_owned(), + business_id: business_id_owned.clone(), + payload: payload_json, + queued_at: now, + apply_after: None, + }, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + Ok::(DisputeHoldIntake::Held) + } + ClaimOutcome::Replay(row) => { + if row.payload_hash == payload_hash { + Ok(DisputeHoldIntake::AlreadyHeld) + } else { + Ok(DisputeHoldIntake::Conflict { + stored_hash: row.payload_hash, + }) + } + } + } + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("refund dispute-hold intake: {e}")))?; + + if let DisputeHoldIntake::Conflict { stored_hash } = &outcome { + // AC #19 (Z14-1): capture the conflicting reuse on the secured-audit sink + // (best-effort, own txn) BEFORE returning the hard error. + self.capture_idempotency_conflict( + ctx, + scope, + req.tenant_id, + FLOW_REFUND_DISPUTE_HOLD, + &business_id, + stored_hash, + &incoming_hash, + ) + .await; + return Err(DomainError::IdempotencyConflict(format!( + "dispute-held refund {business_id} reused with a different payload" + ))); + } + + // Raise the alarm out-of-band (Warn — a control signal, nothing posted). Only + // on a FRESH hold (an idempotent re-hold does not re-raise — mirrors how a + // replay raises no alarm). Reuses `RefundQuarantined` (closest refund-held + // category; the code/detail name the dispute hold explicitly). + if matches!(outcome, DisputeHoldIntake::Held) { + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::RefundQuarantined, + severity: AlarmSeverity::Warn, + tenant_id: req.tenant_id, + scope: format!( + "tenant:{}/flow:{FLOW_REFUND_DISPUTE_HOLD}/business:{business_id}", + req.tenant_id + ), + code: "REFUND_DISPUTE_HELD".to_owned(), + detail: format!( + "refund {} (psp_refund_id {}) on payment {} held — payment has an OPEN \ + dispute {} (cycle {}); the cash leg is held until the dispute resolves", + req.refund_id, req.psp_refund_id, req.payment_id, open.dispute_id, open.cycle + ), + affected: vec![AffectedItem { + id: format!( + "payment:{}/psp_refund:{}/dispute:{}", + req.payment_id, req.psp_refund_id, open.dispute_id + ), + currency: req.currency.clone(), + expected_minor: req.amount_minor, + actual_minor: 0, + }], + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + } + + Ok(business_id) + } + + /// Drain up to `limit` due DISPUTE-HELD refunds for one tenant (Z5-2, design §5): + /// claim them under `SKIP LOCKED`, then RE-READ the dispute for each in its own + /// txn. A dispute-held refund is NOT a blind apply — for each row it: + /// 1. reconstructs the [`RefundRequest`] from the payload; + /// 2. re-reads the dispute by its `(tenant, dispute_id)`: + /// - STILL `OPENED` ⇒ leave `QUEUED` (back off) UNLESS it has aged out past + /// [`DISPUTE_HOLD_AGING_SECS`], in which case CANCEL + escalate (exception + /// stub + alarm); + /// - resolved WON (the payment stands — the refund is owed) ⇒ re-drive through + /// the gated [`Self::post_refund`] path (which re-checks the §4.7 caps + the + /// THEN-CURRENT D2 threshold; an over-threshold release opens an approval and + /// the row stays `QUEUED` — never auto-posts over threshold), flip + /// `→APPLIED` on a post; + /// - resolved LOST (a chargeback returned the money to the customer) ⇒ CANCEL + /// the hold + raise an alarm + escalate to the exception stub. NEVER + /// auto-post: a lost chargeback already refunded the customer, so posting the + /// refund too would DOUBLE-PAY. + /// + /// Public so the periodic sweep can drive it. Per-row faults are isolated. + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn fails; per-row faults + /// are isolated inside the pass. + pub async fn drain_dispute_hold( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + let now = Utc::now(); + let pending_queue = self.pending_queue.clone(); + let scope_owned = scope.clone(); + let claimed: Vec = self + .db + .transaction(move |txn| { + Box::pin(async move { + pending_queue + .claim_due( + txn, + &scope_owned, + tenant, + FLOW_REFUND_DISPUTE_HOLD, + now, + limit, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("refund dispute-hold drain claim: {e}")))?; + + let mut report = DisputeHoldDrainReport::default(); + for row in claimed { + match self.apply_dispute_hold(ctx, scope, &row).await { + Ok(DisputeHoldApply::Released) => report.released += 1, + Ok(DisputeHoldApply::AwaitingApproval) => report.awaiting_approval += 1, + Ok(DisputeHoldApply::StillDisputed) => report.still_disputed += 1, + Ok(DisputeHoldApply::Cancelled) => report.cancelled += 1, + Ok(DisputeHoldApply::Escalated) => report.escalated += 1, + Err(e) => tracing::error!( + tenant_id = %tenant, business_id = %row.business_id, error = %e, + "bss-ledger: dispute-held refund apply failed (infra); continuing" + ), + } + } + Ok(report) + } + + /// Re-read the dispute + (maybe) release / cancel ONE dispute-held refund row. + /// See [`Self::drain_dispute_hold`] for the five terminal shapes. The flip + /// `→APPLIED` (release) and `→CANCELLED` (lost / aged-out) is its OWN short txn. + async fn apply_dispute_hold( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + row: &pending_event_queue::Model, + ) -> Result { + let payload: DisputeHeldRefundPayload = serde_json::from_value(row.payload.clone()) + .map_err(|e| DomainError::Internal(format!("deserialize dispute-held refund: {e}")))?; + let dispute_id = payload.dispute_id.clone(); + let req = payload.into_request()?; + + // Re-read the dispute by its `(tenant, dispute_id)` current state. + let dispute = self + .dispute + .read_dispute(scope, req.tenant_id, &dispute_id) + .await?; + // The held dispute row vanished (a tenant purge / data fix) — treat as no + // longer disputed and re-drive (the payment now stands by absence). Rare; the + // post path re-validates everything regardless. + let last_phase = dispute + .as_ref() + .and_then(|d| DisputePhase::parse(&d.last_phase)); + + match last_phase { + // STILL OPEN ⇒ back off (or escalate if aged out). The cash stays held. + Some(DisputePhase::Opened) => { + let aged = + (Utc::now() - row.queued_at) >= Duration::seconds(DISPUTE_HOLD_AGING_SECS); + if aged { + self.escalate_dispute_hold(ctx, scope, &req, &dispute_id, &row.business_id) + .await?; + Ok(DisputeHoldApply::Escalated) + } else { + Ok(DisputeHoldApply::StillDisputed) + } + } + // LOST ⇒ a chargeback already returned the money to the customer. CANCEL + // the hold + escalate — NEVER auto-post (posting the refund too would + // DOUBLE-PAY). The exception stub flags it for an operator (a refund that + // can no longer post because the dispute clawed the money back). + Some(DisputePhase::Lost) => { + self.cancel_dispute_hold_lost(ctx, scope, &req, &dispute_id, &row.business_id) + .await?; + Ok(DisputeHoldApply::Cancelled) + } + // PARTIAL is behind a flag and NOT implemented — the chargeback handler + // rejects the transition, so a held refund can never observe it today. Guard + // it EXPLICITLY (not folded into WON): a partial clawback returns PART of the + // payment, so re-driving the FULL held refund would double-pay that part. + // When split-chargeback lands, escalate for an operator rather than auto-post. + Some(DisputePhase::Partial) => { + self.escalate_dispute_hold(ctx, scope, &req, &dispute_id, &row.business_id) + .await?; + Ok(DisputeHoldApply::Escalated) + } + // WON (the payment stands — the refund is genuinely owed) OR the dispute + // row is gone / a non-terminal-but-unexpected phase ⇒ re-drive through the + // gated posted path. This re-checks the §4.7 caps in-txn + the + // THEN-CURRENT D2 threshold AND re-runs the dispute-hold gate (now + // passing — no OPEN dispute). An over-threshold release opens an approval + // (DualControlRequired): the row stays QUEUED until the approved replay + // posts — it NEVER auto-posts over threshold. + Some(DisputePhase::Won) | None => { + match self.post_refund(ctx, scope, req).await { + Ok(_) => { + self.mark_dispute_hold_applied(scope, row).await?; + Ok(DisputeHoldApply::Released) + } + Err(DomainError::DualControlRequired(_)) => { + Ok(DisputeHoldApply::AwaitingApproval) + } + // A still-OPEN dispute raced back in between the re-read and the + // post (re-held under the same key — idempotent): leave QUEUED. + Err(DomainError::RefundDisputeHeld(_)) => Ok(DisputeHoldApply::StillDisputed), + Err(other) => Err(other), + } + } + } + } + + /// Flip one dispute-hold row `→APPLIED` (the dispute resolved WON + the refund + /// posted) in its own short txn (the post already committed). Mirrors + /// [`Self::mark_quarantine_applied`]. + async fn mark_dispute_hold_applied( + &self, + scope: &AccessScope, + row: &pending_event_queue::Model, + ) -> Result<(), DomainError> { + let scope_owned = scope.clone(); + let tenant = row.tenant_id; + let business_id_owned = row.business_id.clone(); + self.db + .transaction(move |txn| { + Box::pin(async move { + PendingQueueRepo::mark_applied( + txn, + &scope_owned, + tenant, + FLOW_REFUND_DISPUTE_HOLD, + &business_id_owned, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("mark dispute-hold applied: {e}"))) + } + + /// A dispute-held refund whose dispute resolved LOST: flip the row `→CANCELLED` + /// (terminal — the refund can NEVER post; the chargeback already returned the + /// money) + escalate (exception stub + a `RefundQuarantined` Critical alarm). The + /// CANCELLED flip is its own short txn. NEVER auto-posts (double-pay guard). + async fn cancel_dispute_hold_lost( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + dispute_id: &str, + business_id: &str, + ) -> Result<(), DomainError> { + // exception stub (full exception_queue is Slice 7) + tracing::error!( + tenant_id = %req.tenant_id, + payment_id = %req.payment_id, + psp_refund_id = %req.psp_refund_id, + dispute_id = %dispute_id, + amount_minor = req.amount_minor, + "bss-ledger: dispute-held refund's dispute resolved LOST (chargeback returned the \ + money) — cancelling the hold, NOT posting (double-pay guard); escalating \ + (full exception_queue is Slice 7)" + ); + self.cancel_dispute_hold_row(scope, req.tenant_id, business_id) + .await?; + // Critical — a refund that can no longer be paid because the dispute clawed + // the money back; Finance must reconcile (the outbound the PSP may have + // already actioned vs the chargeback). Reuses `RefundQuarantined` (closest + // refund-held category; code/detail name the lost-dispute cancel). + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::RefundQuarantined, + severity: AlarmSeverity::Critical, + tenant_id: req.tenant_id, + scope: format!( + "tenant:{}/flow:{FLOW_REFUND_DISPUTE_HOLD}/business:{business_id}", + req.tenant_id + ), + code: "REFUND_DISPUTE_LOST".to_owned(), + detail: format!( + "dispute-held refund {} (psp_refund_id {}) on payment {} cancelled — dispute {} \ + resolved LOST (the chargeback already returned the money; posting the refund too \ + would double-pay)", + req.refund_id, req.psp_refund_id, req.payment_id, dispute_id + ), + affected: vec![AffectedItem { + id: format!( + "payment:{}/psp_refund:{}/dispute:{}", + req.payment_id, req.psp_refund_id, dispute_id + ), + currency: req.currency.clone(), + expected_minor: req.amount_minor, + actual_minor: 0, + }], + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + Ok(()) + } + + /// A dispute-held refund whose dispute NEVER resolved past the aging horizon + /// ([`DISPUTE_HOLD_AGING_SECS`]): flip the row `→CANCELLED` + escalate (exception + /// stub + a `RefundQuarantined` Critical alarm — the dispute is stuck). Mirrors + /// [`Self::escalate_quarantine`]. NEVER auto-posts (the dispute is still OPEN). + async fn escalate_dispute_hold( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + dispute_id: &str, + business_id: &str, + ) -> Result<(), DomainError> { + // exception stub (full exception_queue is Slice 7) + tracing::error!( + tenant_id = %req.tenant_id, + payment_id = %req.payment_id, + psp_refund_id = %req.psp_refund_id, + dispute_id = %dispute_id, + amount_minor = req.amount_minor, + "bss-ledger: dispute-held refund's dispute never resolved past the aging horizon \ + — cancelling the hold + escalating (REFUND_DISPUTE_HELD; full exception_queue is \ + Slice 7)" + ); + self.cancel_dispute_hold_row(scope, req.tenant_id, business_id) + .await?; + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::RefundQuarantined, + severity: AlarmSeverity::Critical, + tenant_id: req.tenant_id, + scope: format!( + "tenant:{}/flow:{FLOW_REFUND_DISPUTE_HOLD}/business:{business_id}", + req.tenant_id + ), + code: "REFUND_DISPUTE_HELD".to_owned(), + detail: format!( + "dispute-held refund {} (psp_refund_id {}) on payment {} never released — \ + dispute {} stayed OPEN past the aging horizon", + req.refund_id, req.psp_refund_id, req.payment_id, dispute_id + ), + affected: vec![AffectedItem { + id: format!( + "payment:{}/psp_refund:{}/dispute:{}", + req.payment_id, req.psp_refund_id, dispute_id + ), + currency: req.currency.clone(), + expected_minor: req.amount_minor, + actual_minor: 0, + }], + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + Ok(()) + } + + /// Capture an idempotency-conflict (AC #19, Z14-1) on the wired + /// [`SecuredAuditSink`], BEFORE returning [`DomainError::IdempotencyConflict`]. + /// A conflict is a same-business-key reuse with a DIFFERENT payload (a possible + /// replay-attack / client bug), so the secured-audit trail must record WHO + /// attempted it against WHICH stored record — PII-free: tenant, the business id, + /// the stored-vs-incoming payload hashes, the actor, and the flow. BEST-EFFORT on + /// its OWN short txn (the conflict already rolled the intake back): a capture + /// failure is logged + SWALLOWED so it never masks the hard `IdempotencyConflict` + /// the caller must still see (mirrors the `unknown_final` / write-off capture + /// pattern — the audit append rides the disposition there, but a CONFLICT has no + /// post to ride, so the capture opens its own txn and tolerates failure). + #[allow(clippy::too_many_arguments)] // a PII-free forensic capture; grouping the ids/hashes into a struct adds churn + async fn capture_idempotency_conflict( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + flow: &str, + business_id: &str, + stored_hash: &str, + incoming_hash: &str, + ) { + // PII-free: ids + enum/flow literals + hashes + actor only (no names / free + // text / payload bodies). The hashes let an auditor prove the two payloads + // differed without storing either. + let before_after = serde_json::json!({ + "event": "IDEMPOTENCY_CONFLICT", + "flow": flow, + "business_id": business_id, + "stored_payload_hash": stored_hash, + "incoming_payload_hash": incoming_hash, + }); + let actor_ref = Some(ctx.subject_id().to_string()); + let scope_owned = scope.clone(); + let audit = Arc::clone(&self.audit); + let result = self + .db + .transaction(move |txn| { + let before_after = before_after.clone(); + let actor_ref = actor_ref.clone(); + Box::pin(async move { + audit + .append( + txn, + &scope_owned, + tenant, + AuditEventType::ManualAdjustment, + actor_ref.as_deref(), + Some(REASON_IDEMPOTENCY_CONFLICT), + &before_after, + // No posted entry to correlate — a conflict has no books + // effect (the intake rolled back). + None, + None, + ) + .await + .map(|_id| ()) + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await; + if let Err(e) = result { + // Swallow — the conflict capture is best-effort; never mask the hard + // `IdempotencyConflict` the caller must still observe. + tracing::error!( + tenant_id = %tenant, + flow = %flow, + business_id = %business_id, + error = %e, + "bss-ledger: secured-audit capture of idempotency conflict failed (swallowed; \ + the IdempotencyConflict is still returned)" + ); + } + } + + /// Flip one dispute-hold row `→CANCELLED` (terminal — never re-claimed) in its + /// own short txn. Shared by the LOST-cancel + the aged-out escalate. Mirrors + /// [`Self::escalate_quarantine`]'s cancel. + async fn cancel_dispute_hold_row( + &self, + scope: &AccessScope, + tenant: Uuid, + business_id: &str, + ) -> Result<(), DomainError> { + let scope_owned = scope.clone(); + let business_id_owned = business_id.to_owned(); + self.db + .transaction(move |txn| { + Box::pin(async move { + PendingQueueRepo::mark_cancelled( + txn, + &scope_owned, + tenant, + FLOW_REFUND_DISPUTE_HOLD, + &business_id_owned, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("dispute-hold cancel: {e}"))) + } + + /// The terminal `unknown_final` disposition (Group F, design §4.4 / Rev2 / + /// K-1): the PSP can produce NO final state for a two-stage refund's stage-1, + /// so its `REFUND_CLEARING` is stuck open. This is NOT a PSP event — it is a + /// ledger-side, dual-control GOVERNED disposition (gated in `post_refund_inner` + /// above) that: + /// + /// 1. Posts a balanced park-clearing entry that DRAINS the open clearing to + /// zero against the SUSPENSE holding account: `DR REFUND_CLEARING (open + /// amount) · CR SUSPENSE` ([`UNKNOWN_FINAL_PARK_CLASS`]). The DR cancels the + /// stage-1 `CR REFUND_CLEARING`, so the guarded `REFUND_CLEARING` balance + /// returns to zero; the amount holds on SUSPENSE (outcome unknown) until a + /// terminal disposition resolves it (Slice 7) — NOT a premature loss/gain. + /// 2. In the SAME post txn writes one `secured_audit_record` via the + /// [`SecuredAuditSink`] ([`AuditEventType::ManualAdjustment`], reason + /// `REFUND_UNKNOWN_FINAL`, the acting subject, a PII-clean before/after + /// payload) — atomic with the park entry (a sink failure rolls the + /// disposition back; the no-op sink never fails). + /// 3. Stamps the `refund` row `clearing_state = SETTLED` (the live + /// `REFUND_CLEARING` is drained — parked to SUSPENSE) on its own + /// `(tenant, psp_refund_id:unknown_final)` phase grain. + /// + /// Idempotent on the engine's `(tenant, REFUND, psp_refund_id:unknown_final)` + /// claim (a replay returns before the sidecar — the audit append + park entry + /// are at-most-once). The open clearing amount is the refund's `amount_minor` + /// (the stuck stage-1 amount the disposition parks); a zero amount is + /// rejected up-front by `post_refund_inner`. + async fn post_unknown_final( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + ) -> Result { + // Z5-4: read the LIVE stage-1 state instead of assuming a hardcoded + // PENDING / request amount. The disposition writes off the STAGE-1 + // (`initiated`) row's open `REFUND_CLEARING`, so resolve that row on its + // `(tenant, psp_refund_id, initiated)` grain and use its REAL `clearing_state` + // + `amount_minor`: + // - stage-1 `PENDING` (genuinely stuck — stage-2 never confirmed, no + // reversal landed) ⇒ write off the stage-1 row's `amount_minor` (the real + // open clearing), NOT the disposition request's amount; + // - stage-1 already `SETTLED` (stage-2 drained the cash out) / `REVERSED` + // (a reject/void already drained the clearing) ⇒ the clearing is NOT open; + // a write-off DR would drive `REFUND_CLEARING` NEGATIVE (an over-DR), so + // REJECT the disposition as a no-op (`InvalidRequest`) rather than corrupt + // the clearing; + // - no stage-1 row (e.g. a single-step refund never opened a clearing, or + // the stage-1 was never recorded) ⇒ nothing to dispose; REJECT. + let stage1 = self + .adjustment + .read_refund_by_psp_phase( + scope, + req.tenant_id, + &req.psp_refund_id, + RefundPhase::Initiated.as_str(), + ) + .await + .map_err(|e| DomainError::Internal(format!("read stage-1 refund row: {e}")))?; + let Some(stage1) = stage1 else { + return Err(DomainError::InvalidRequest(format!( + "unknown_final disposition for refund {} (psp_refund_id {}) has no recorded \ + stage-1 initiated refund — nothing to write off", + req.refund_id, req.psp_refund_id + ))); + }; + // Only a genuinely-stuck PENDING stage-1 has open clearing to write off. + if stage1.clearing_state != CLEARING_STATE_PENDING { + return Err(DomainError::InvalidRequest(format!( + "unknown_final disposition for refund {} (psp_refund_id {}) is a no-op: the \ + stage-1 refund is already {} (not PENDING) — its REFUND_CLEARING is not open, so \ + a write-off would over-debit it", + req.refund_id, req.psp_refund_id, stage1.clearing_state + ))); + } + // The REAL open clearing amount is the stage-1 row's amount (what stage-1 + // CR'd into REFUND_CLEARING and never drained), not the disposition request's + // amount_minor. + let open_minor = stage1.amount_minor; + + // The park-clearing plan: DR REFUND_CLEARING (drain the open balance) · CR + // SUSPENSE (park the amount pending reconciliation). Balanced (one DR == one + // CR), and the DR on the GUARDED REFUND_CLEARING returns its balance toward + // zero — the mirror of the stage-1 `CR REFUND_CLEARING`. Both legs are + // stream-less (matches the never-stream refund classes). Sized at the REAL + // open clearing amount (Z5-4). NOT a loss/gain — `unknown_final` means the + // outcome is unknown, so the amount holds on SUSPENSE until a terminal + // disposition resolves it (Slice 7). + let plan = RefundLegPlan { + legs: vec![ + PlannedLeg { + account_class: AccountClass::RefundClearing, + side: Side::Debit, + amount_minor: open_minor, + revenue_stream: None, + }, + PlannedLeg { + account_class: UNKNOWN_FINAL_PARK_CLASS, + side: Side::Credit, + amount_minor: open_minor, + revenue_stream: None, + }, + ], + // The REFUND_CLEARING is drained off the live account (parked to + // SUSPENSE) — SETTLED on the `refund` row, not a fresh PENDING. The + // terminal loss/release attribution is a later governed step (Slice 7). + clearing_state: CLEARING_STATE_SETTLED, + }; + + let business_id = refund_business_id(&req.psp_refund_id, req.phase.as_str()); + let (entry, lines) = self + .assemble_post(ctx, scope, req, &plan, &business_id, None) + .await?; + + // The sidecar persists the `refund` row (clearing_state = SETTLED) AND + // appends the secured-audit record in the post txn — no cap movement (the + // disposition does not touch the per-payment money-out counters: it neither + // refunds nor reverses, it writes the stuck clearing off; the stage-1 that + // opened the clearing already moved the cap, which stays as the net + // money-out of record). + let sidecar: Arc = Arc::new(UnknownFinalSidecar { + refund_row: Self::refund_row(req, CLEARING_STATE_SETTLED, None), + audit: Arc::clone(&self.audit), + // The acting subject (the approver/operator) — the audit `actor_ref`. + // `subject_id` is always present on an authenticated context. + actor_ref: Some(ctx.subject_id().to_string()), + // The audit `before` image carries the REAL stage-1 state (Z5-4): its + // live `clearing_state` + the live open clearing amount, read above — NOT + // a hardcoded `PENDING` / the request's amount. + before_after: unknown_final_audit_payload(req, &stage1.clearing_state, open_minor), + tenant: req.tenant_id, + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }); + + let posting = self + .posting + .post(ctx, scope, entry, lines, Some(sidecar)) + .await?; + + // Count the disposition only on a FRESH post (a replay re-returns the same + // handle but applied nothing — the sidecar never ran). `ledger_refund_ + // unknown_final_total` (design §9 / K-1). + if !posting.replayed { + self.metrics.refund_unknown_final(); + } + Ok(posting) + } + + /// Refund-of-refund CLAW-BACK stage-1 (`initiated`) path (Group E, design §4.4 / + /// Rev3 / S3-F1). A claw-back DECREMENTS the origin payment's money-out counters + /// (so the total money-out cap reflects the NET refunded). The decrement is + /// guarded against UNDERFLOW under the rank-1 `payment_settlement` lock inside + /// the post sidecar: if the PSP claw-back arrived BEFORE / without the matching + /// outbound refund stage-1 (or claws back MORE than was refunded), the decrement + /// would drive `refunded_minor` below zero. The design forbids both APPLYING + /// that decrement AND hard-failing on the `refunded_minor >= 0` CHECK — instead + /// it DEFERS: the sidecar returns [`DomainError::RefundClawbackDeferred`] which + /// rolls the whole post back (nothing applied), and this method then durably + /// ENQUEUES the claw-back on the deferred-apply queue (status QUEUED) for a later + /// retry by [`Self::drain_clawbacks`] (when the matching outbound lands and the + /// decrement stops underflowing). The deferred signal is surfaced as + /// `RefundClawbackDeferred` (NOT a generic error — the future REST surface maps + /// it to a 202-like accepted-but-queued; the kebab token is the queue handle). + /// + /// An IN-ORDER claw-back (the outbound already landed, the decrement fits) posts + /// inline exactly like [`Self::post_forward`] and returns the posting handle. + async fn post_clawback( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + ) -> Result { + // 0. Replay short-circuit (BEFORE the post), mirroring + // `ChargebackService::replay_short_circuit`. The engine dedup row for + // `(REFUND, psp_refund_id:initiated)` may already be `QUEUED` (a prior + // defer of THIS claw-back) or `POSTED` (already applied — inline or + // drained). A `Fresh` post on a `QUEUED` dedup row would surface an infra + // fault (the row carries no result id yet), so intercept it here: a + // `QUEUED` row re-signals `RefundClawbackDeferred` (still queued — the + // drain owns applying it); a `POSTED` row is an idempotent replay. Racy by + // nature (the authoritative dedup is the engine's in-txn claim); a + // `CLAIMED` / absent row falls through to the post. + let business_id = refund_business_id(&req.psp_refund_id, RefundPhase::Initiated.as_str()); + if let Some(outcome) = self + .clawback_replay_short_circuit(scope, req, &business_id) + .await? + { + return outcome; + } + + match self.post_forward(ctx, scope, req).await { + Ok(posting) => Ok(posting), + // The sidecar's locked underflow pre-check rolled the post back: DEFER — + // durably enqueue for retry (never hard-fail). The enqueue is idempotent + // on the `(tenant, REFUND, psp_refund_id:initiated)` engine dedup grain. + Err(DomainError::RefundClawbackDeferred(_)) => { + let token = self.enqueue_clawback(ctx, scope, req).await?; + Err(DomainError::RefundClawbackDeferred(token)) + } + Err(other) => Err(other), + } + } + + /// Replay short-circuit for a claw-back (the §4.4 counterpart to + /// `ChargebackService::replay_short_circuit`): read the engine + /// `(tenant, REFUND, psp_refund_id:initiated)` dedup status ONCE and, when the + /// claw-back was already deferred (`QUEUED`) or applied (`POSTED`), return the + /// matching outcome WITHOUT re-posting. `None` (absent / `CLAIMED` in-flight) + /// falls through to the post. Out-of-txn (racy by nature). + async fn clawback_replay_short_circuit( + &self, + scope: &AccessScope, + req: &RefundRequest, + business_id: &str, + ) -> Result>, DomainError> { + let dedup = self + .payment + .lookup_dedup_status(scope, req.tenant_id, SourceDocType::Refund, business_id) + .await + .map_err(|e| DomainError::Internal(format!("claw-back dedup lookup: {e}")))?; + let Some((status, result_entry_id, _hash)) = dedup else { + return Ok(None); + }; + if status == STATUS_QUEUED { + // Already deferred: re-signal `RefundClawbackDeferred` (the drain owns it). + return Ok(Some(Err(DomainError::RefundClawbackDeferred( + business_id.to_owned(), + )))); + } + if status == STATUS_POSTED { + let entry_id = result_entry_id.ok_or_else(|| { + DomainError::Internal(format!( + "claw-back dedup POSTED but no result_entry_id for \ + ({}, {FLOW_REFUND_ENGINE}, {business_id})", + req.tenant_id + )) + })?; + return Ok(Some(Ok(PostingRef { + entry_id, + created_seq: 0, + replayed: true, + }))); + } + // CLAIMED (in-flight) / other: fall through to the post. + Ok(None) + } + + /// Durably ENQUEUE a deferred claw-back on the deferred-apply queue (Group E, + /// design §4.4): claim the `(tenant, REFUND_CLAWBACK, psp_refund_id:initiated)` + /// dedup row as `QUEUED` and insert the work-state queue row carrying the + /// PII-free [`QueuedClawbackPayload`], in ONE `db.transaction` (mirrors + /// [`crate::infra::payment::chargeback::ChargebackService::enqueue_phase`]). The + /// dedup hash is request-based (`content_hash` over the payload) — a deferred + /// claw-back never inlines under the same key, so request-based is the stable + /// choice and `claim_queued`'s `Replay` makes the intake idempotent. Returns the + /// kebab queue token (the REST 202 handle later). A FLOW distinct from the + /// engine `REFUND` source-doc, so the chargeback / allocation sweeps never pick + /// it up. + async fn enqueue_clawback( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + ) -> Result { + let now = Utc::now(); + let business_id = clawback_business_id(&req.psp_refund_id); + let payload = QueuedClawbackPayload::from_request(req); + let payload_json = serde_json::to_value(&payload) + .map_err(|e| DomainError::Internal(format!("serialize claw-back payload: {e}")))?; + let payload_hash = clawback_request_hash(&payload)?; + + let tenant = req.tenant_id; + let gate = IdempotencyGate::new(); + let scope_owned = scope.clone(); + let business_id_owned = business_id.clone(); + // Keep an un-moved copy of the incoming hash for the AC #19 conflict capture + // after the txn (Z14-1). + let incoming_hash = payload_hash.clone(); + let outcome = self + .db + .transaction(move |txn| { + Box::pin(async move { + // Claim the ENGINE dedup row (flow = REFUND, matching the entry's + // source-doc) as QUEUED — so `post_queued_apply` later reads + + // finalizes THIS row. + let claim = gate + .claim_queued( + txn, + tenant, + FLOW_REFUND_ENGINE, + &business_id_owned, + &payload_hash, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + match claim { + ClaimOutcome::Claimed => { + // The WORK-STATE queue row under the distinct + // REFUND_CLAWBACK flow (so the claw-back drain claims only + // these rows). + PendingQueueRepo::insert_queued( + txn, + &scope_owned, + &NewQueueRow { + tenant_id: tenant, + flow: FLOW_REFUND_CLAWBACK.to_owned(), + business_id: business_id_owned.clone(), + payload: payload_json, + queued_at: now, + // Immediately eligible — the drain re-tries the + // decrement and only NOW-checks the aging horizon. + apply_after: None, + }, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + Ok::(ClawbackIntake::Enqueued) + } + // Same key already queued (idempotent re-defer) OR a + // different payload (conflict). A POSTED race (the matching + // outbound landed AND the drain applied this claw-back between + // the post rollback and this claim) is transient — surface it + // as an error so the caller retries cleanly. + ClaimOutcome::Replay(row) => { + if row.payload_hash != payload_hash { + Ok(ClawbackIntake::Conflict { + stored_hash: row.payload_hash, + }) + } else if row.status == STATUS_QUEUED { + Ok(ClawbackIntake::AlreadyQueued) + } else { + Err(DbError::Sea(DbErr::Custom(format!( + "claw-back intake: unexpected dedup status {:?} for \ + ({tenant}, {FLOW_REFUND_ENGINE}, {business_id_owned})", + row.status + )))) + } + } + } + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("claw-back intake: {e}")))?; + + match outcome { + ClawbackIntake::Enqueued | ClawbackIntake::AlreadyQueued => Ok(business_id), + ClawbackIntake::Conflict { stored_hash } => { + // AC #19 (Z14-1): capture the conflicting reuse on the secured-audit + // sink (best-effort, own txn) BEFORE returning the hard error. + self.capture_idempotency_conflict( + ctx, + scope, + tenant, + FLOW_REFUND_CLAWBACK, + &business_id, + &stored_hash, + &incoming_hash, + ) + .await; + Err(DomainError::IdempotencyConflict(format!( + "claw-back {business_id} reused with a different payload" + ))) + } + } + } + + /// Drain up to `limit` due queued claw-backs for one tenant (Group E): claim them + /// under `SKIP LOCKED` in a short claim txn, then RE-TRY EACH in its own txn (the + /// "apply is a second txn" shape). On retry the claw-back is re-driven through the + /// SAME [`Self::post_forward`] path: + /// - the decrement now FITS (the matching outbound refund stage-1 has landed) ⇒ + /// the post commits + the queue row flips `→APPLIED`; + /// - it STILL underflows AND the row is YOUNGER than the aging horizon ⇒ leave it + /// `QUEUED`, back off (a later drain retries); + /// - it STILL underflows AND the row is OLDER than the aging horizon + /// ([`CLAWBACK_AGING_SECS`]) ⇒ it never reconciled: flip the row `→CANCELLED`, + /// ESCALATE to the exception stub, and raise the `ClawbackUnderflow` alarm + + /// Finance alert (design §4.4 — never hard-fail, escalate). + /// + /// Public so the periodic sweep ([`crate::infra::jobs::queue_applier`]) and a + /// drain-on-outbound hook can drive it. Per-row faults are isolated. + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn fails; per-row faults + /// are isolated inside the pass. + pub async fn drain_clawbacks( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + let now = Utc::now(); + let pending_queue = self.pending_queue.clone(); + let scope_owned = scope.clone(); + let claimed: Vec = self + .db + .transaction(move |txn| { + Box::pin(async move { + pending_queue + .claim_due(txn, &scope_owned, tenant, FLOW_REFUND_CLAWBACK, now, limit) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("claw-back drain claim: {e}")))?; + + let mut report = ClawbackDrainReport::default(); + for row in claimed { + match self.apply_queued_clawback(ctx, scope, &row).await { + Ok(ClawbackApply::Applied) => report.applied += 1, + Ok(ClawbackApply::StillDeferred) => { + report.still_deferred += 1; + if let Err(e) = self + .bump_clawback_attempts( + scope, + tenant, + &row.business_id, + i64::from(row.attempts), + ) + .await + { + tracing::error!( + tenant_id = %tenant, business_id = %row.business_id, error = %e, + "bss-ledger: claw-back drain failed to bump attempts" + ); + } + } + Ok(ClawbackApply::Escalated) => report.escalated += 1, + Err(e) => tracing::error!( + tenant_id = %tenant, business_id = %row.business_id, error = %e, + "bss-ledger: queued claw-back apply failed (infra); continuing" + ), + } + } + Ok(report) + } + + /// Apply ONE queued claw-back row (the drain): deserialize the payload, + /// reconstruct the [`RefundRequest`], and RE-DRIVE it through the inline + /// claw-back post path. Three terminal shapes: + /// - the decrement FITS now ⇒ [`ClawbackApply::Applied`] (post committed; the + /// composite sidecar flipped the row `→APPLIED` in the same txn); + /// - it STILL underflows but the row is within the aging horizon ⇒ + /// [`ClawbackApply::StillDeferred`] (the caller backs off + leaves it QUEUED); + /// - it STILL underflows AND the row aged out ⇒ [`ClawbackApply::Escalated`] + /// (flip `→CANCELLED` + exception stub + alarm). + /// + /// `pub(crate)` so the sweep can drive a single row. + /// + /// # Errors + /// [`DomainError::Internal`] on an infra fault (bad payload / engine Internal). + pub(crate) async fn apply_queued_clawback( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + row: &pending_event_queue::Model, + ) -> Result { + let payload: QueuedClawbackPayload = serde_json::from_value(row.payload.clone()) + .map_err(|e| DomainError::Internal(format!("deserialize queued claw-back: {e}")))?; + let req = payload.into_request()?; + + // Re-drive the claw-back inline, but via the queued-apply engine path with a + // COMPOSITE sidecar that also flips the queue row `→APPLIED`. The underflow + // pre-check runs again under the rank-1 lock against THEN-CURRENT counters. + match self + .post_clawback_queued_apply(ctx, scope, &req, &row.business_id) + .await + { + Ok(_) => Ok(ClawbackApply::Applied), + // Still underflows: defer again UNLESS the row has aged out, in which + // case it never reconciled → escalate (design §4.4). + Err(DomainError::RefundClawbackDeferred(_)) => { + let aged = (Utc::now() - row.queued_at) >= Duration::seconds(CLAWBACK_AGING_SECS); + if aged { + self.escalate_clawback(ctx, scope, &req, &row.business_id) + .await?; + Ok(ClawbackApply::Escalated) + } else { + Ok(ClawbackApply::StillDeferred) + } + } + Err(other) => Err(other), + } + } + + /// The deferred-apply twin of [`Self::post_clawback`]/[`Self::post_forward`]: + /// re-drive the claw-back stage-1 via [`PostingService::post_queued_apply`] with + /// a COMPOSITE sidecar that wraps the [`RefundPostSidecar`] AND flips the queue + /// row `→APPLIED` in the same txn. The dedup row claimed `QUEUED` at the defer + /// intake is read (not re-claimed) and finalized `QUEUED → POSTED`. Surfaces + /// [`DomainError::RefundClawbackDeferred`] (rolled back) when the decrement still + /// underflows. + async fn post_clawback_queued_apply( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + queue_business_id: &str, + ) -> Result { + let plan = build_refund_legs(req)?; + let entry_business_id = refund_business_id(&req.psp_refund_id, req.phase.as_str()); + let (entry, lines) = self + .assemble_post(ctx, scope, req, &plan, &entry_business_id, None) + .await?; + let sidecar: Arc = Arc::new(QueuedClawbackApplySidecar { + inner: RefundPostSidecar { + cap_mode: CapMode::Clawback, + cap: RefundCap::for_request(req), + refund_row: Self::refund_row(req, plan.clearing_state, None), + payment: self.payment.clone(), + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }, + flow: FLOW_REFUND_CLAWBACK.to_owned(), + business_id: queue_business_id.to_owned(), + tenant: req.tenant_id, + }); + self.posting + .post_queued_apply(ctx, scope, entry, lines, Some(sidecar)) + .await + } + + /// A claw-back that never reconciled past the aging horizon: flip the queue row + /// `→CANCELLED` (terminal) and ESCALATE (design §4.4 — never hard-fail). The + /// exception-queue is Slice 7 (VHP-1859), so this is a minimal exception STUB: a + /// logged escalation + the out-of-band `ClawbackUnderflow` alarm (which mirrors + /// into `ledger_invariant_alarm_total` and pages Finance). The CANCELLED flip is + /// its own short txn (the apply already rolled back). + async fn escalate_clawback( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + business_id: &str, + ) -> Result<(), DomainError> { + // exception stub (full exception_queue is Slice 7) + tracing::error!( + tenant_id = %req.tenant_id, + payment_id = %req.payment_id, + psp_refund_id = %req.psp_refund_id, + relates_to_refund_id = ?req.relates_to_refund_id, + amount_minor = req.amount_minor, + "bss-ledger: claw-back never reconciled past the aging horizon — escalating \ + (CLAWBACK_UNDERFLOW; full exception_queue is Slice 7)" + ); + + // Flip the row `→CANCELLED` in its own txn (terminal — never re-claimed). + let scope_owned = scope.clone(); + let tenant = req.tenant_id; + let business_id_owned = business_id.to_owned(); + self.db + .transaction(move |txn| { + Box::pin(async move { + PendingQueueRepo::mark_cancelled( + txn, + &scope_owned, + tenant, + FLOW_REFUND_CLAWBACK, + &business_id_owned, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("claw-back escalate cancel: {e}")))?; + + // Raise the CLAWBACK_UNDERFLOW alarm out-of-band + Finance alert (Critical — + // a books-affecting money-out that could not be netted; mirrors the + // `CreditNoteSplitBlocked` explicit raise). Fire-and-forget. + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::ClawbackUnderflow, + severity: AlarmSeverity::Critical, + tenant_id: req.tenant_id, + scope: format!( + "tenant:{}/flow:{FLOW_REFUND_CLAWBACK}/business:{business_id}", + req.tenant_id + ), + code: "CLAWBACK_UNDERFLOW".to_owned(), + detail: format!( + "claw-back of {} minor on payment {} (psp_refund_id {}) never found a matching \ + outbound refund to net against within the aging horizon", + req.amount_minor, req.payment_id, req.psp_refund_id + ), + affected: vec![AffectedItem { + id: format!( + "payment:{}/psp_refund:{}/relates_to:{}", + req.payment_id, + req.psp_refund_id, + req.relates_to_refund_id.as_deref().unwrap_or("") + ), + currency: req.currency.clone(), + expected_minor: req.amount_minor, + actual_minor: 0, + }], + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + + // Slice 7 Phase 2: ADDITIVELY open a durable close-blocking exception row + // beside the alarm above (the escalation already logged + alarmed; this only + // makes the never-reconciled clawback block the next close until resolved). + if let Some(ex) = &self.exceptions { + ex.route( + req.tenant_id, + crate::domain::exception::ExceptionType::ReconMismatch, + &req.psp_refund_id, + Some(serde_json::json!({ + "psp_refund_id": req.psp_refund_id, + "payment_id": req.payment_id, + })), + ) + .await; + } + Ok(()) + } + + /// Bump one claw-back queue row's `attempts` + defer its next eligibility by an + /// exponential backoff, in its own short txn (mirrors the chargeback/allocate + /// `bump_attempts_own_txn`). The `apply_after` defer keeps a still-underflowing + /// row from hot-looping the drain before it either reconciles or ages out. + async fn bump_clawback_attempts( + &self, + scope: &AccessScope, + tenant: Uuid, + business_id: &str, + prior_attempts: i64, + ) -> Result<(), DomainError> { + let scope_owned = scope.clone(); + let business_id = business_id.to_owned(); + let defer_until = Utc::now() + clawback_backoff(prior_attempts + 1); + self.db + .transaction(move |txn| { + Box::pin(async move { + PendingQueueRepo::bump_attempts_and_defer( + txn, + &scope_owned, + tenant, + FLOW_REFUND_CLAWBACK, + &business_id, + defer_until, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("claw-back drain bump attempts: {e}"))) + } + + /// Durably QUARANTINE a refund-before-payment (Group G, design §4.4 / PRD L668 + /// / Rev2 E-11): claim the `(tenant, REFUND_QUARANTINE, psp_refund_id:phase)` + /// dedup row as `QUEUED` and insert the work-state queue row carrying the + /// PII-free [`QuarantinedRefundPayload`] (+ the PSP correlation id), in ONE + /// `db.transaction` (mirrors [`Self::enqueue_clawback`]). Raises the out-of-band + /// `RefundQuarantined` alarm. Returns [`RefundOutcome::Quarantined`] (the REST + /// 202 handle). NEVER posts — de-quarantine ([`Self::drain_quarantine`]) is the + /// only path that can later post it, and only after re-validating everything. + async fn quarantine_refund( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + ) -> Result { + let now = Utc::now(); + let business_id = refund_business_id(&req.psp_refund_id, req.phase.as_str()); + let payload = QuarantinedRefundPayload::from_request(req); + let payload_json = serde_json::to_value(&payload) + .map_err(|e| DomainError::Internal(format!("serialize quarantine payload: {e}")))?; + let payload_hash = { + let canonical = serde_json::to_string(&payload).map_err(|e| { + DomainError::Internal(format!("canonicalize quarantine payload: {e}")) + })?; + IdempotencyGate::content_hash(&canonical) + }; + + let tenant = req.tenant_id; + let gate = IdempotencyGate::new(); + let scope_owned = scope.clone(); + let business_id_owned = business_id.clone(); + // Keep an un-moved copy of the incoming hash for the AC #19 conflict capture + // after the txn (Z14-1). + let incoming_hash = payload_hash.clone(); + let outcome = self + .db + .transaction(move |txn| { + Box::pin(async move { + // Claim the QUARANTINE-flow dedup row as QUEUED. A re-quarantine + // of the SAME key is idempotent (Replay); a different payload is + // a conflict. + let claim = gate + .claim_queued( + txn, + tenant, + FLOW_REFUND_QUARANTINE, + &business_id_owned, + &payload_hash, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + match claim { + ClaimOutcome::Claimed => { + PendingQueueRepo::insert_queued( + txn, + &scope_owned, + &NewQueueRow { + tenant_id: tenant, + flow: FLOW_REFUND_QUARANTINE.to_owned(), + business_id: business_id_owned.clone(), + payload: payload_json, + queued_at: now, + apply_after: None, + }, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + Ok::(QuarantineIntake::Quarantined) + } + ClaimOutcome::Replay(row) => { + if row.payload_hash == payload_hash { + Ok(QuarantineIntake::AlreadyQuarantined) + } else { + Ok(QuarantineIntake::Conflict { + stored_hash: row.payload_hash, + }) + } + } + } + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("refund quarantine intake: {e}")))?; + + if let QuarantineIntake::Conflict { stored_hash } = &outcome { + // AC #19 (Z14-1): capture the conflicting reuse on the secured-audit sink + // (best-effort, own txn) BEFORE returning the hard error. + self.capture_idempotency_conflict( + ctx, + scope, + req.tenant_id, + FLOW_REFUND_QUARANTINE, + &business_id, + stored_hash, + &incoming_hash, + ) + .await; + return Err(DomainError::IdempotencyConflict(format!( + "quarantined refund {business_id} reused with a different payload" + ))); + } + + // Raise the RefundQuarantined alarm out-of-band (Warn — a PSP/ingestion + // ordering signal, nothing posted). Fire-and-forget. Only on a FRESH + // quarantine (an idempotent re-quarantine does not re-raise — mirrors how a + // replay raises no alarm). + if matches!(outcome, QuarantineIntake::Quarantined) { + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::RefundQuarantined, + severity: AlarmSeverity::Warn, + tenant_id: req.tenant_id, + scope: format!( + "tenant:{}/flow:{FLOW_REFUND_QUARANTINE}/business:{business_id}", + req.tenant_id + ), + code: "REFUND_QUARANTINED".to_owned(), + detail: format!( + "refund {} (psp_refund_id {}) references payment {} with no resolvable \ + settlement — quarantined, never posted", + req.refund_id, req.psp_refund_id, req.payment_id + ), + affected: vec![AffectedItem { + id: format!( + "payment:{}/psp_refund:{}", + req.payment_id, req.psp_refund_id + ), + currency: req.currency.clone(), + expected_minor: req.amount_minor, + actual_minor: 0, + }], + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + } + + Ok(RefundOutcome::Quarantined(QuarantineHandle { + flow: FLOW_REFUND_QUARANTINE.to_owned(), + business_id, + quarantined_at: now, + })) + } + + /// Drain up to `limit` due QUARANTINED refunds for one tenant (Group G + /// de-quarantine, design §4.4): claim them under `SKIP LOCKED`, then RE-VALIDATE + /// each in its own txn. De-quarantine is NOT a blind apply — for each row it: + /// 1. reconstructs the [`RefundRequest`] from the payload; + /// 2. re-resolves the origin `payment_settlement` — STILL ABSENT ⇒ leave it + /// `QUEUED` (back off) UNLESS it has aged out past [`QUARANTINE_AGING_SECS`], + /// in which case CANCEL + escalate (exception stub + alarm); + /// 3. origin NOW resolvable ⇒ re-drive through [`Self::record_refund`]'s posted + /// path, which RE-CHECKS all §4.7 caps (in-txn under the rank-1 lock) + the + /// THEN-CURRENT D2 threshold (the gated [`Self::post_refund`] path). An + /// over-threshold de-quarantine returns `DualControlRequired` ⇒ the row stays + /// `QUEUED` (an approval is now open; it NEVER auto-posts) and is marked + /// `APPLIED` once the approved replay posts. A posted refund flips the row + /// `→APPLIED`. + /// + /// Public so the periodic sweep can drive it. Per-row faults are isolated. + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn fails; per-row faults + /// are isolated inside the pass. + pub async fn drain_quarantine( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + let now = Utc::now(); + let pending_queue = self.pending_queue.clone(); + let scope_owned = scope.clone(); + let claimed: Vec = self + .db + .transaction(move |txn| { + Box::pin(async move { + pending_queue + .claim_due( + txn, + &scope_owned, + tenant, + FLOW_REFUND_QUARANTINE, + now, + limit, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("refund quarantine drain claim: {e}")))?; + + let mut report = QuarantineDrainReport::default(); + for row in claimed { + match self.apply_quarantined(ctx, scope, &row).await { + Ok(QuarantineApply::Released) => report.released += 1, + Ok(QuarantineApply::AwaitingApproval) => report.awaiting_approval += 1, + Ok(QuarantineApply::StillMissing) => report.still_missing += 1, + Ok(QuarantineApply::Escalated) => report.escalated += 1, + Err(e) => tracing::error!( + tenant_id = %tenant, business_id = %row.business_id, error = %e, + "bss-ledger: quarantined refund apply failed (infra); continuing" + ), + } + } + Ok(report) + } + + /// Re-validate + (maybe) release ONE quarantined refund row (de-quarantine). + /// See [`Self::drain_quarantine`] for the four terminal shapes. The flip + /// `→APPLIED` is its OWN short txn once the refund posts (or an approval opens). + async fn apply_quarantined( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + row: &pending_event_queue::Model, + ) -> Result { + let payload: QuarantinedRefundPayload = serde_json::from_value(row.payload.clone()) + .map_err(|e| DomainError::Internal(format!("deserialize quarantined refund: {e}")))?; + let req = payload.into_request()?; + + // Re-resolve the origin settlement. STILL ABSENT ⇒ leave queued (back off) or + // escalate if aged out. (Re-uses the same scoped read the intake used.) + let settlement = self + .payment + .read_settlement(scope, req.tenant_id, &req.payment_id) + .await + .map_err(|e| DomainError::Internal(format!("re-read origin settlement: {e}")))?; + if settlement.is_none() { + let aged = (Utc::now() - row.queued_at) >= Duration::seconds(QUARANTINE_AGING_SECS); + if aged { + self.escalate_quarantine(ctx, scope, &req, &row.business_id) + .await?; + return Ok(QuarantineApply::Escalated); + } + return Ok(QuarantineApply::StillMissing); + } + + // Origin NOW resolvable ⇒ re-drive through the gated posted path. This + // RE-CHECKS the §4.7 caps in-txn + the THEN-CURRENT D2 threshold AND the + // DISPUTE-HOLD gate (Z5-2): `post_refund` → `post_refund_inner` reads the open + // dispute on the origin payment BEFORE posting and HOLDS the cash leg if one + // exists (the 4th dispute-hold checkpoint — a de-quarantined refund whose + // payment is now disputed must NOT post). An over-threshold de-quarantine + // opens an approval (DualControlRequired): the row stays QUEUED until the + // approved replay posts — it NEVER auto-posts over threshold. (`record_refund` + // would re-quarantine on a now-impossible absent origin; we call `post_refund` + // directly since we just confirmed presence.) + match self.post_refund(ctx, scope, req).await { + Ok(_) => { + // Posted (in-txn caps + threshold + no open dispute) ⇒ flip the + // quarantine row →APPLIED (its own short txn — the post already + // committed). + self.mark_quarantine_applied(scope, row).await?; + Ok(QuarantineApply::Released) + } + // Over the THEN-CURRENT D2 ⇒ an approval is now open. The row stays QUEUED + // (it never auto-posts); the approved replay will post it and a later + // drain marks it APPLIED once the origin+threshold clear. Counted, not + // re-tried this pass. + Err(DomainError::DualControlRequired(_)) => Ok(QuarantineApply::AwaitingApproval), + // The origin landed BUT the payment now has an OPEN dispute ⇒ the refund + // was durably HELD on the `REFUND_DISPUTE_HOLD` queue (Z5-2). The + // QUARANTINE concern is resolved (origin present) and the refund is now + // tracked on the dispute-hold queue, so flip the quarantine row →APPLIED + // (it never re-quarantines). The dispute-hold drain owns it from here + // (re-drives on WON / cancels on LOST). Counted as Released (the + // quarantine terminated cleanly — it neither stays queued nor escalates). + Err(DomainError::RefundDisputeHeld(_)) => { + self.mark_quarantine_applied(scope, row).await?; + Ok(QuarantineApply::Released) + } + Err(other) => Err(other), + } + } + + /// Flip one quarantine row `→APPLIED` (de-quarantine succeeded) in its own short + /// txn (the post already committed). Mirrors [`Self::escalate_clawback`]'s cancel. + async fn mark_quarantine_applied( + &self, + scope: &AccessScope, + row: &pending_event_queue::Model, + ) -> Result<(), DomainError> { + let scope_owned = scope.clone(); + let tenant = row.tenant_id; + let business_id_owned = row.business_id.clone(); + self.db + .transaction(move |txn| { + Box::pin(async move { + PendingQueueRepo::mark_applied( + txn, + &scope_owned, + tenant, + FLOW_REFUND_QUARANTINE, + &business_id_owned, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("mark quarantine applied: {e}"))) + } + + /// A quarantined refund whose origin payment never landed past the aging horizon + /// ([`QUARANTINE_AGING_SECS`]): flip the row `→CANCELLED` + escalate (exception + /// stub + `RefundQuarantined` Critical alarm — the origin never arrived). Mirrors + /// [`Self::escalate_clawback`]. + async fn escalate_quarantine( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + business_id: &str, + ) -> Result<(), DomainError> { + // exception stub (full exception_queue is Slice 7) + tracing::error!( + tenant_id = %req.tenant_id, + payment_id = %req.payment_id, + psp_refund_id = %req.psp_refund_id, + amount_minor = req.amount_minor, + "bss-ledger: quarantined refund's origin payment never landed past the aging horizon \ + — escalating (REFUND_QUARANTINED; full exception_queue is Slice 7)" + ); + let scope_owned = scope.clone(); + let tenant = req.tenant_id; + let business_id_owned = business_id.to_owned(); + self.db + .transaction(move |txn| { + Box::pin(async move { + PendingQueueRepo::mark_cancelled( + txn, + &scope_owned, + tenant, + FLOW_REFUND_QUARANTINE, + &business_id_owned, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("quarantine escalate cancel: {e}")))?; + + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::RefundQuarantined, + severity: AlarmSeverity::Critical, + tenant_id: req.tenant_id, + scope: format!( + "tenant:{}/flow:{FLOW_REFUND_QUARANTINE}/business:{business_id}", + req.tenant_id + ), + code: "REFUND_QUARANTINED".to_owned(), + detail: format!( + "quarantined refund {} (psp_refund_id {}) on payment {} never found its origin \ + settlement within the aging horizon", + req.refund_id, req.psp_refund_id, req.payment_id + ), + affected: vec![AffectedItem { + id: format!( + "payment:{}/psp_refund:{}", + req.payment_id, req.psp_refund_id + ), + currency: req.currency.clone(), + expected_minor: req.amount_minor, + actual_minor: 0, + }], + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + Ok(()) + } + + /// Forward post for the `initiated` / `confirmed` phases (design §4.4), + /// OUTBOUND or refund-of-refund CLAW-BACK. Builds the routed two-leg plan + /// (`build_refund_legs` flips the sides for a claw-back), assembles the engine + /// entry, and posts it with the [`RefundPostSidecar`]. The cap mode follows the + /// `(phase, direction)`: + /// - OUTBOUND `initiated` (a plain stage-1 OR an additional-outbound + /// refund-of-refund) → [`CapMode::Initiate`] (INCREMENT the money-out cap); + /// - CLAW-BACK `initiated` → [`CapMode::Clawback`] (DECREMENT, under the + /// sidecar's rank-1-lock underflow pre-check — Group E); + /// - `confirmed` (either direction) → [`CapMode::None`] (the counters moved at + /// stage-1; stage-2 only drains `REFUND_CLEARING`). + /// + /// On a CLAW-BACK whose decrement would underflow (out-of-order PSP claw-back), + /// the sidecar returns [`DomainError::RefundClawbackDeferred`] which rolls the + /// whole post back; [`Self::post_clawback`] catches it and DEFERS the request to + /// the queue (never hard-fail). An OUTBOUND post never defers here. + async fn post_forward( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + ) -> Result { + let plan = build_refund_legs(req)?; + let cap_mode = match (req.phase, req.is_clawback()) { + // Outbound stage-1 (single-step `initiated` included): reserve the cap. + // A single-step refund's one `initiated` entry already moves the cash, + // so it must reserve the cap exactly like a two-stage stage-1. An + // additional-outbound refund-of-refund also lands here (cash out again + // under the SAME money-out cap). + (RefundPhase::Initiated, false) => CapMode::Initiate, + // Claw-back stage-1: DECREMENT the origin money-out counters (net to the + // refunded amount) — the sidecar pre-checks the underflow under the + // rank-1 lock and defers (not hard-fail) if it would go negative. + (RefundPhase::Initiated, true) => CapMode::Clawback, + // Stage-2 (either direction) drains REFUND_CLEARING; the cash/claw-back + // was capped at stage-1. + (RefundPhase::Confirmed, _) => CapMode::None, + // `post_forward` is only called for Initiated/Confirmed (routed in + // `post_refund`); defend the invariant. + (other, _) => { + return Err(DomainError::Internal(format!( + "post_forward reached with non-forward phase {}", + other.as_str() + ))); + } + }; + + let business_id = refund_business_id(&req.psp_refund_id, req.phase.as_str()); + let (entry, lines) = self + .assemble_post(ctx, scope, req, &plan, &business_id, None) + .await?; + + let sidecar: Arc = Arc::new(RefundPostSidecar { + cap_mode, + cap: RefundCap::for_request(req), + refund_row: Self::refund_row(req, plan.clearing_state, None), + payment: self.payment.clone(), + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }); + + self.posting + .post(ctx, scope, entry, lines, Some(sidecar)) + .await + } + + /// Stage-1 reversal for the `rejected` / `voided` phases (design §4.4 / §4.7, + /// Group C): the PSP failed an initiated refund, so we (a) STRICTLY line-negate + /// the stage-1 entry (`reverses_entry_id = `, legs = the + /// stage-1 legs with sides inverted — `DR REFUND_CLEARING` against the restored + /// `UNALLOCATED`(A) / `AR`(B)), and (b) in the SAME txn RELEASE the caps the + /// stage-1 reserved + drain `REFUND_CLEARING` to zero. Idempotent on + /// `(tenant, REFUND, psp_refund_id:rejected/voided)`; the `refund` row records + /// the reversal with `clearing_state = REVERSED` + `reverses_entry_id`. + async fn post_reversal( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + ) -> Result { + // Resolve the stage-1 `initiated` entry this reversal negates. The + // line-negation requires the original entry id (the `reverses_entry_id` + // foreign link); a reject/void with no prior stage-1 is an upstream contract + // violation (nothing to reverse). The stage-1 entry's business id is + // `(psp_refund_id:initiated)` — resolve it scoped (SQL-level BOLA). + let stage1_business_id = + refund_business_id(&req.psp_refund_id, RefundPhase::Initiated.as_str()); + let stage1_entry_id = self + .resolve_stage1_entry(scope, req, &stage1_business_id) + .await?; + + // Build the stage-1 plan, then invert each leg's side: the reversal posts + // exactly the stage-1 legs with DR<->CR swapped. Stage-1 was + // `DR pattern.debit · CR REFUND_CLEARING`, so the reversal is + // `DR REFUND_CLEARING · CR pattern.debit` — restoring the drawn-down + // UNALLOCATED(A) / AR(B) and draining REFUND_CLEARING back to zero. + let stage1_plan = build_refund_legs(&RefundRequest { + phase: RefundPhase::Initiated, + ..req.clone() + })?; + let reversal_plan = invert_plan(&stage1_plan); + + let business_id = refund_business_id(&req.psp_refund_id, req.phase.as_str()); + let (entry, lines) = self + .assemble_post( + ctx, + scope, + req, + &reversal_plan, + &business_id, + Some(stage1_entry_id), + ) + .await?; + + let sidecar: Arc = Arc::new(RefundPostSidecar { + // Release the caps the stage-1 reserved (decrement by the stage-1 amount, + // which equals this reversal's amount). The reversal `refund` row stamps + // `clearing_state = REVERSED` + `reverses_entry_id`. + cap_mode: CapMode::Release, + cap: RefundCap::for_request(req), + refund_row: Self::refund_row(req, CLEARING_STATE_REVERSED, Some(stage1_entry_id)), + payment: self.payment.clone(), + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }); + + self.posting + .post(ctx, scope, entry, lines, Some(sidecar)) + .await + } + + /// Resolve the stage-1 `initiated` journal entry id a stage-1 reversal negates, + /// by its `(tenant, psp_refund_id:initiated)` business id (scoped). Exactly one + /// `initiated` entry exists per `(psp_refund_id)` (the idempotency claim + /// guarantees at-most-once); a reject/void with no prior stage-1 is rejected as + /// `InvalidRequest` (nothing to reverse — an upstream contract violation). + async fn resolve_stage1_entry( + &self, + scope: &AccessScope, + req: &RefundRequest, + stage1_business_id: &str, + ) -> Result { + let mut ids = self + .journal + .entry_ids_for_business_id(scope, req.tenant_id, stage1_business_id) + .await + .map_err(|e| DomainError::Internal(format!("resolve stage-1 entry: {e}")))?; + match ids.len() { + 0 => Err(DomainError::InvalidRequest(format!( + "refund {} phase {} has no stage-1 initiated entry to reverse (psp_refund_id {})", + req.refund_id, + req.phase.as_str(), + req.psp_refund_id + ))), + 1 => Ok(ids.remove(0)), + // The at-most-once idempotency claim makes >1 a hard invariant breach + // (two `initiated` posts for one psp_refund_id). Surface as Internal + // rather than silently negating an arbitrary one. + n => Err(DomainError::Internal(format!( + "refund {} found {n} stage-1 initiated entries for psp_refund_id {} (expected 1)", + req.refund_id, req.psp_refund_id + ))), + } + } + + /// Resolve each planned leg's chart `account_id` + currency scale and assemble + /// the engine [`NewEntry`] + [`NewLine`] vector. Every refund class is + /// stream-less, so each resolves on `stream = None`. The header is + /// `source_doc_type = Refund` + `source_business_id = "{psp_refund_id}:{phase}"` + /// (the engine's idempotency key — one claim per PSP-refund phase). + /// `reverses_entry_id` is `Some()` for a stage-1 reversal (the + /// strict line-negation link) and `None` for a forward post. + async fn assemble_post( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &RefundRequest, + plan: &RefundLegPlan, + business_id: &str, + reverses_entry_id: Option, + ) -> Result<(NewEntry, Vec), DomainError> { + let chart = load_chart(&self.reference, scope, req.tenant_id).await?; + let scale = self + .resolver + .resolve(scope, req.tenant_id, &req.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + + let eff_date = Utc::now().date_naive(); + let period_id = format!("{:04}{:02}", eff_date.year(), eff_date.month()); + + let mut lines: Vec = Vec::with_capacity(plan.legs.len()); + for leg in &plan.legs { + let account_id = chart + .resolve( + leg.account_class, + &req.currency, + leg.revenue_stream.as_deref(), + ) + .ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + leg.account_class.as_str(), + leg.revenue_stream, + req.currency + )) + })?; + lines.push(Self::mk_line(req, leg, account_id, scale)); + } + + // Slice 5 (F2): functional carry-forward on a cross-currency refund. A + // refund unwinds a position through REFUND_CLEARING entirely in the + // transaction currency (no in-ledger conversion), so the functional cost + // basis carries forward and each stage's functional column nets to zero — + // NO realized FX line (true EUR→USD realization is Slice-7 reconciliation / + // ERP-export, owner decision 2026-06-28). The stamp keeps the relieved + // grain's functional column in lockstep with balance_minor; a single- + // currency refund is a no-op (functional NULL). + self.stamp_fx_carry_forward(scope, req, &chart, &mut lines) + .await?; + + // A line-negation reversal carries the reversed period alongside the + // reversed entry (the journal's `(reverses_entry_id IS NULL) = + // (reverses_period_id IS NULL)` CHECK). v1 posts the reversal into the + // current period; the reversed period is the same period_id (the stage-1 + // entry is recent — a cross-period reverse is not a Group-C concern). + let reverses_period_id = reverses_entry_id.map(|_| period_id.clone()); + + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: req.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: req.tenant_id, + period_id, + entry_currency: req.currency.clone(), + source_doc_type: SourceDocType::Refund, + // The engine's `(tenant, REFUND, psp_refund_id:phase)` idempotency key — + // one claim per PSP-refund phase (design §7). + source_business_id: business_id.to_owned(), + reverses_entry_id, + reverses_period_id, + posted_at_utc: Utc::now(), + effective_at: eff_date, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: ctx.subject_id(), + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + // Slice 5 (F2): a refund locks NO new rate — it unwinds at the carried + // rate (functional carry-forward in `stamp_fx_carry_forward`), so the + // entry never carries a rate_snapshot_ref. + rate_snapshot_ref: None, + }; + Ok((entry, lines)) + } + + /// Stamp functional **carry-forward** onto a cross-currency refund entry (Slice + /// 5 F2, design §3.5 — refund close, owner decision 2026-06-28: a refund unwinds + /// a position at its carried cost basis; the true EUR→USD realization is + /// Slice-7 reconciliation, not the refund). A refund moves money through the + /// two-stage `REFUND_CLEARING` entirely in the transaction currency, so the + /// functional cost basis carries forward and each stage's two equal-amount legs + /// are stamped at the SAME functional → the entry's functional column nets to + /// zero (NO `FX_GAIN_LOSS` line) while the relieved grain's functional column + /// decrements in lockstep with `balance_minor`. + /// + /// The carried-WAC **anchor** grain (the value each stage moves) by stage: + /// - `Confirmed` (stage-2) / `Rejected` / `Voided` (stage-1 reversal) → the + /// `REFUND_CLEARING` leg (its functional was set at stage-1; the stage drains + /// it, restoring the cash / the position at exactly the carried basis); + /// - `Initiated` Pattern A (stage-1 / single-step) → the `UNALLOCATED` pool it + /// relieves (`read_unallocated_carried`); + /// - `Initiated` Pattern B (stage-1 / single-step) → `CASH_CLEARING` (the AR leg + /// re-OPENS the receivable rather than relieving it, so the cash being + /// returned is the carried value the re-opened AR inherits). + /// + /// No-op (leaves functional NULL — byte-green single-currency path) when the + /// anchor grain carries no functional balance (design decision 8), when the + /// relieved amount exceeds the grain's balance (an over-relief the projector + /// rejects with `NegativeBalance` — let that cleaner rejection surface), or for a + /// refund-of-refund claw-back (a rare Group-E edge whose restored position needs + /// the prior refund's rate, not readily available — its cross-currency + /// functional is reconciled in Slice 7; tracked, not silently wrong) and the + /// `UnknownFinal` disposition (Group F, out of scope). + /// + /// # Errors + /// [`DomainError::Internal`] on a carried-read fault or a [`carried_relief`] + /// misuse (a malformed grain value — an internal invariant breach). + async fn stamp_fx_carry_forward( + &self, + scope: &AccessScope, + req: &RefundRequest, + chart: &ChartIndex, + lines: &mut [NewLine], + ) -> Result<(), DomainError> { + // Read the carried (transaction, functional) value of this stage's anchor + // grain — the position whose cost basis the stage carries. For a claw-back + // this read is used ONLY to detect cross-currency below (a claw-back restores + // a position at the PRIOR refund's rate, which this WAC carry-forward cannot + // source — see the cross-currency reject after the detect). + let (balance_minor, functional_balance_minor, functional_currency) = + match (req.phase, req.pattern) { + // Stage-2 / reversal: relieve REFUND_CLEARING (set at stage-1). The + // REFUND_CLEARING leg is present in every such entry; defensively no-op + // if absent. + (RefundPhase::Confirmed | RefundPhase::Rejected | RefundPhase::Voided, _) => { + let Some(account_id) = lines + .iter() + .find(|l| l.account_class == AccountClass::RefundClearing) + .map(|l| l.account_id) + else { + return Ok(()); + }; + let c = self + .payment + .read_account_carried(scope, req.tenant_id, account_id, &req.currency) + .await + .map_err(|e| { + DomainError::Internal(format!("read refund-clearing carried: {e}")) + })?; + ( + c.balance_minor, + c.functional_balance_minor, + c.functional_currency, + ) + } + // Stage-1 / single-step Pattern A: relieve the UNALLOCATED pool. + (RefundPhase::Initiated, RefundPattern::AUnallocated) => { + let u = self + .payment + .read_unallocated_carried( + scope, + req.tenant_id, + req.payer_tenant_id, + &req.currency, + ) + .await + .map_err(|e| { + DomainError::Internal(format!("read unallocated carried: {e}")) + })?; + ( + u.balance_minor, + u.functional_balance_minor, + u.functional_currency, + ) + } + // Stage-1 / single-step Pattern B: the AR leg re-OPENS the receivable, so + // anchor on CASH_CLEARING (the cash being returned is the carried value). + (RefundPhase::Initiated, RefundPattern::BRestoreAr) => { + let Some(account_id) = + chart.resolve(AccountClass::CashClearing, &req.currency, None) + else { + return Ok(()); + }; + let c = self + .payment + .read_account_carried(scope, req.tenant_id, account_id, &req.currency) + .await + .map_err(|e| { + DomainError::Internal(format!("read cash-clearing carried: {e}")) + })?; + ( + c.balance_minor, + c.functional_balance_minor, + c.functional_currency, + ) + } + // UnknownFinal (Group F) — out of scope. + (RefundPhase::UnknownFinal, _) => return Ok(()), + }; + + // Cross-currency detect (design decision 8): the anchor grain carries a + // functional balance. NULL ⇒ single-currency refund: leave functional NULL. + let (Some(anchor_functional), Some(functional_ccy)) = + (functional_balance_minor, functional_currency) + else { + return Ok(()); + }; + + // A CROSS-CURRENCY claw-back (refund-of-refund) restores the drawn-down + // position, which must be valued at the PRIOR refund's locked rate — NOT the + // anchor grain's current WAC (the grain is restored, not relieved, so its WAC + // would synthesize a spurious FX result). Sourcing the prior refund's snapshot + // is Slice 7; until then REJECT a cross-currency claw-back with a precise, + // NON-retryable error (distinct from the queue's `RefundClawbackDeferred`) + // rather than silently posting functional-NULL — a silent transaction-vs- + // functional drift. Single-currency claw-backs took the NULL branch above and + // post unaffected. + if req.is_clawback() { + return Err(DomainError::FxOperationUnsupported(format!( + "refund {} is a cross-currency claw-back; functional carry-forward at \ + the prior refund's rate is not yet supported (Slice 7) — route it \ + through a manual adjustment", + req.refund_id + ))); + } + + // Both refund legs share the amount, so the first line's amount is the + // relieved amount. A non-positive carried balance or an over-relief ⇒ skip + // carry-forward so the projector's NegativeBalance surfaces. + let relieved = lines.first().map_or(0, |l| l.amount_minor); + if balance_minor <= 0 || relieved > balance_minor { + return Ok(()); + } + + // Stamp every leg's functional at the anchor grain's WAC pro-rata of its OWN + // amount. Both legs share the amount, so both get the same value → the + // functional column nets to zero (carry-forward; no FX line). + for line in lines.iter_mut() { + let func = carried_relief(anchor_functional, balance_minor, line.amount_minor) + .map_err(|e| DomainError::Internal(format!("refund FX carry-forward: {e}")))?; + line.functional_amount_minor = Some(func); + line.functional_currency = Some(functional_ccy.clone()); + } + Ok(()) + } + + /// Assemble the `refund` record row for a phase post. `clearing_state` is the + /// plan's resulting state (`PENDING` / `SETTLED`) for a forward post or + /// `REVERSED` for a stage-1 reversal; `reverses_entry_id` is `Some` only on a + /// reversal. + fn refund_row( + req: &RefundRequest, + clearing_state: &str, + reverses_entry_id: Option, + ) -> NewRefund { + NewRefund { + tenant_id: req.tenant_id, + refund_id: req.refund_id.clone(), + psp_refund_id: req.psp_refund_id.clone(), + phase: req.phase.as_str().to_owned(), + pattern: req.pattern.as_str().to_owned(), + payment_id: req.payment_id.clone(), + invoice_id: req.invoice_id.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + clearing_state: clearing_state.to_owned(), + // The refund-of-refund forward link (Group E): a claw-back / additional- + // outbound carries the prior refund it references; `None` for a + // first-order refund. Rides the request verbatim. + relates_to_refund_id: req.relates_to_refund_id.clone(), + reverses_entry_id, + created_at_utc: Utc::now(), + } + } + + /// Map one [`PlannedLeg`] + its resolved chart account/scale to the engine + /// [`NewLine`]. The `AR` / `UNALLOCATED` legs carry `payer_tenant_id` (their + /// cache grains key on it) + the Pattern-B `invoice_id` (so a restored-AR leg + /// nets the right invoice's `ar_invoice_balance`); the clearing legs are + /// stream-less, invoice-less system grains. + fn mk_line(req: &RefundRequest, leg: &PlannedLeg, account_id: Uuid, scale: u8) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: req.payer_tenant_id, + seller_tenant_id: Some(req.tenant_id), + resource_tenant_id: None, + account_id, + account_class: leg.account_class, + gl_code: None, + side: leg.side, + amount_minor: leg.amount_minor, + currency: req.currency.clone(), + currency_scale: scale, + // Only the Pattern-B AR leg keys on an invoice (it re-opens that + // invoice's receivable). Pattern A (UNALLOCATED) + the clearing legs + // carry no invoice. `validate_shape` guaranteed `invoice_id` is `Some` + // exactly for Pattern B, so this rides the request's `invoice_id`. The + // stage-1 reversal carries the same invoice_id (the inverted AR leg nets + // the receivable back down). + invoice_id: req.invoice_id.clone(), + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } + } +} + +/// The engine idempotency `source_business_id` for a refund phase post: +/// `"{psp_refund_id}:{phase}"` — one claim per PSP-refund phase (design §7). A +/// single PSP refund advances through several phase rows (`initiated → confirmed`, +/// or `rejected`/`voided`), each its own idempotent post. +fn refund_business_id(psp_refund_id: &str, phase: &str) -> String { + format!("{psp_refund_id}:{phase}") +} + +/// The deferred-apply queue / dedup `business_id` for a deferred claw-back +/// (Group E): `"{psp_refund_id}:initiated"` — a claw-back only ever defers at its +/// stage-1 `initiated` (where the money-out decrement happens). Distinct grain from +/// the engine `REFUND` claim because it rides the separate `REFUND_CLAWBACK` flow. +fn clawback_business_id(psp_refund_id: &str) -> String { + format!("{psp_refund_id}:{}", RefundPhase::Initiated.as_str()) +} + +/// What the claw-back defer intake committed (mirrors `ChargebackService`'s +/// `IntakeOutcome`): a fresh enqueue, an idempotent re-defer of the same key, or a +/// same-key / different-payload conflict (carrying the STORED hash for the AC #19 +/// secured-audit capture, Z14-1). +enum ClawbackIntake { + Enqueued, + AlreadyQueued, + /// A same-key reuse with a different payload — carries the STORED payload hash so + /// the conflict-return site can capture stored-vs-incoming on the secured-audit + /// sink (Z14-1) before returning the error. + Conflict { + stored_hash: String, + }, +} + +/// The terminal shape of applying ONE queued claw-back row +/// ([`RefundHandler::apply_queued_clawback`]). +#[derive(Debug)] +pub enum ClawbackApply { + /// The decrement fit (the matching outbound landed) — posted + flipped + /// `→APPLIED`. + Applied, + /// The decrement STILL underflows but the row is within the aging horizon — + /// leave `QUEUED`, back off. + StillDeferred, + /// The claw-back aged out without reconciling — flipped `→CANCELLED` + escalated + /// (exception stub + `CLAWBACK_UNDERFLOW` alarm). + Escalated, +} + +/// Summary of one [`RefundHandler::drain_clawbacks`] pass (mirrors the +/// allocation/chargeback `DrainReport`). +#[derive(Debug, Default)] +pub struct ClawbackDrainReport { + pub applied: u64, + pub still_deferred: u64, + pub escalated: u64, +} + +/// The outcome of recording a refund via [`RefundHandler::record_refund`] (Group +/// G): either it POSTED inline (the origin settlement resolved) or it was +/// QUARANTINED (refund-before-payment — durably queued, NEVER posted; the REST +/// surface maps it to a 202 + `refund-quarantined` token). +#[derive(Debug)] +pub enum RefundOutcome { + /// The refund posted inline (fresh ⇒ 201, replay ⇒ 200 via `PostingRef::replayed`). + Posted(PostingRef), + /// The refund references a payment with no resolvable origin settlement — it was + /// QUARANTINED on the `REFUND_QUARANTINE` queue (202), never posted. + Quarantined(QuarantineHandle), + /// The refund's origin payment has an OPEN dispute (Z5-2, design §5) — the cash + /// leg was durably HELD on the `REFUND_DISPUTE_HOLD` queue (202), never posted. + /// The hold drain re-drives it once the dispute resolves WON (or cancels it on + /// LOST). The REST surface maps it to a 202 + `refund-dispute-held` body token. + DisputeHeld(DisputeHoldHandle), +} + +/// The handle a quarantined refund returns (Group G): the queue key + the intake +/// instant — the REST 202 `refund-quarantined` body. No posting handle (nothing +/// posted). +#[derive(Debug, Clone)] +pub struct QuarantineHandle { + /// The quarantine queue flow (`REFUND_QUARANTINE`). + pub flow: String, + /// The quarantine/dedup business id (`psp_refund_id:phase`). + pub business_id: String, + /// When the intake durably quarantined the request. + pub quarantined_at: chrono::DateTime, +} + +/// The handle a dispute-held refund returns (Z5-2, design §5): the queue key + the +/// intake instant — the REST 202 `refund-dispute-held` body. No posting handle +/// (nothing posted). Mirrors [`QuarantineHandle`]. +#[derive(Debug, Clone)] +pub struct DisputeHoldHandle { + /// The dispute-hold queue flow (`REFUND_DISPUTE_HOLD`). + pub flow: String, + /// The dispute-hold/dedup business id (`psp_refund_id:phase`). + pub business_id: String, + /// When the intake durably held the request. + pub held_at: chrono::DateTime, +} + +/// The outcome of the atomic `refund-with-credit-note` composite +/// ([`RefundHandler::post_refund_with_credit_note`]): both entry ids, committed +/// together. `replayed` ⇒ an idempotent re-drive of an already-posted composite. +#[derive(Debug, Clone)] +pub struct RefundWithCreditNoteOutcome { + pub refund_entry_id: Uuid, + pub credit_note_entry_id: Uuid, + pub replayed: bool, +} + +/// What the quarantine intake committed (mirrors `ClawbackIntake`). +enum QuarantineIntake { + Quarantined, + AlreadyQuarantined, + /// A same-key reuse with a different payload — carries the STORED payload hash + /// for the AC #19 secured-audit capture (Z14-1). + Conflict { + stored_hash: String, + }, +} + +/// The terminal shape of de-quarantining ONE row +/// ([`RefundHandler::apply_quarantined`]). +#[derive(Debug)] +pub enum QuarantineApply { + /// The origin landed + caps/threshold passed ⇒ posted + flipped `→APPLIED`. + Released, + /// The origin landed but the THEN-CURRENT D2 threshold was crossed ⇒ an approval + /// is now open; the row stays `QUEUED` (it NEVER auto-posts over threshold). + AwaitingApproval, + /// The origin payment is STILL absent and the row is within the aging horizon ⇒ + /// leave `QUEUED`, back off. + StillMissing, + /// The origin never landed past the aging horizon ⇒ flipped `→CANCELLED` + + /// escalated (exception stub + `RefundQuarantined` Critical alarm). + Escalated, +} + +/// Summary of one [`RefundHandler::drain_quarantine`] pass. +#[derive(Debug, Default)] +pub struct QuarantineDrainReport { + pub released: u64, + pub awaiting_approval: u64, + pub still_missing: u64, + pub escalated: u64, +} + +/// What the dispute-hold intake committed (mirrors `QuarantineIntake`). +enum DisputeHoldIntake { + Held, + AlreadyHeld, + /// A same-key reuse with a different payload — carries the STORED payload hash + /// for the AC #19 secured-audit capture (Z14-1). + Conflict { + stored_hash: String, + }, +} + +/// The terminal shape of draining ONE dispute-held refund row +/// ([`RefundHandler::apply_dispute_hold`], Z5-2 / design §5). +#[derive(Debug)] +pub enum DisputeHoldApply { + /// The dispute resolved WON + caps/threshold passed ⇒ posted + flipped + /// `→APPLIED`. + Released, + /// The dispute resolved WON but the THEN-CURRENT D2 threshold was crossed ⇒ an + /// approval is now open; the row stays `QUEUED` (it NEVER auto-posts over + /// threshold). + AwaitingApproval, + /// The dispute is STILL `OPENED` and the row is within the aging horizon ⇒ leave + /// `QUEUED`, back off (the cash stays held). + StillDisputed, + /// The dispute resolved LOST (a chargeback returned the money) ⇒ flipped + /// `→CANCELLED` + escalated. NEVER posted (double-pay guard). + Cancelled, + /// The dispute never resolved past the aging horizon ⇒ flipped `→CANCELLED` + + /// escalated (exception stub + `RefundQuarantined` Critical alarm). + Escalated, +} + +/// Summary of one [`RefundHandler::drain_dispute_hold`] pass. +#[derive(Debug, Default)] +pub struct DisputeHoldDrainReport { + pub released: u64, + pub awaiting_approval: u64, + pub still_disputed: u64, + pub cancelled: u64, + pub escalated: u64, +} + +/// The PII-free snapshot of a dispute-held refund, persisted as the queue row's +/// `payload` jsonb at intake + re-read by the dispute-hold drain (Z5-2 / design §5). +/// Carries the held refund's financial keys PLUS the `(dispute_id, dispute_cycle)` +/// the drain re-reads to decide WON (re-drive) vs LOST (cancel) vs still-OPEN (back +/// off). PII-free by construction (ids + money + enum wire literals only). The enums +/// ride as their stable wire literals (the domain stays serde-free); the drain +/// parses them back into a [`RefundRequest`]. Mirrors [`QuarantinedRefundPayload`]. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct DisputeHeldRefundPayload { + tenant_id: Uuid, + payer_tenant_id: Uuid, + refund_id: String, + psp_refund_id: String, + /// The phase wire literal. + phase: String, + /// The pattern wire literal. + pattern: String, + payment_id: String, + invoice_id: Option, + currency: String, + amount_minor: i64, + two_stage: bool, + relates_to_refund_id: Option, + /// The direction wire literal. + direction: String, + /// The OPEN dispute this refund is held behind — re-read by the drain. + dispute_id: String, + /// The dispute cycle at hold time (diagnostic; the drain re-reads the row's + /// current state, not this snapshot). + dispute_cycle: i32, +} + +impl DisputeHeldRefundPayload { + /// Snapshot a dispute-held [`RefundRequest`] (+ the open dispute it is held + /// behind) into the PII-free payload. + fn from_request(req: &RefundRequest, dispute_id: &str, dispute_cycle: i32) -> Self { + Self { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + refund_id: req.refund_id.clone(), + psp_refund_id: req.psp_refund_id.clone(), + phase: req.phase.as_str().to_owned(), + pattern: req.pattern.as_str().to_owned(), + payment_id: req.payment_id.clone(), + invoice_id: req.invoice_id.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + two_stage: req.two_stage, + relates_to_refund_id: req.relates_to_refund_id.clone(), + direction: req.direction.as_str().to_owned(), + dispute_id: dispute_id.to_owned(), + dispute_cycle, + } + } + + /// Reconstruct the [`RefundRequest`] from a dispute-held payload at drain time, + /// parsing the wire enum literals (the inverse of [`Self::from_request`]). + /// + /// # Errors + /// [`DomainError::Internal`] when a stored enum literal is unknown (data + /// corruption — written only from `as_str`). + fn into_request(self) -> Result { + let phase = RefundPhase::parse(&self.phase).ok_or_else(|| { + DomainError::Internal(format!( + "dispute-held refund unknown phase {:?}", + self.phase + )) + })?; + let pattern = RefundPattern::parse(&self.pattern).ok_or_else(|| { + DomainError::Internal(format!( + "dispute-held refund unknown pattern {:?}", + self.pattern + )) + })?; + let direction = RefundDirection::parse(&self.direction).ok_or_else(|| { + DomainError::Internal(format!( + "dispute-held refund unknown direction {:?}", + self.direction + )) + })?; + Ok(RefundRequest { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + refund_id: self.refund_id, + psp_refund_id: self.psp_refund_id, + phase, + pattern, + payment_id: self.payment_id, + invoice_id: self.invoice_id, + currency: self.currency, + amount_minor: self.amount_minor, + two_stage: self.two_stage, + relates_to_refund_id: self.relates_to_refund_id, + direction, + }) + } +} + +/// The PII-free snapshot of a quarantined refund, persisted as the queue row's +/// `payload` jsonb at intake + re-read by the de-quarantine drain. Carries the +/// PSP-correlation id (`psp_refund_id`) so a reconciler can match it to the missing +/// payment, but NO names / free text. The enums ride as their stable wire literals +/// (the domain stays serde-free); the drain parses them back into a +/// [`RefundRequest`]. Mirrors [`QueuedClawbackPayload`]. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct QuarantinedRefundPayload { + tenant_id: Uuid, + payer_tenant_id: Uuid, + refund_id: String, + psp_refund_id: String, + /// The phase wire literal. + phase: String, + /// The pattern wire literal. + pattern: String, + payment_id: String, + invoice_id: Option, + currency: String, + amount_minor: i64, + two_stage: bool, + relates_to_refund_id: Option, + /// The direction wire literal. + direction: String, +} + +impl QuarantinedRefundPayload { + /// Snapshot a refund-before-payment [`RefundRequest`] into the PII-free payload. + fn from_request(req: &RefundRequest) -> Self { + Self { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + refund_id: req.refund_id.clone(), + psp_refund_id: req.psp_refund_id.clone(), + phase: req.phase.as_str().to_owned(), + pattern: req.pattern.as_str().to_owned(), + payment_id: req.payment_id.clone(), + invoice_id: req.invoice_id.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + two_stage: req.two_stage, + relates_to_refund_id: req.relates_to_refund_id.clone(), + direction: req.direction.as_str().to_owned(), + } + } + + /// Reconstruct the [`RefundRequest`] from a quarantined payload at drain time, + /// parsing the wire enum literals (the inverse of [`Self::from_request`]). + /// + /// # Errors + /// [`DomainError::Internal`] when a stored enum literal is unknown (data + /// corruption — written only from `as_str`). + fn into_request(self) -> Result { + let phase = RefundPhase::parse(&self.phase).ok_or_else(|| { + DomainError::Internal(format!("quarantined refund unknown phase {:?}", self.phase)) + })?; + let pattern = RefundPattern::parse(&self.pattern).ok_or_else(|| { + DomainError::Internal(format!( + "quarantined refund unknown pattern {:?}", + self.pattern + )) + })?; + let direction = RefundDirection::parse(&self.direction).ok_or_else(|| { + DomainError::Internal(format!( + "quarantined refund unknown direction {:?}", + self.direction + )) + })?; + Ok(RefundRequest { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + refund_id: self.refund_id, + psp_refund_id: self.psp_refund_id, + phase, + pattern, + payment_id: self.payment_id, + invoice_id: self.invoice_id, + currency: self.currency, + amount_minor: self.amount_minor, + two_stage: self.two_stage, + relates_to_refund_id: self.relates_to_refund_id, + direction, + }) + } +} + +/// The in-transaction [`PostSidecar`] for the atomic `refund-with-credit-note` +/// composite (Group G / K-3): it runs the refund's normal in-txn work (delegating +/// to the wrapped [`RefundPostSidecar`] — caps + `refund` row + `refund.recorded` +/// event) AND posts the prepared credit note as the SECOND entry in the SAME txn +/// (via [`CreditNoteHandler::apply_in_txn`]). So both journal entries + both record +/// rows + both events commit atomically, or roll back together — AR is never +/// overstated between them. Mirrors the composite shape of +/// [`QueuedClawbackApplySidecar`] (delegate-then-extra-write). +struct RefundWithCreditNoteSidecar { + /// The refund's own in-txn sidecar (caps + record + event). + refund: RefundPostSidecar, + /// The credit-note orchestrator (posts the second entry in-txn). + credit_note: Arc, + /// The prepared credit note, shared by `Arc` so a serializable RETRY of the + /// outer refund post re-runs this sidecar safely (the prepared note is borrowed, + /// never consumed). `apply_in_txn` re-claims the credit note's dedup each attempt + /// (idempotent: a committed prior attempt replays). + prepared: Arc, + /// The security context for the credit-note's in-txn writes. + ctx: SecurityContext, + /// The slot the composite orchestrator reads the posted credit-note entry id + /// back from after the txn commits (overwritten each attempt; the committed + /// attempt's value is the one read back). + outcome: Arc>>, +} + +#[async_trait::async_trait] +impl PostSidecar for RefundWithCreditNoteSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + // 1. The refund's in-txn work (caps + refund row + refund.recorded event). + self.refund.run(txn, scope, posted).await?; + // 2. The paired credit note as the SECOND entry in the SAME txn. A failure + // here (split-ambiguous slipped through, headroom CHECK, AR no-negative, + // credit-note idempotency conflict) rolls the WHOLE composite back — the + // refund entry included. + let cn = self + .credit_note + .apply_in_txn(&self.ctx, txn, scope, &self.prepared) + .await?; + let mut slot = self + .outcome + .lock() + .map_err(|_| DomainError::Internal("composite outcome mutex poisoned".to_owned()))?; + *slot = Some(cn); + Ok(()) + } +} + +/// The financial-key snapshot of a deferred claw-back, persisted as the queue row's +/// `payload` jsonb at intake and re-read by the drain. PII-free by construction +/// (ids + money + enum wire literals only — no names / free text). The enums are +/// carried as their stable wire literals so the domain stays serde-free (pure); the +/// drain parses them back into a [`RefundRequest`]. Mirrors +/// [`crate::infra::payment::chargeback::QueuedDisputePayload`]. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct QueuedClawbackPayload { + tenant_id: Uuid, + payer_tenant_id: Uuid, + refund_id: String, + psp_refund_id: String, + /// The pattern wire literal (`A_UNALLOCATED` / `B_RESTORE_AR`). + pattern: String, + payment_id: String, + invoice_id: Option, + currency: String, + amount_minor: i64, + two_stage: bool, + /// The prior refund this claws back (always `Some` — a claw-back requires it). + relates_to_refund_id: Option, + /// The direction wire literal — always `CLAWBACK` (only a claw-back defers), but + /// carried verbatim so the rebuilt request is byte-identical. + direction: String, +} + +impl QueuedClawbackPayload { + /// Snapshot a deferred claw-back [`RefundRequest`] into the PII-free payload. + fn from_request(req: &RefundRequest) -> Self { + Self { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + refund_id: req.refund_id.clone(), + psp_refund_id: req.psp_refund_id.clone(), + pattern: req.pattern.as_str().to_owned(), + payment_id: req.payment_id.clone(), + invoice_id: req.invoice_id.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + two_stage: req.two_stage, + relates_to_refund_id: req.relates_to_refund_id.clone(), + direction: req.direction.as_str().to_owned(), + } + } + + /// Reconstruct the [`RefundRequest`] from a queued payload at drain time, + /// parsing the wire enum literals (the inverse of [`Self::from_request`]). The + /// phase is fixed to `initiated` (the only phase a claw-back defers at). + /// + /// # Errors + /// [`DomainError::Internal`] when a stored enum literal is unknown (data + /// corruption — written only from `as_str`). + fn into_request(self) -> Result { + let pattern = RefundPattern::parse(&self.pattern).ok_or_else(|| { + DomainError::Internal(format!( + "queued claw-back unknown pattern {:?}", + self.pattern + )) + })?; + let direction = RefundDirection::parse(&self.direction).ok_or_else(|| { + DomainError::Internal(format!( + "queued claw-back unknown direction {:?}", + self.direction + )) + })?; + Ok(RefundRequest { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + refund_id: self.refund_id, + psp_refund_id: self.psp_refund_id, + phase: RefundPhase::Initiated, + pattern, + payment_id: self.payment_id, + invoice_id: self.invoice_id, + currency: self.currency, + amount_minor: self.amount_minor, + two_stage: self.two_stage, + relates_to_refund_id: self.relates_to_refund_id, + direction, + }) + } +} + +/// The request-based idempotency hash for a deferred claw-back — the `content_hash` +/// of the canonical [`QueuedClawbackPayload`]. Stable across retries, so it (not an +/// entry hash) is what the dedup row stores; `claim_queued`'s `Replay` compares it +/// to reject a same-key / different-payload reuse. Mirrors `dispute_request_hash`. +fn clawback_request_hash(payload: &QueuedClawbackPayload) -> Result { + let canonical = serde_json::to_string(payload) + .map_err(|e| DomainError::Internal(format!("canonicalize claw-back payload: {e}")))?; + Ok(IdempotencyGate::content_hash(&canonical)) +} + +/// Exponential backoff (wall-clock) before a still-underflowing claw-back may be +/// re-claimed (mirrors the allocate/chargeback `blocked_backoff`): ~`2^(attempts-1)` +/// seconds, capped at 5 minutes. Keeps a not-yet-reconciled claw-back from +/// hot-looping the drain before it either nets against its matching outbound or ages +/// out and escalates. +fn clawback_backoff(attempts: i64) -> Duration { + const BASE_SECS: i64 = 2; + const MAX_SECS: i64 = 300; + let shift = attempts.clamp(1, 16) - 1; + let secs = BASE_SECS.saturating_mul(1_i64 << shift).min(MAX_SECS); + Duration::seconds(secs) +} + +/// Composite in-transaction sidecar for the deferred apply of a queued claw-back: +/// it runs the SAME claw-back decrement + record write as the inline path (by +/// delegating to the wrapped [`RefundPostSidecar`] — whose underflow pre-check +/// re-runs under the rank-1 lock) and THEN flips the work-state queue row +/// `→APPLIED` — both inside the post txn opened by +/// [`PostingService::post_queued_apply`]. So the claw-back effect and the queue-row +/// transition commit atomically, or roll back together (an underflow-deferred +/// re-try rolls the `→APPLIED` flip back too, leaving the row claimable). Mirrors +/// [`crate::infra::payment::chargeback`]'s `QueuedChargebackApplySidecar`. +struct QueuedClawbackApplySidecar { + inner: RefundPostSidecar, + flow: String, + business_id: String, + tenant: Uuid, +} + +#[async_trait::async_trait] +impl PostSidecar for QueuedClawbackApplySidecar { + /// The queued-apply re-drive must, like the inline claw-back, run its cap / + /// underflow CHECK BEFORE projection (delegates to the wrapped inner + /// [`RefundPostSidecar`]). Otherwise an orphan claw-back (no matching + /// outbound) draws `REFUND_CLEARING` negative and the projector's no-negative + /// guard trips with a raw `NegativeBalance` before the underflow can surface + /// as `RefundClawbackDeferred` — so the drain never recognizes the aged row as + /// never-reconciled and never escalates it. + fn run_before_projection(&self) -> bool { + self.inner.run_before_projection() + } + + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + // 1. The claw-back decrement (+ underflow pre-check) + the refund record + // write (delegated). A still-underflow returns `RefundClawbackDeferred`, + // rolling the whole apply back (including the `→APPLIED` flip below). + self.inner.run(txn, scope, posted).await?; + // 2. Flip the work-state queue row `→APPLIED` in the SAME txn. + PendingQueueRepo::mark_applied(txn, scope, self.tenant, &self.flow, &self.business_id) + .await + .map_err(|e| { + DomainError::Internal(format!("claw-back queue-apply mark_applied: {e}")) + })?; + Ok(()) + } +} + +/// Build the strict line-negation of a refund plan: the SAME legs with DR<->CR +/// inverted (and the same amounts). Used for the stage-1 reversal — a `rejected` / +/// `voided` posts the stage-1 entry with its sides swapped, so every grain the +/// stage-1 moved is moved back exactly. The result is balanced iff the input was +/// (one DR ↔ one CR of equal amount), preserved by the inversion. +fn invert_plan(plan: &RefundLegPlan) -> RefundLegPlan { + let legs = plan + .legs + .iter() + .map(|l| PlannedLeg { + account_class: l.account_class, + side: match l.side { + Side::Debit => Side::Credit, + Side::Credit => Side::Debit, + }, + amount_minor: l.amount_minor, + revenue_stream: l.revenue_stream.clone(), + }) + .collect(); + RefundLegPlan { + legs, + // The reversal leaves the refund REVERSED; the plan's own clearing_state is + // not used for the row (the handler stamps CLEARING_STATE_REVERSED). + clearing_state: CLEARING_STATE_REVERSED, + } +} + +/// The per-payment cap deltas a refund stage-1 post applies (design §4.4 / §4.7). +/// Resolved from the request's pattern: BOTH patterns move `refunded_minor`; +/// Pattern A additionally moves `refunded_unallocated_minor`; Pattern B additionally +/// moves the per-`(payment, invoice)` `payment_allocation_refund.refunded_minor`. +/// The [`CapMode`] decides the SIGN (initiate = +amount, release = −amount); stage-2 +/// (`CapMode::None`) does not construct any movement. +struct RefundCap { + tenant: Uuid, + payment_id: String, + amount_minor: i64, + /// `Some(invoice_id)` for Pattern B (the per-invoice cap target); `None` for + /// Pattern A. + invoice_id: Option, + /// Whether to additionally move `refunded_unallocated_minor` (Pattern A only). + is_unallocated_pattern: bool, +} + +impl RefundCap { + /// Resolve the cap movement from the request's pattern. + fn for_request(req: &RefundRequest) -> Self { + Self { + tenant: req.tenant_id, + payment_id: req.payment_id.clone(), + amount_minor: req.amount_minor, + invoice_id: match req.pattern { + RefundPattern::BRestoreAr => req.invoice_id.clone(), + RefundPattern::AUnallocated => None, + }, + is_unallocated_pattern: matches!(req.pattern, RefundPattern::AUnallocated), + } + } + + /// Apply the cap movement for `mode` under the rank-1 `payment_settlement` lock + /// (the post-delta CHECKs are the over-refund backstop). [`CapMode::None`] is a + /// no-op (stage-2). For [`CapMode::Clawback`] this FIRST runs the underflow + /// PRE-CHECK under the rank-1 lock (`payment.read_settlement_for_update`): if the + /// decrement would drive `refunded_minor` below zero (an out-of-order / over PSP + /// claw-back) it returns [`UnderflowDeferred`] WITHOUT applying any movement — + /// the design defers such a claw-back rather than applying it or tripping the + /// `refunded_minor >= 0` CHECK (the CHECK stays a backstop that must never fire). + /// Other errors are repo-level; the sidecar refines them. + async fn apply( + &self, + payment: &PaymentRepo, + txn: &DbTx<'_>, + scope: &AccessScope, + mode: CapMode, + ) -> Result<(), CapApplyError> { + let signed = match mode { + CapMode::Initiate => self.amount_minor, + // Both DECREMENT by the amount: `Release` backs out a matching stage-1 + // (cannot underflow); `Clawback` nets the origin money-out down to the + // NET refunded AFTER the underflow pre-check below has cleared it. + CapMode::Release | CapMode::Clawback => -self.amount_minor, + CapMode::None => return Ok(()), + }; + + // Claw-back underflow PRE-CHECK (Group E, design §4.4). Read the current + // counters UNDER THE RANK-1 LOCK the decrement is about to take, and decide + // `current - amount < 0` BEFORE applying anything. If it would underflow, + // DEFER (return `UnderflowDeferred`) — do NOT apply the decrement and do NOT + // let the `refunded_minor >= 0` CHECK hard-fail. We check `refunded_minor` + // (the total money-out counter both patterns decrement) and, for the + // additional counters, their own current values, so a Pattern-A + // `refunded_unallocated` / Pattern-B `payment_allocation_refund` claw-back + // also defers rather than tripping their nonneg CHECKs. The settlement MUST + // exist for a claw-back (nothing to claw back otherwise) — absent ⇒ a repo + // Db error (an upstream contract violation), surfaced as infra. + if mode == CapMode::Clawback { + let settlement = payment + .read_settlement_for_update(txn, scope, self.tenant, &self.payment_id) + .await + .map_err(CapApplyError::Repo)? + .ok_or_else(|| { + CapApplyError::Repo(RepoError::Db(format!( + "claw-back references payment {} with no settlement row", + self.payment_id + ))) + })?; + // Would the total money-out decrement underflow? (`refunded_minor` is the + // counter the matching outbound refund stage-1 raised; the claw-back + // arriving first / over-clawing leaves it too small.) + if settlement.refunded_minor < self.amount_minor { + return Err(CapApplyError::UnderflowDeferred); + } + // The additional per-pattern counters must also have room (defensive — + // they move in lockstep with `refunded_minor` in the happy path, but an + // out-of-order Pattern-A/B claw-back could underflow one of them first). + if self.is_unallocated_pattern + && settlement.refunded_unallocated_minor < self.amount_minor + { + return Err(CapApplyError::UnderflowDeferred); + } + if let Some(invoice_id) = &self.invoice_id { + let par_refunded = payment + .read_allocation_refund_refunded_for_update( + txn, + scope, + self.tenant, + &self.payment_id, + invoice_id, + ) + .await + .map_err(CapApplyError::Repo)?; + if par_refunded < self.amount_minor { + return Err(CapApplyError::UnderflowDeferred); + } + } + } + + // 1. Total money-out cap (both patterns): refunded + clawed_back <= settled. + // Rank-1 `payment_settlement` lock — taken first. + PaymentRepo::add_refunded(txn, scope, self.tenant, &self.payment_id, signed) + .await + .map_err(CapApplyError::Repo)?; + + // 2a. Pattern A: spendable-headroom cap (allocated + refunded_unallocated <= + // settled) — refunded on-account cash can no longer be allocated. Same + // `payment_settlement` row (rank-1). + if self.is_unallocated_pattern { + PaymentRepo::add_refunded_unallocated( + txn, + scope, + self.tenant, + &self.payment_id, + signed, + ) + .await + .map_err(CapApplyError::Repo)?; + } + + // 2b. Pattern B: per-`(payment, invoice)` cap (refunded <= allocated) on the + // `payment_allocation_refund` row. + if let Some(invoice_id) = &self.invoice_id { + PaymentRepo::add_allocation_refund_refunded( + txn, + scope, + self.tenant, + &self.payment_id, + invoice_id, + signed, + ) + .await + .map_err(CapApplyError::Repo)?; + } + Ok(()) + } +} + +/// The outcome of [`RefundCap::apply`] when it fails. A `Repo` error is a cap CHECK +/// violation or an infra fault (the sidecar refines it to the over-refund domain +/// errors); `UnderflowDeferred` is the Group-E signal that a CLAW-BACK's money-out +/// decrement would drive a counter below zero (out-of-order / over PSP claw-back) — +/// the sidecar turns it into [`DomainError::RefundClawbackDeferred`] so the post +/// rolls back and the handler defers the request (never a hard-fail on the nonneg +/// CHECK). +enum CapApplyError { + /// A cap CHECK violation or an infra fault from a counter write / read. + Repo(RepoError), + /// A claw-back decrement would underflow a money-out counter — DEFER, do not + /// apply (Group E). + UnderflowDeferred, +} + +/// Build the PII-clean before/after payload for an `unknown_final` disposition's +/// secured-audit record (design §2.3 — ids + amounts + enum codes only, NO names +/// / free text). The "before" is the stuck open clearing — its LIVE +/// `clearing_state` + open amount, read from the stage-1 `refund` row by the caller +/// (Z5-4), NOT a hardcoded `PENDING` / the request amount; the "after" is the +/// SUSPENSE park that drains it. The caller (`post_unknown_final`) asserts this is +/// PII-clean before it reaches the sink. +fn unknown_final_audit_payload( + req: &RefundRequest, + before_clearing_state: &str, + open_minor: i64, +) -> serde_json::Value { + serde_json::json!({ + "disposition": "REFUND_UNKNOWN_FINAL", + "refund_id": req.refund_id, + "psp_refund_id": req.psp_refund_id, + "payment_id": req.payment_id, + "pattern": req.pattern.as_str(), + "currency": req.currency, + "before": { + // The REAL open clearing amount + the REAL stage-1 clearing_state (Z5-4), + // read live from the stage-1 refund row — not assumed. + "refund_clearing_open_minor": open_minor, + "clearing_state": before_clearing_state, + }, + "after": { + "refund_clearing_open_minor": 0, + "park_account_class": UNKNOWN_FINAL_PARK_CLASS.as_str(), + "parked_minor": open_minor, + "clearing_state": CLEARING_STATE_SETTLED, + }, + }) +} + +/// The in-transaction [`PostSidecar`] for the `unknown_final` disposition (Group +/// F): in the post txn it **(1)** persists the `refund` row (`clearing_state` = +/// SETTLED) and **(2)** appends one `secured_audit_record` via the +/// [`SecuredAuditSink`] — both atomic with the loss-clearing journal entry (or +/// rolled back with it). Order is record-then-audit; either failure rolls the +/// whole disposition back. The sink is the [`NoopSecuredAuditSink`] until Slice 6 +/// merges (it logs + never fails), so today the audit step is observably a no-op +/// but the call site is the real Slice-6 contract. +struct UnknownFinalSidecar { + /// The `refund` row to persist (`clearing_state` = SETTLED, the terminal + /// park-to-SUSPENSE resolution; its own `(tenant, psp_refund_id, unknown_final)` + /// grain). + refund_row: NewRefund, + /// The secured-audit sink (Slice 6 port). No-op until merge. + audit: Arc, + /// The acting subject id (the approver/operator) for the audit `actor_ref`; + /// `None` for a system-initiated disposition. + actor_ref: Option, + /// The PII-clean before/after audit payload. + before_after: serde_json::Value, + /// Owning tenant. + tenant: Uuid, + /// The event publisher: `billing.ledger.refund.recorded` (phase = + /// `unknown_final`, K-1) is published IN this post txn (Group G). + publisher: Arc, + /// The security context for the in-txn outbox publish. + ctx: SecurityContext, +} + +#[async_trait::async_trait] +impl PostSidecar for UnknownFinalSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + // 1. Persist the refund record row (surrogate PK + natural UNIQUE on + // (tenant, psp_refund_id, unknown_final)). A replay is short-circuited + // by the engine claim BEFORE the sidecar, so a collision rolls back. + AdjustmentRepo::insert_refund(txn, scope, &self.refund_row) + .await + .map_err(|e| DomainError::Internal(format!("insert refund (unknown_final): {e}")))?; + + // 2. Append the secured-audit record IN THE SAME txn (atomic with the loss + // entry, design §4.4 / K-1). `ManualAdjustment` event type + the + // REFUND_UNKNOWN_FINAL reason; the posted ENTRY id is the correlation id + // (links the audit record to the loss-clearing entry — `PostedFacts` + // surfaces the entry id, not the header's correlation uuid). `retain_until + // = None` ⇒ the store's default retention. Until Slice 6 merges this is + // the no-op sink (never fails). + self.audit + .append( + txn, + scope, + self.tenant, + AuditEventType::ManualAdjustment, + self.actor_ref.as_deref(), + Some(REASON_REFUND_UNKNOWN_FINAL), + &self.before_after, + Some(posted.entry_id), + None, + ) + .await + .map_err(|e| { + DomainError::Internal(format!("secured-audit append (unknown_final): {e}")) + })?; + + // 3. Publish `billing.ledger.refund.recorded` (phase = unknown_final, K-1 + // "incl unknown_final") into the SAME post txn — atomic with the loss + // entry + the secured-audit record (Group G). + self.publisher + .publish_refund_recorded( + &self.ctx, + txn, + refund_recorded_event(&self.refund_row, posted), + ) + .await + .map_err(|e| { + DomainError::Internal(format!("publish refund_recorded (unknown_final): {e}")) + })?; + Ok(()) + } +} + +/// The in-transaction [`PostSidecar`] for a refund stage: runs AFTER balance +/// projection and BEFORE the dedup finalize (fresh-claim path only — a replay +/// returns before the sidecar), so its writes commit atomically with the journal +/// entry or roll back with it (design §4.4 / §4.7). +/// +/// Order (the §4.7 lock order): **(1)** the per-payment money-out CAPS under the +/// rank-1 `payment_settlement` lock (taken before the rank-N record write) — a +/// stage-1 initiation INCREMENTS them ([`CapMode::Initiate`]); a stage-1 reversal +/// DECREMENTS them ([`CapMode::Release`]); a stage-2 `confirmed` leaves them +/// untouched ([`CapMode::None`]). Then **(2)** the `refund` record row. A cap CHECK +/// violation surfaces as [`RepoError::MoneyOutCapExceeded`], refined here to +/// [`DomainError::RefundExceedsSettled`] / [`DomainError::RefundExceedsAllocated`]. +pub struct RefundPostSidecar { + /// Whether (and how) to move the caps for this phase. + cap_mode: CapMode, + /// The pattern-resolved cap deltas to move. + cap: RefundCap, + /// The `refund` record to persist (surrogate `(tenant, refund_id)` PK + natural + /// `(tenant, psp_refund_id, phase)` UNIQUE). + refund_row: NewRefund, + /// The payment counter repo — used by [`RefundCap::apply`] for the CLAW-BACK + /// underflow pre-read under the rank-1 lock (Group E). A cheap clone of the + /// handler's repo (it wraps the provider Arc). + payment: PaymentRepo, + /// The event publisher: `billing.ledger.refund.recorded` is published IN this + /// post txn (the transactional outbox, Group G) so it commits atomically with + /// the refund entry + caps, or rolls back with them. Mirrors + /// [`CreditNotePostSidecar`](super::credit_note_service::CreditNotePostSidecar). + publisher: Arc, + /// The security context for the in-txn outbox publish (cloned by the handler). + ctx: SecurityContext, +} + +#[async_trait::async_trait] +impl PostSidecar for RefundPostSidecar { + /// Refund / claw-back runs its rank-1 cap / underflow CHECK BEFORE projection + /// (see [`PostSidecar::run_before_projection`]). Without this a stage-1 + /// over-settled forward draws `UNALLOCATED` negative, and an out-of-order + /// claw-back draws `REFUND_CLEARING` negative, tripping the projector's + /// no-negative guard with a raw `NegativeBalance` BEFORE `cap.apply` can + /// refine it to `RefundExceedsSettled` / `RefundClawbackDeferred`. + fn run_before_projection(&self) -> bool { + true + } + + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + // 1. Move the per-payment money-out caps under the rank-1 payment_settlement + // lock, BEFORE the rank-N record write (design §4.7). Stage-1 initiation + // INCREMENTS the cap (outbound — CHECKs enforce it before the cash leaves + // on stage-2); a stage-1 reversal / claw-back DECREMENTS it; stage-2 is a + // no-op. For a CLAW-BACK the apply runs the underflow PRE-CHECK under the + // same lock and returns `UnderflowDeferred` if the decrement would go + // negative — mapped here to `RefundClawbackDeferred`, which rolls the + // whole post back (the handler then defers the request to the queue, never + // hard-failing on the nonneg CHECK). A cap CHECK violation (over-refund) + // is refined to RefundExceedsSettled / RefundExceedsAllocated. + self.cap + .apply(&self.payment, txn, scope, self.cap_mode) + .await + .map_err(map_cap_apply_err)?; + + // 2. Persist the refund record row (surrogate PK + natural UNIQUE). A + // duplicate (replay) is short-circuited by the + // (tenant, REFUND, psp_refund_id:phase) idempotency claim BEFORE the + // sidecar, so an unexpected collision rolls the post back. + AdjustmentRepo::insert_refund(txn, scope, &self.refund_row) + .await + .map_err(|e| DomainError::Internal(format!("insert refund: {e}")))?; + + // 3. Publish `billing.ledger.refund.recorded` into the SAME post txn + // (transactional outbox, Group G): the event commits atomically with the + // entry + caps + record, or a publish failure rolls the whole post back. + // Never on replay (a replay returns before the sidecar). Ids + amount + + // enum codes only (no PII). The clearing_state stamped on the `refund` + // row is the event's clearing_state. + self.publisher + .publish_refund_recorded( + &self.ctx, + txn, + refund_recorded_event(&self.refund_row, posted), + ) + .await + .map_err(|e| DomainError::Internal(format!("publish refund_recorded: {e}")))?; + Ok(()) + } +} + +/// Build the `billing.ledger.refund.recorded` event payload from the `refund` row +/// being persisted + the posted entry facts (the in-txn `PostedFacts` surfaces the +/// entry id). PII-free by construction (ids + enum codes + amount only). Shared by +/// the forward/reversal sidecar ([`RefundPostSidecar`]) and the `unknown_final` +/// disposition sidecar ([`UnknownFinalSidecar`]). +fn refund_recorded_event(row: &NewRefund, posted: &PostedFacts) -> RefundRecorded { + RefundRecorded { + tenant_id: row.tenant_id, + refund_id: row.refund_id.clone(), + psp_refund_id: row.psp_refund_id.clone(), + entry_id: posted.entry_id, + phase: row.phase.clone(), + pattern: row.pattern.clone(), + payment_id: row.payment_id.clone(), + amount_minor: row.amount_minor, + currency: row.currency.clone(), + clearing_state: row.clearing_state.clone(), + } +} + +/// Map a [`CapApplyError`] into the sidecar's [`DomainError`]. An +/// `UnderflowDeferred` (a claw-back whose decrement would underflow) becomes +/// [`DomainError::RefundClawbackDeferred`] — rolling the post back so the handler +/// can DEFER the claw-back to the queue (Group E, never hard-fail). A `Repo` error +/// is refined by [`map_refund_cap_err`] (over-refund cap CHECK → the +/// `RefundExceeds*` domain errors; everything else → infra). +fn map_cap_apply_err(e: CapApplyError) -> DomainError { + match e { + CapApplyError::UnderflowDeferred => DomainError::RefundClawbackDeferred( + "claw-back money-out decrement would underflow (out-of-order / over-claw); deferred" + .to_owned(), + ), + CapApplyError::Repo(repo) => map_refund_cap_err(repo), + } +} + +/// Map a refund cap-counter [`RepoError`] into the sidecar's [`DomainError`]: a cap +/// CHECK violation becomes [`DomainError::RefundExceedsSettled`] (the +/// `payment_settlement` total-money-out / spendable-headroom caps) or, when the +/// violated constraint is the per-`(payment, invoice)` `chk_par_*` cap, +/// [`DomainError::RefundExceedsAllocated`]. Every other repo failure is an +/// infrastructure fault that rolls the post back. +/// +/// Both cap families surface as the same [`RepoError::MoneyOutCapExceeded`] (the +/// repo's `is_check_violation` matches both the `chk_payment_settlement_*` and +/// `chk_par_*` prefixes), so the message carries the discriminating constraint +/// context the repo stamped; a `chk_par_` / "allocated" marker routes to +/// `RefundExceedsAllocated`, everything else to `RefundExceedsSettled` (the +/// settled-amount caps are the common case + the safe default — both are +/// over-refund rejects on the `InvalidArgument` category). +fn map_refund_cap_err(e: RepoError) -> DomainError { + match e { + RepoError::MoneyOutCapExceeded(m) => { + // The per-invoice cap is the `payment_allocation_refund` row + // (`chk_par_refunded_le_allocated`); its repo context stamps the + // "allocation_refund" marker. Everything else is a settlement cap. + if m.contains("allocation_refund") || m.contains("chk_par_") { + DomainError::RefundExceedsAllocated(m) + } else { + DomainError::RefundExceedsSettled(m) + } + } + other => DomainError::Internal(format!("refund cap sidecar: {other}")), + } +} + +#[cfg(test)] +#[path = "refund_service_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/adjustment/refund_service_tests.rs b/gears/bss/ledger/ledger/src/infra/adjustment/refund_service_tests.rs new file mode 100644 index 000000000..845b2db3d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/adjustment/refund_service_tests.rs @@ -0,0 +1,198 @@ +//! Pure unit tests for [`RefundHandler`](super::RefundHandler)'s in-module +//! helpers: the stage-1 reversal plan inversion (`invert_plan`), the per-pattern +//! cap-target resolution (`RefundCap::for_request`), the `unknown_final` +//! loss-clearing plan, and the PII-clean `unknown_final` secured-audit payload. +//! Extracted to a sibling file (dylint DE1101 — no inline `#[cfg(test)] mod`). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use bss_ledger_sdk::AccountClass; + +use super::*; +use crate::domain::adjustment::refund::build_refund_legs; + +fn stage1_req(pattern: RefundPattern, amount: i64) -> RefundRequest { + let invoice_id = match pattern { + RefundPattern::BRestoreAr => Some("inv-1".to_owned()), + RefundPattern::AUnallocated => None, + }; + RefundRequest { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + refund_id: "rf-1".to_owned(), + psp_refund_id: "psp-1".to_owned(), + phase: RefundPhase::Initiated, + pattern, + payment_id: "pay-1".to_owned(), + invoice_id, + currency: "USD".to_owned(), + amount_minor: amount, + two_stage: true, + relates_to_refund_id: None, + direction: RefundDirection::Outbound, + } +} + +fn sum_side(plan: &RefundLegPlan, side: Side) -> i64 { + plan.legs + .iter() + .filter(|l| l.side == side) + .map(|l| l.amount_minor) + .sum() +} + +/// The stage-1 reversal is the STRICT line-negation of the stage-1 plan: same +/// classes + amounts, every side flipped. Asserted for both patterns. +#[test] +fn invert_plan_flips_sides_and_stays_balanced() { + for pattern in [RefundPattern::AUnallocated, RefundPattern::BRestoreAr] { + let stage1 = build_refund_legs(&stage1_req(pattern, 500)).unwrap(); + let reversed = invert_plan(&stage1); + + // Same number of legs, same classes + amounts. + assert_eq!(reversed.legs.len(), stage1.legs.len()); + for (orig, rev) in stage1.legs.iter().zip(reversed.legs.iter()) { + assert_eq!(rev.account_class, orig.account_class, "class preserved"); + assert_eq!(rev.amount_minor, orig.amount_minor, "amount preserved"); + assert_ne!(rev.side, orig.side, "side flipped"); + } + + // Stage-1 was DR pattern.debit · CR REFUND_CLEARING; the reversal is + // DR REFUND_CLEARING · CR pattern.debit (drains clearing, restores the + // drawn-down UNALLOCATED(A) / AR(B)). + let debit_class = reversed + .legs + .iter() + .find(|l| l.side == Side::Debit) + .unwrap() + .account_class; + let credit_class = reversed + .legs + .iter() + .find(|l| l.side == Side::Credit) + .unwrap() + .account_class; + assert_eq!(debit_class, AccountClass::RefundClearing); + assert_eq!(credit_class, pattern.debit_class()); + + // Balanced (Σ DR == Σ CR), and the reversal row stamps REVERSED. + assert_eq!( + sum_side(&reversed, Side::Debit), + sum_side(&reversed, Side::Credit) + ); + assert_eq!(reversed.clearing_state, CLEARING_STATE_REVERSED); + } +} + +/// The cap movement is resolved from the pattern: both bump `refunded_minor`; +/// Pattern A additionally moves `refunded_unallocated_minor`; Pattern B +/// additionally targets the per-`(payment, invoice)` counter. +#[test] +fn refund_cap_targets_match_pattern() { + let a = RefundCap::for_request(&stage1_req(RefundPattern::AUnallocated, 100)); + assert!( + a.is_unallocated_pattern, + "Pattern A moves refunded_unallocated" + ); + assert!( + a.invoice_id.is_none(), + "Pattern A has no per-invoice target" + ); + + let b = RefundCap::for_request(&stage1_req(RefundPattern::BRestoreAr, 100)); + assert!( + !b.is_unallocated_pattern, + "Pattern B does not move refunded_unallocated" + ); + assert_eq!( + b.invoice_id.as_deref(), + Some("inv-1"), + "Pattern B targets the per-(payment, invoice) counter" + ); +} + +/// The `unknown_final` disposition PARKS the stuck `REFUND_CLEARING` on SUSPENSE: +/// a BALANCED two-leg plan `DR REFUND_CLEARING (open amount) · CR SUSPENSE`, +/// draining the guarded clearing balance to zero (the DR cancels the stage-1 +/// `CR REFUND_CLEARING`). This mirrors the +/// plan `post_unknown_final` builds inline (kept in lockstep here so the +/// park-account choice + balance are unit-asserted without a DB). +#[test] +fn unknown_final_park_clearing_plan_is_balanced_and_drains_clearing() { + let amount = 750; + let plan = RefundLegPlan { + legs: vec![ + PlannedLeg { + account_class: AccountClass::RefundClearing, + side: Side::Debit, + amount_minor: amount, + revenue_stream: None, + }, + PlannedLeg { + account_class: UNKNOWN_FINAL_PARK_CLASS, + side: Side::Credit, + amount_minor: amount, + revenue_stream: None, + }, + ], + clearing_state: CLEARING_STATE_SETTLED, + }; + // Balanced (Σ DR == Σ CR) and exactly two legs. + assert_eq!(plan.legs.len(), 2); + assert_eq!(sum_side(&plan, Side::Debit), sum_side(&plan, Side::Credit)); + // The DR DRAINS the guarded REFUND_CLEARING (toward zero); the CR PARKS the + // amount on SUSPENSE pending reconciliation (not a premature loss/gain). + let dr = plan.legs.iter().find(|l| l.side == Side::Debit).unwrap(); + let cr = plan.legs.iter().find(|l| l.side == Side::Credit).unwrap(); + assert_eq!(dr.account_class, AccountClass::RefundClearing); + assert_eq!(cr.account_class, AccountClass::Suspense); + assert_eq!(cr.account_class, UNKNOWN_FINAL_PARK_CLASS); + // The disposition drains REFUND_CLEARING off the live account → SETTLED. + assert_eq!(plan.clearing_state, CLEARING_STATE_SETTLED); +} + +/// The `unknown_final` secured-audit before/after payload is PII-clean (ids + +/// amounts + enum codes only — no names / emails / free text) and carries the +/// park arithmetic (open → 0, parked to SUSPENSE). The reason code is the closed +/// `REFUND_UNKNOWN_FINAL` literal. +#[test] +fn unknown_final_audit_payload_is_pii_clean_and_shaped() { + let mut req = stage1_req(RefundPattern::AUnallocated, 900); + req.phase = RefundPhase::UnknownFinal; + // Z5-4: the before-image now carries the LIVE stage-1 clearing_state + open + // amount (read from the stage-1 row by the handler) rather than a hardcoded + // PENDING / the request amount. Here the stuck stage-1 is PENDING with 900 open. + let payload = unknown_final_audit_payload( + &req, + crate::domain::adjustment::refund::CLEARING_STATE_PENDING, + 900, + ); + + assert_eq!(payload["disposition"], "REFUND_UNKNOWN_FINAL"); + assert_eq!(payload["before"]["clearing_state"], "PENDING"); + assert_eq!(payload["before"]["refund_clearing_open_minor"], 900); + assert_eq!(payload["after"]["refund_clearing_open_minor"], 0); + assert_eq!(payload["after"]["parked_minor"], 900); + assert_eq!(payload["after"]["park_account_class"], "SUSPENSE"); + assert_eq!(payload["after"]["clearing_state"], CLEARING_STATE_SETTLED); + assert_eq!(REASON_REFUND_UNKNOWN_FINAL, "REFUND_UNKNOWN_FINAL"); + + // No PII: the serialized payload's keys/values are ids, amounts, and enum + // codes. Assert the top-level key set is exactly the expected ids/amounts. + let obj = payload.as_object().unwrap(); + let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + "after", + "before", + "currency", + "disposition", + "pattern", + "payment_id", + "psp_refund_id", + "refund_id", + ] + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/annotation.rs b/gears/bss/ledger/ledger/src/infra/annotation.rs new file mode 100644 index 000000000..e5e4b8104 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/annotation.rs @@ -0,0 +1,477 @@ +//! `AnnotationService` — the typed controlled non-financial annotation overlay +//! (Slice 6 Phase 2 Group 2B, Variant C remodel). The ONLY mutating path over an +//! otherwise append-only ledger: it sets the CURRENT `description` note on a +//! journal entry / line, recording every change as an append-only +//! `metadata-change` secured-audit record — in ONE `SERIALIZABLE` transaction. +//! It NEVER touches `journal_entry` / `journal_line` (financial truth stays +//! byte-identical), and authoritative workflow state (dispute, reconciliation) +//! stays owned by its own resource — this overlay carries only the free-text +//! note. +//! +//! ## Pre-write screen (before any write) +//! +//! The `description` is screened for raw customer PII via [`PiiMinimizer`]: the +//! secured-audit record is append-only, so a value carrying an email / phone / +//! payment number (or a prohibited key) is rejected up front +//! ([`DomainError::PiiInMetadataValue`], HTTP 400) and never sealed into the +//! chain. The target entry / line must also exist in-tenant (a tenant-scoped, +//! race-free read over the append-only journal) so a change is never logged +//! against a dangling id. + +use sea_orm::sea_query::OnConflict; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{AccessScope, DbConn, DbTx, SecureEntityExt, SecureInsertExt, TxConfig}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use std::sync::Arc; + +use crate::domain::error::DomainError; +use crate::domain::ports::metrics::{LedgerMetricsPort, NoopLedgerMetrics}; +use crate::infra::audit::event_type::AuditEventType; +use crate::infra::audit::store::SecuredAuditStore; +use crate::infra::pii::PiiMinimizer; +use crate::infra::posting::service::infra; +use crate::infra::storage::entity::{entry_annotation, journal_entry, journal_line}; + +/// `'ENTRY'` / `'LINE'` literals for the `target_kind` column (CHECK-constrained +/// in migration 000015). +const KIND_ENTRY: &str = "ENTRY"; +const KIND_LINE: &str = "LINE"; + +/// The `attribute` label kept on the §9 metric + the audit record for +/// continuity with the metric token (`ledger_metadata_change_total{attribute}`). +const ATTRIBUTE_DESCRIPTION: &str = "description"; + +/// The typed annotation overlay service. Holds the append-only +/// [`SecuredAuditStore`] (stateless); every write runs in its own +/// `SERIALIZABLE` transaction. +#[derive(Clone)] +pub struct AnnotationService { + audit: SecuredAuditStore, + metrics: Arc, +} + +impl Default for AnnotationService { + fn default() -> Self { + Self::new() + } +} + +impl AnnotationService { + #[must_use] + pub fn new() -> Self { + Self { + audit: SecuredAuditStore::new(), + metrics: Arc::new(NoopLedgerMetrics), + } + } + + /// Bind the §9 metrics sink (`ledger_metadata_change_total{attribute}` is + /// emitted on each committed change). Defaults to no-op until wired. + #[must_use] + pub fn with_metrics(mut self, metrics: Arc) -> Self { + self.metrics = metrics; + self + } + + /// Reject a `description` that carries raw customer PII BEFORE any write + /// (fail fast). The secured-audit record is append-only, so PII must be + /// screened out up front — once a record is sealed into the chain it can + /// never be redacted. Catches PII both as object KEYS and embedded in + /// free-text string VALUES (see [`PiiMinimizer`]). + fn screen_description_for_pii(description: &serde_json::Value) -> Result<(), DomainError> { + let mut categories = PiiMinimizer::prohibited_fields(description); + for category in PiiMinimizer::prohibited_in_values(description) { + if !categories.contains(&category) { + categories.push(category); + } + } + if categories.is_empty() { + return Ok(()); + } + Err(DomainError::PiiInMetadataValue(format!( + "annotation carries prohibited PII categories {categories:?}; the audit \ + chain is append-only — remove the PII before retrying" + ))) + } + + /// Assert the `target_id` is a real journal object in `tenant` BEFORE any + /// write (fail fast). Journal rows are append-only (never deleted), so a + /// pre-transaction read is race-free; this stops a typo / dangling + /// `target_id` from logging an annotation + audit record that point at + /// nothing. The lookup is tenant-scoped (`SecureORM`), so it also cannot + /// probe another tenant's ids. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when no entry / line matches; + /// [`DomainError::Internal`] on a storage / scope failure. + async fn assert_target_exists( + db: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + target_id: Uuid, + target_period_id: &str, + target: AnnotationTarget, + ) -> Result<(), DomainError> { + let conn: DbConn<'_> = db + .conn() + .map_err(|e| DomainError::Internal(format!("annotation target-check conn: {e}")))?; + let exists = match target { + AnnotationTarget::Entry => journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant)) + .add(journal_entry::Column::PeriodId.eq(target_period_id.to_owned())) + .add(journal_entry::Column::EntryId.eq(target_id)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("annotation target read: {e}")))? + .is_some(), + AnnotationTarget::Line => journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_line::Column::TenantId.eq(tenant)) + .add(journal_line::Column::PeriodId.eq(target_period_id.to_owned())) + .add(journal_line::Column::LineId.eq(target_id)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("annotation target read: {e}")))? + .is_some(), + }; + if exists { + Ok(()) + } else { + Err(DomainError::InvalidRequest(format!( + "annotation target {} {target_id} not found in tenant period {target_period_id}", + target.as_str() + ))) + } + } + + /// Set the controlled `description` annotation on a journal entry / line. + /// + /// Pre-write screen (no writes): PII screen on `description`; the target must + /// exist in-tenant. Then ONE `SERIALIZABLE` transaction reads the current + /// annotation row (for the before/after audit payload), UPSERTs the typed + /// `entry_annotation` row (current-state), and appends a `metadata-change` + /// secured-audit record onto the tenant's audit chain — all in the SAME + /// transaction. NO journal table is touched. + /// + /// # Errors + /// [`DomainError::PiiInMetadataValue`] (description carries raw customer PII) + /// / [`DomainError::InvalidRequest`] (`target_id` does not exist) — both + /// pre-write, no writes; [`DomainError::Internal`] on a storage / scope / + /// audit failure (rolls the transaction back). + #[allow( + clippy::too_many_arguments, + reason = "one annotation's full identity (target/period/kind) + description + actor/reason over the caller's ctx/scope" + )] + pub async fn set( + &self, + db: &DBProvider, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + target_id: Uuid, + target_period_id: String, + target: AnnotationTarget, + description: Option, + actor_ref: String, + reason: Option, + correlation_id: Option, + ) -> Result<(), DomainError> { + // --- PRE-WRITE screen (fail fast, no writes) --- + let description_json = description.as_ref().map_or(serde_json::Value::Null, |d| { + serde_json::Value::String(d.clone()) + }); + Self::screen_description_for_pii(&description_json)?; + Self::assert_target_exists(db, scope, tenant, target_id, &target_period_id, target).await?; + + // `ctx` authorized the call at the REST seam (the PEP gate ran there); + // the in-txn writes are tenant-scoped by `scope`, and the audit append + // generates its own ids, so the txn body does not re-thread `ctx`. + let _ = ctx; + let audit = self.audit.clone(); + let scope = scope.clone(); + let kind = target.as_str().to_owned(); + + // --- TRANSACTION (SERIALIZABLE + retry) --- + let result: Result<(), DbError> = db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let audit = audit.clone(); + let scope = scope.clone(); + let kind = kind.clone(); + let description = description.clone(); + let description_json = description_json.clone(); + let actor_ref = actor_ref.clone(); + let reason = reason.clone(); + let target_period_id = target_period_id.clone(); + Box::pin(async move { + set_in_txn( + txn, + &audit, + &scope, + tenant, + target_id, + &target_period_id, + &kind, + description, + &description_json, + &actor_ref, + reason.as_deref(), + correlation_id, + ) + .await + }) + }) + .await; + + let mapped = result.map_err(|e| DomainError::Internal(format!("annotation set txn: {e}"))); + if mapped.is_ok() { + self.metrics.metadata_change(ATTRIBUTE_DESCRIPTION); + } + mapped + } +} + +/// In-transaction body: read the current annotation, UPSERT the typed row, +/// append the secured-audit record. All under the caller's `SERIALIZABLE` +/// transaction. +#[allow( + clippy::too_many_arguments, + reason = "the full annotation tuple threaded into one txn body" +)] +async fn set_in_txn( + txn: &DbTx<'_>, + audit: &SecuredAuditStore, + scope: &AccessScope, + tenant: Uuid, + target_id: Uuid, + target_period_id: &str, + target_kind: &str, + description: Option, + description_json: &serde_json::Value, + actor_ref: &str, + reason: Option<&str>, + correlation_id: Option, +) -> Result<(), DbError> { + // 1. Read the current annotation (for the before/after audit payload). + let current = entry_annotation::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(entry_annotation::Column::TenantId.eq(tenant)) + .add(entry_annotation::Column::TargetId.eq(target_id)) + .add(entry_annotation::Column::TargetKind.eq(target_kind.to_owned())), + ) + .one(txn) + .await + .map_err(|e| infra(format!("annotation read current: {e}")))?; + + let before = current + .and_then(|row| row.description) + .map_or(serde_json::Value::Null, serde_json::Value::String); + let before_after = serde_json::json!({ "before": before, "after": description_json }); + + // 2. UPSERT the typed current-state row. + let am = entry_annotation::ActiveModel { + tenant_id: Set(tenant), + target_id: Set(target_id), + target_kind: Set(target_kind.to_owned()), + target_period_id: Set(target_period_id.to_owned()), + description: Set(description), + actor_ref: Set(actor_ref.to_owned()), + updated_at: Set(chrono::Utc::now()), + }; + let on_conflict = OnConflict::columns([ + entry_annotation::Column::TenantId, + entry_annotation::Column::TargetId, + entry_annotation::Column::TargetKind, + ]) + .update_columns([ + entry_annotation::Column::Description, + entry_annotation::Column::ActorRef, + entry_annotation::Column::UpdatedAt, + entry_annotation::Column::TargetPeriodId, + ]) + .to_owned(); + + entry_annotation::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| infra(format!("annotation upsert scope: {e}")))? + .on_conflict_raw(on_conflict) + .exec(txn) + .await + .map_err(|e| infra(format!("annotation upsert: {e}")))?; + + // 3. Append the secured-audit record in the SAME transaction. + audit + .append( + txn, + scope, + tenant, + AuditEventType::MetadataChange, + Some(actor_ref), + reason, + &before_after, + correlation_id, + None, + ) + .await?; + + Ok(()) +} + +/// Write port the `PATCH …/annotation` handler records changes through. +/// Abstracts the single annotation write (screen → upsert + audit in one txn) +/// so the journal-entry router tests can stub the path without a database. The +/// production implementation is [`LedgerAnnotationWriter`]. +#[async_trait::async_trait] +pub trait AnnotationWriter: Send + Sync { + /// Set one controlled annotation (see [`AnnotationService::set`]). + /// + /// # Errors + /// [`DomainError::PiiInMetadataValue`] on the pre-write PII screen; + /// [`DomainError::InvalidRequest`] on a dangling target; + /// [`DomainError::Internal`] on a storage fault. + #[allow( + clippy::too_many_arguments, + reason = "one annotation's full identity + description + actor/reason over the caller's ctx/scope" + )] + async fn set( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + target_id: Uuid, + target_period_id: String, + target: AnnotationTarget, + description: Option, + actor_ref: String, + reason: Option, + correlation_id: Option, + ) -> Result<(), DomainError>; +} + +/// The production [`AnnotationWriter`]: a stateless [`AnnotationService`] bound +/// to one [`DBProvider`]. +#[derive(Clone)] +pub struct LedgerAnnotationWriter { + service: AnnotationService, + db: DBProvider, +} + +impl LedgerAnnotationWriter { + /// Build the writer over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { + service: AnnotationService::new(), + db, + } + } + + /// Bind the §9 metrics sink onto the wrapped service. + #[must_use] + pub fn with_metrics(mut self, metrics: Arc) -> Self { + self.service = self.service.with_metrics(metrics); + self + } +} + +#[async_trait::async_trait] +impl AnnotationWriter for LedgerAnnotationWriter { + #[allow( + clippy::too_many_arguments, + reason = "delegates the full annotation tuple to the inherent service method" + )] + async fn set( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + target_id: Uuid, + target_period_id: String, + target: AnnotationTarget, + description: Option, + actor_ref: String, + reason: Option, + correlation_id: Option, + ) -> Result<(), DomainError> { + self.service + .set( + &self.db, + ctx, + scope, + tenant, + target_id, + target_period_id, + target, + description, + actor_ref, + reason, + correlation_id, + ) + .await + } +} + +/// The kind of journal object an annotation targets (`ENTRY` / `LINE`). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum AnnotationTarget { + /// The annotation targets a journal entry header (`target_id` = `entry_id`). + Entry, + /// The annotation targets a journal line (`target_id` = `line_id`). + Line, +} + +impl AnnotationTarget { + /// Stable wire token (matches the `target_kind` CHECK in migration 000015). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Entry => KIND_ENTRY, + Self::Line => KIND_LINE, + } + } + + /// Parse a `target_kind` literal (`ENTRY` / `LINE`); any other literal is an + /// invalid request. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `s` is neither `ENTRY` nor `LINE`. + pub fn parse(s: &str) -> Result { + match s { + KIND_ENTRY => Ok(Self::Entry), + KIND_LINE => Ok(Self::Line), + other => Err(DomainError::InvalidRequest(format!( + "invalid target_kind {other:?} (expected ENTRY or LINE)" + ))), + } + } +} + +/// Retry-extractor for the `SERIALIZABLE` annotation write: a wrapped `DbErr` so +/// a serialization failure is recognised as retryable contention (mirrors +/// `infra::posting::service::as_db_err`). +fn as_db_err(e: &DbError) -> Option<&sea_orm::DbErr> { + match e { + DbError::Sea(db_err) => Some(db_err), + _ => None, + } +} + +#[cfg(test)] +#[path = "annotation_tests.rs"] +mod annotation_tests; diff --git a/gears/bss/ledger/ledger/src/infra/annotation_tests.rs b/gears/bss/ledger/ledger/src/infra/annotation_tests.rs new file mode 100644 index 000000000..30b2d38a5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/annotation_tests.rs @@ -0,0 +1,73 @@ +//! Unit tests for the typed annotation overlay's pre-write screen + the +//! `AnnotationTarget` parse. The transactional upsert/audit path is exercised +//! end-to-end in `tests/postgres_entry_annotation.rs` (testcontainers). +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use super::{AnnotationService, AnnotationTarget}; +use crate::domain::error::DomainError; + +/// `target_kind` parses `ENTRY` / `LINE` and rejects anything else. +#[test] +fn target_parse_round_trips() { + assert_eq!( + AnnotationTarget::parse("ENTRY").unwrap(), + AnnotationTarget::Entry + ); + assert_eq!( + AnnotationTarget::parse("LINE").unwrap(), + AnnotationTarget::Line + ); + assert_eq!(AnnotationTarget::Entry.as_str(), "ENTRY"); + assert_eq!(AnnotationTarget::Line.as_str(), "LINE"); + assert!(matches!( + AnnotationTarget::parse("entry"), + Err(DomainError::InvalidRequest(_)) + )); + assert!(matches!( + AnnotationTarget::parse("BOGUS"), + Err(DomainError::InvalidRequest(_)) + )); +} + +/// `AnnotationService::new` / `default` build without a DB (stateless audit store). +#[test] +fn service_builds_stateless() { + let _ = AnnotationService::new(); + let _ = AnnotationService::default(); +} + +/// A clean free-text `description` passes the pre-write PII screen. +#[test] +fn screen_accepts_clean_description() { + let v = serde_json::json!("reconciled against export 2026-06 batch 42"); + assert!( + AnnotationService::screen_description_for_pii(&v).is_ok(), + "a benign note must pass the PII screen" + ); +} + +/// A `description` carrying an email is rejected BEFORE any write. +#[test] +fn screen_rejects_email_in_description() { + let v = serde_json::json!("refund requested by jane.doe@example.com"); + assert!( + matches!( + AnnotationService::screen_description_for_pii(&v), + Err(DomainError::PiiInMetadataValue(_)) + ), + "an email in the description must be rejected as PiiInMetadataValue" + ); +} + +/// A prohibited PII KEY (object value) is also rejected by the screen. +#[test] +fn screen_rejects_prohibited_key() { + let v = serde_json::json!({ "customer_name": "Ada Lovelace" }); + assert!( + matches!( + AnnotationService::screen_description_for_pii(&v), + Err(DomainError::PiiInMetadataValue(_)) + ), + "a prohibited PII key must be rejected as PiiInMetadataValue" + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/approval.rs b/gears/bss/ledger/ledger/src/infra/approval.rs new file mode 100644 index 000000000..7b1821bef --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/approval.rs @@ -0,0 +1,8 @@ +//! Dual-control approval orchestration (VHP-1852): the [`service::ApprovalService`] +//! lifecycle engine and the [`service::ApprovalExecutor`] port it dispatches the +//! governed mutation through on approve. Lives in `infra` (needs repo + +//! transaction access); the pure state types + threshold policy stay in +//! `crate::domain::approval`. + +pub mod executor; +pub mod service; diff --git a/gears/bss/ledger/ledger/src/infra/approval/executor.rs b/gears/bss/ledger/ledger/src/infra/approval/executor.rs new file mode 100644 index 000000000..122d9e749 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/approval/executor.rs @@ -0,0 +1,349 @@ +//! `LedgerApprovalExecutor` — the concrete [`ApprovalExecutor`] that replays an +//! approved [`ApprovalIntent`] against the real mutation surfaces (VHP-1852, +//! Group E). Reverse goes through the posting engine (read-back the original → +//! `build_reversal` → `post_reversal`); credit-grant and chargeback-loss go +//! through the `LedgerClientV1` surfaces (which re-apply their own PEP gate + +//! idempotency). Replay is idempotent: a re-approve short-circuits on the +//! foundation idempotency key, so execute-then-mark is safe. + +use std::sync::Arc; + +use bss_ledger_sdk::{ + ChangeRecognitionSchedule, ChangeSegment, CreditApplication, CreditGrant, LedgerClientV1, + RecordDisputePhase, +}; +use toolkit_db::secure::AccessScope; +use toolkit_security::SecurityContext; + +use crate::domain::adjustment::manual::ManualAdjustmentRequest; +use crate::domain::adjustment::refund::RefundRequest; +use crate::domain::approval::intent::{ApprovalIntent, BackdatedPost}; +use crate::domain::error::DomainError; +use crate::domain::invoice::builder::PostedInvoice; +use crate::domain::invoice::reversal::build_reversal; +use crate::domain::payment::chargeback::DisputePhase; +use crate::infra::adjustment::credit_note_service::CreditNoteHandler; +use crate::infra::adjustment::debit_note_service::DebitNoteHandler; +use crate::infra::adjustment::manual_adjustment_service::ManualAdjustmentHandler; +use crate::infra::adjustment::refund_service::RefundHandler; +use crate::infra::approval::service::ApprovalExecutor; +use crate::infra::invoice_post::InvoicePoster; +use crate::infra::period_close::PeriodCloseService; +use crate::infra::storage::repo::PayerStateRepo; + +/// Default advisory currency scale passed on replayed commands; the ledger +/// resolves the authoritative per-line scale from the provisioned currency config. +const ADVISORY_SCALE: u8 = 2; + +/// Dispatches an approved intent to the real mutation surface. +pub struct LedgerApprovalExecutor { + client: Arc, + posting: Arc, + payer_state: PayerStateRepo, + /// The refund orchestrator (Group D). A refund is NOT a `LedgerClientV1` + /// surface (it is a concrete handler, like the notes), so an approved refund + /// replays straight through it via [`RefundHandler::post_refund_approved`] + /// (which skips the gate). Kept the un-gated `RefundHandler` (no `approval` + /// attached) so the replay can never re-gate. + refund: Arc, + /// The governed manual-adjustment orchestrator (Group 5 / Phase 3). Like + /// [`Self::refund`], a manual adjustment is NOT a `LedgerClientV1` surface (it is a + /// concrete handler), so an approved manual adjustment replays straight through it + /// via [`ManualAdjustmentHandler::post_manual_adjustment_approved`] (which skips + /// the gate). Kept the un-gated `ManualAdjustmentHandler` (no `approval` attached) + /// so the replay can never re-gate. + manual: Arc, + /// The credit-note orchestrator (Group E / Slice 3). Like [`Self::refund`], a + /// credit note is NOT a `LedgerClientV1` surface (it is a concrete handler), so an + /// approved credit note replays straight through it via + /// [`CreditNoteHandler::post_credit_note_approved`], which skips the dual-control + /// gate via an internal `gate=false` flag REGARDLESS of whether the handler has an + /// `approval` wired. This is therefore the SAME gated instance the REST surface + /// uses (a gated handler serves both the inline gated path and this replay) — the + /// `*_approved` entry never re-gates. + credit_note: Arc, + /// The debit-note orchestrator (Group E / Slice 3). Like [`Self::credit_note`], a + /// debit note is NOT a `LedgerClientV1` surface (it is a concrete handler), so an + /// approved debit note replays straight through it via + /// [`DebitNoteHandler::post_debit_note_approved`], which skips the dual-control gate + /// via an internal `gate=false` flag REGARDLESS of whether the handler has an + /// `approval` wired. This is therefore the SAME gated instance the REST surface uses + /// — the `*_approved` entry never re-gates. + debit_note: Arc, + /// The period-close service (Slice 7). An approved `PeriodReopen` replays + /// through [`PeriodCloseService::reopen`] (CLOSED→REOPENED flip + secured + /// `period-reopen` audit) — the only reopen path (reopen is always + /// dual-control, so there is no inline reopen). + period_close: PeriodCloseService, +} + +impl LedgerApprovalExecutor { + #[must_use] + #[allow( + clippy::too_many_arguments, + reason = "the executor replays each approved mutation kind through its own handler — one handler per dual-control approval kind" + )] + pub fn new( + client: Arc, + posting: Arc, + payer_state: PayerStateRepo, + refund: Arc, + manual: Arc, + credit_note: Arc, + debit_note: Arc, + period_close: PeriodCloseService, + ) -> Self { + Self { + client, + posting, + payer_state, + refund, + manual, + credit_note, + debit_note, + period_close, + } + } +} + +#[async_trait::async_trait] +impl ApprovalExecutor for LedgerApprovalExecutor { + async fn execute( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + intent: &ApprovalIntent, + ) -> Result<(), DomainError> { + match intent { + ApprovalIntent::Reverse(i) => { + let tenant = ctx.subject_tenant_id(); + let original = self + .client + .get_entry(ctx, tenant, i.entry_id) + .await + .map_err(|e| DomainError::Internal(format!("get_entry on approve: {e:?}")))? + .ok_or_else(|| { + DomainError::ApprovalNotActionable(format!( + "reversed entry {} no longer exists", + i.entry_id + )) + })?; + let into_period = i + .into_period_id + .clone() + .unwrap_or_else(|| original.period_id.clone()); + let effective_on = i + .effective_at + .unwrap_or_else(|| chrono::Utc::now().date_naive()); + let reversal = build_reversal( + &original, + into_period, + effective_on, + ctx.subject_id(), + original.correlation_id, + ) + .map_err(|e| DomainError::Internal(format!("build_reversal on approve: {e:?}")))?; + // Approved explicit reversal — announce `entry.reversed` (VHP-1837) + // with the intent's audit reason. + self.posting + .post_reversal(ctx, scope, reversal, Some(i.reason.clone())) + .await?; + Ok(()) + } + ApprovalIntent::CreditGrant(i) => { + let event_type = i.credit_grant_event_type.clone().ok_or_else(|| { + DomainError::Internal( + "credit-grant approval intent missing credit_grant_event_type".to_owned(), + ) + })?; + let cmd = CreditApplication::Grant(CreditGrant { + tenant_id: i.tenant_id, + payer_tenant_id: i.payer_tenant_id, + credit_application_id: i.credit_application_id.clone(), + currency: i.currency.clone(), + scale: ADVISORY_SCALE, + amount_minor: i.amount_minor, + credit_grant_event_type: event_type, + }); + self.client + .post_credit_application(ctx, cmd) + .await + .map_err(|e| { + DomainError::Internal(format!("credit grant on approve: {e:?}")) + })?; + Ok(()) + } + ApprovalIntent::ChargebackLoss(i) => { + let cmd = RecordDisputePhase { + tenant_id: i.tenant_id, + payer_tenant_id: i.payer_tenant_id, + payment_id: i.payment_id.clone(), + dispute_id: i.dispute_id.clone(), + invoice_id: i.invoice_id.clone(), + cycle: i.cycle, + phase: DisputePhase::Lost.as_str().to_owned(), + funds_at_open: i.funds_at_open.clone(), + disputed_amount_minor: i.disputed_amount_minor, + currency: i.currency.clone(), + scale: ADVISORY_SCALE, + effective_at: None, + }; + self.client + .record_dispute_phase(ctx, cmd) + .await + .map_err(|e| { + DomainError::Internal(format!("chargeback loss on approve: {e:?}")) + })?; + Ok(()) + } + ApprovalIntent::PayerClosure(i) => { + self.payer_state + .close( + scope, + i.tenant_id, + i.payer_tenant_id, + ctx.subject_id(), + i.closed_with_open_balance, + ) + .await?; + Ok(()) + } + // Slice 7: an approved period-reopen flips CLOSED→OPEN under its own + // single-active lease + SERIALIZABLE txn and writes the `period-reopen` + // secured-audit record (idempotent on an already-OPEN period). + ApprovalIntent::PeriodReopen(i) => { + self.period_close + .reopen( + i.tenant_id, + i.legal_entity_id, + &i.period_id, + ctx.subject_id(), + ) + .await?; + Ok(()) + } + // Group J: replay a materially-backdated post against its surface. The + // gate captured the whole external post (the object did not exist at + // gate time); rebuild the domain primitive and post it idempotently + // (`payer_open = true`, matching the inline post seam — the foundation + // account-lifecycle invariant is the authority on a closed payer). + ApprovalIntent::MaterialBackdating(post) => match post { + BackdatedPost::Invoice(snap) => { + let posted = PostedInvoice::try_from(snap)?; + self.posting.post_invoice(ctx, scope, &posted, true).await?; + Ok(()) + } + }, + // Replay the recognition schedule change/cancel through the client + // (which re-applies its own PEP gate + is idempotent on `change_id`, so + // a re-approve replays harmlessly — execute-then-mark is safe). + ApprovalIntent::RecognitionScheduleChange(i) => { + let cmd = ChangeRecognitionSchedule { + tenant_id: i.tenant_id, + schedule_id: i.schedule_id.clone(), + change_id: i.change_id.clone(), + action: i.action.clone(), + treatment: i.treatment.clone(), + new_segments: i.new_segments.as_ref().map(|segs| { + segs.iter() + .map(|s| ChangeSegment { + period_id: s.period_id.clone(), + amount_minor: s.amount_minor, + }) + .collect() + }), + }; + self.client + .change_recognition_schedule(ctx, cmd) + .await + .map_err(|e| { + DomainError::Internal(format!( + "recognition schedule change on approve: {e:?}" + )) + })?; + Ok(()) + } + // Group D: replay an approved over-threshold refund through the refund + // orchestrator's APPROVED entry (skips the gate — the threshold was + // already crossed at gate time). Rebuild the `RefundRequest` from the + // snapshot and post it idempotently (the engine's + // `(tenant, REFUND, psp_refund_id:phase)` claim short-circuits a committed + // post, so a re-approve replays harmlessly — execute-then-mark is safe). + ApprovalIntent::Refund(i) => { + let req = RefundRequest::try_from(i)?; + match self.refund.post_refund_approved(ctx, scope, req).await { + Ok(_) => Ok(()), + // Z5-2: a dispute opened on the origin payment between gate time + // and this replay ⇒ the refund's cash leg was durably HELD on the + // `REFUND_DISPUTE_HOLD` queue (it must NOT pay out while the + // dispute is sub judice). This is a successful DEFERRAL, not an + // executor failure: the approval decision stands (mark APPROVED), + // and the dispute-hold drain owns the eventual post (re-driving on + // WON / cancelling on LOST — never double-paying). Reverting the + // approval to PENDING here would orphan the approver's decision and + // race the hold drain. Mirrors how the gate's own deferral signals + // are non-failures. + Err(DomainError::RefundDisputeHeld(token)) => { + tracing::info!( + %token, + "bss-ledger: approved refund held behind an open dispute on replay — \ + durably enqueued on REFUND_DISPUTE_HOLD; approval marked APPROVED, the \ + hold drain owns the post" + ); + Ok(()) + } + Err(e) => Err(e), + } + } + // Group 5 / Phase 3: replay an approved over-threshold governed manual + // adjustment through the orchestrator's APPROVED entry (skips the gate — + // the threshold was already crossed at gate time). Rebuild the + // `ManualAdjustmentRequest` from the snapshot and post it idempotently (the + // engine's `(tenant, MANUAL_ADJUSTMENT, adjustment_id)` claim short-circuits + // a committed post, so a re-approve replays harmlessly — execute-then-mark + // is safe). + ApprovalIntent::ManualAdjustment(i) => { + let req = ManualAdjustmentRequest::try_from(i)?; + self.manual + .post_manual_adjustment_approved(ctx, scope, req) + .await?; + Ok(()) + } + // Group E / Slice 3: replay an approved over-threshold credit note through + // the orchestrator's APPROVED entry (skips the gate — the threshold was + // already crossed at gate time). Rebuild the `CreditNoteRequest` from the + // intent mirror and post it idempotently (a re-approve short-circuits on the + // foundation idempotency key, so execute-then-mark is safe). + ApprovalIntent::CreditNote(i) => { + let req = crate::domain::adjustment::credit_note::CreditNoteRequest::from(i); + self.credit_note + .post_credit_note_approved(ctx, scope, req) + .await?; + Ok(()) + } + // Group E / Slice 3: replay an approved over-threshold debit note through the + // orchestrator's APPROVED entry (skips the gate). Rebuild the + // `DebitNoteRequest` from the intent mirror and post it idempotently. + ApprovalIntent::DebitNote(i) => { + let req = crate::domain::adjustment::debit_note::DebitNoteRequest::from(i); + // payer_open = true: the foundation closed-AR-account invariant is the + // authority on a since-closed payer (matching the MaterialBackdating seam). + self.debit_note + .post_debit_note_approved(ctx, scope, req, true) + .await?; + Ok(()) + } + // Group E / Slice 3: replay an approved `refund-with-credit-note` composite + // through the refund orchestrator's APPROVED entry. Split the intent into its + // (`RefundRequest`, `CreditNoteRequest`) pair and re-drive the atomic post + // (the composite uses `apply_in_txn`, which never gates). + ApprovalIntent::RefundWithCreditNote(i) => { + let (refund, credit_note) = i.to_requests()?; + self.refund + .post_refund_with_credit_note_approved(ctx, scope, refund, credit_note) + .await?; + Ok(()) + } + } + } +} diff --git a/gears/bss/ledger/ledger/src/infra/approval/service.rs b/gears/bss/ledger/ledger/src/infra/approval/service.rs new file mode 100644 index 000000000..bd7026ef2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/approval/service.rs @@ -0,0 +1,1174 @@ +//! `ApprovalService` — the dual-control lifecycle engine (VHP-1852). Owns the +//! `PENDING → APPROVED | REJECTED | NEEDS_REWORK | CANCELLED | EXPIRED` state +//! machine, the `preparer ≠ approver` rule, and the same-transaction decision +//! audit. It dispatches the governed mutation through an [`ApprovalExecutor`] +//! port so the lifecycle stays testable in isolation from the posting engine. +//! +//! **Latch-execute-mark (DC-impl, H2).** `PostingService` opens its own +//! serializable transaction, so `approve` cannot nest the mutation inside the +//! approval transaction. To keep the dual-control invariant "a rejected/cancelled +//! approval never executes", `approve` (0) atomically latches `PENDING → +//! APPROVING` in its own txn — once latched, `reject`/`cancel`/`request-changes` +//! (all keyed on the `PENDING` state) can no longer win; then (1) executes the +//! stored `intent` through the executor (an idempotent mutation in its own txn); +//! then (2) marks `APPROVING → APPROVED` + writes the decision audit. If the +//! mutation fails (its txn rolled back, nothing committed) the latch is reverted +//! to `PENDING` so the approval is actionable again. Idempotency covers the crash +//! windows: a crash after the latch (before/during/after execute, before the +//! mark) leaves the row `APPROVING`; a later approve recovers it — re-running the +//! mutation (the idempotency key short-circuits a committed post) and completing +//! the mark. Without the latch, a concurrent `reject` landing during execute would +//! leave the mutation committed but the approval `REJECTED`. +//! +//! **Audit (DC7).** No `secured_audit_record` writer exists on this base (Slice 6 +//! brings it). The decision audit is recorded in the same transaction as the +//! state transition via the append-only `ledger_approval_comment` thread, carrying +//! a structured JSON body. When Slice 6 lands, add the `secured_audit_record` +//! write alongside this call in the same txn. + +use std::sync::Arc; + +use bss_ledger_sdk::SourceDocType; +use chrono::{DateTime, Duration, Utc}; +use sea_orm::DbErr; +use toolkit_db::secure::{AccessScope, TxConfig, is_unique_violation}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::config::FxConfig; +use crate::domain::approval::ApprovalState; +use crate::domain::approval::intent::ApprovalIntent; +use crate::domain::approval::policy::{ + DualControlPolicy, OperationFacts, PolicyConfigError, PolicyVersion, effective_version, + requires_dual_control, resolve_policy, validate_config, +}; +use crate::domain::error::DomainError; +use crate::domain::fx::translate::translate_amount; +use crate::domain::model::RepoError; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::fx::rate_source::RateSource; +use crate::infra::storage::repo::{ + ApprovalRepo, FxRepo, JournalRepo, NewPendingApproval, NewPolicyVersion, ReferenceRepo, +}; + +/// The seam through which an approved governed mutation is actually executed. +/// `ApprovalService` reconstructs the [`ApprovalIntent`] from the stored row and +/// hands it here; the concrete adapter (wired in `module`) replays the inline +/// flow (reverse / credit-grant / chargeback) idempotently. Kept a port so the +/// lifecycle engine is unit-testable with a stub. +#[async_trait::async_trait] +pub trait ApprovalExecutor: Send + Sync { + /// Execute the governed mutation captured by `intent`, idempotently (a replay + /// short-circuits via the foundation idempotency key). + /// + /// # Errors + /// A [`DomainError`] propagates the mutation's own rejection unchanged. + async fn execute( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + intent: &ApprovalIntent, + ) -> Result<(), DomainError>; +} + +/// Dual-control lifecycle engine. Cheap to clone (`Arc` + handle fields). +#[derive(Clone)] +pub struct ApprovalService { + db: DBProvider, + repo: ApprovalRepo, + executor: Arc, + metrics: Arc, + // DC10 / FX: the dual-control threshold is held in the tenant's FUNCTIONAL + // (reporting) currency, so the gate translates a cross-currency comparand into + // it before the threshold compare. `source` resolves the rate; `reference` + // reads the tenant's functional currency. + source: RateSource, + reference: ReferenceRepo, + // D2 (FX): reads the OPERATION's locked rate (the referenced posted entry's + // `rate_snapshot_ref`) so the threshold is valued at the operation's own rate, + // not a fresh gate-time rate. + journal: JournalRepo, +} + +impl ApprovalService { + #[must_use] + pub fn new( + db: DBProvider, + executor: Arc, + metrics: Arc, + fx_config: FxConfig, + ) -> Self { + let repo = ApprovalRepo::new(db.clone()); + let source = RateSource::new(FxRepo::new(db.clone()), fx_config); + let reference = ReferenceRepo::new(db.clone()); + let journal = JournalRepo::new(db.clone()); + Self { + db, + repo, + executor, + metrics, + source, + reference, + journal, + } + } + + /// Create (or idempotently return) a `PENDING` approval for an over-threshold + /// mutation. A retry with the same `(tenant, kind, business_key)` returns the + /// existing active record rather than a duplicate (DC13). + /// + /// # Errors + /// [`DomainError::Internal`] on a storage failure. + #[allow(clippy::too_many_arguments)] // a pending record carries several snapshot fields + pub async fn create_pending( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + intent: ApprovalIntent, + reason_code: String, + threshold_snapshot: serde_json::Value, + amount_usd_eq_minor: Option, + ttl_seconds: i64, + ) -> Result { + let tenant = ctx.subject_tenant_id(); + let kind = intent.kind(); + let business_key = intent.business_key(); + + let now = Utc::now(); + if let Some(existing) = self + .repo + .read_active(scope, tenant, kind.as_str(), &business_key, now) + .await? + { + return Ok(existing.approval_id); + } + + let approval_id = Uuid::now_v7(); + let prepared_at = now; + let expires_at = prepared_at + Duration::seconds(ttl_seconds); + let intent_json = serde_json::to_value(&intent) + .map_err(|e| DomainError::Internal(format!("serialize approval intent: {e}")))?; + let row = NewPendingApproval { + approval_id, + tenant, + kind: kind.as_str().to_owned(), + business_key: business_key.clone(), + intent: intent_json, + amount_usd_eq_minor, + threshold_snapshot, + reason_code, + prepared_by: ctx.subject_id(), + prepared_at, + correlation_id: Uuid::now_v7(), + expires_at, + }; + let scope_owned = scope.clone(); + let created = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let row = row.clone(); + let scope = scope_owned.clone(); + Box::pin(async move { + // Lazy expiry pass (DC13/DC12): flip this tenant's lapsed + // PENDING/NEEDS_REWORK rows to EXPIRED first, so an abandoned + // approval past its TTL no longer occupies the active-uniqueness + // slot and the insert below can claim it. This is the per-tenant + // lazy complement to the cross-tenant TTL sweep job + // (`expire_due_all`, wired in `module.rs`) — DC12 ships BOTH, so + // a slot is freed on the next prepare even between sweep ticks. + ApprovalRepo::expire_due(txn, &scope, tenant, now) + .await + .map_err(repo_to_db)?; + ApprovalRepo::insert_pending(txn, &scope, row) + .await + .map_err(repo_to_db)?; + Ok::<(), DbError>(()) + }) + }) + .await; + if let Err(e) = created { + // ONLY the DC13 active-uniqueness race is recoverable: a concurrent + // preparer that both passed the read_active check and won the partial- + // unique index, leaving this caller the loser (23505). Any OTHER error + // (connection drop, CHECK violation, …) is a real failure — surface it, + // never mask it behind a re-read. Gate on the unique-violation + // discriminator; on a confirmed dup, return the winner idempotently. + if as_db_err(&e).is_some_and(is_unique_violation) + && let Some(existing) = self + .repo + .read_active(scope, tenant, kind.as_str(), &business_key, Utc::now()) + .await? + { + return Ok(existing.approval_id); + } + return Err(DomainError::Internal(format!( + "create pending approval: {e}" + ))); + } + self.metrics.dual_control_pending(kind.as_str()); + Ok(approval_id) + } + + /// The retrofit gate (Group E): resolve the tenant's effective policy and + /// decide whether `facts` crosses the threshold. Over threshold → create a + /// `PENDING` approval and return its id (the handler returns + /// `409 DUAL_CONTROL_REQUIRED`); at/under threshold → `None` (the handler + /// proceeds single-actor, unchanged). + /// + /// # Errors + /// [`DomainError::Internal`] on a storage failure. + pub async fn gate( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + intent: ApprovalIntent, + facts: OperationFacts, + reason_code: String, + ) -> Result, DomainError> { + let versions = self + .repo + .read_policy_versions(scope, ctx.subject_tenant_id()) + .await?; + let now = Utc::now(); + let policy = resolve_policy(&versions, now); + // DC10 / FX: value the comparand in the tenant's FUNCTIONAL (reporting) + // currency before the threshold compare — the threshold is denominated in + // functional minor. A single-currency tenant (or same-currency op) compares + // as-is; a cross-currency op translates at the OPERATION's own locked rate + // (design D2 — deterministic + anti-circumvention), falling back to the + // gate-time rate only when the operation carries no referenced snapshot. + let txn_ccy = intent.transaction_currency().map(str::to_owned); + let locked_rate_micro = self + .operation_locked_rate(ctx.subject_tenant_id(), &intent) + .await?; + let facts = self + .to_functional_facts( + scope, + ctx.subject_tenant_id(), + facts, + txn_ccy.as_deref(), + now, + locked_rate_micro, + ) + .await?; + if !requires_dual_control(&facts, policy, now.date_naive()) { + return Ok(None); + } + let snapshot = threshold_snapshot(&policy, now); + let id = self + .create_pending( + ctx, + scope, + intent, + reason_code, + snapshot, + facts.amount_usd_eq_minor, + policy.pending_ttl_seconds, + ) + .await?; + Ok(Some(id)) + } + + /// The OPERATION's own locked FX `rate_micro` for the D2 threshold (design D2: + /// value at the operation's snapshot, not a fresh gate-time rate). Maps the intent + /// to its referenced posted entry — refund -> the payment's `PAYMENT_SETTLE`, + /// credit / debit note -> the invoice's `INVOICE_POST` — and reads that entry's + /// locked rate. `None` (⇒ gate-time fallback) for a single-currency operation, an + /// absent reference, or an intent with no rate-bearing reference (reverse / + /// material-backdating carry no currency — a separate documented residual). + /// + /// # Errors + /// [`DomainError::Internal`] on a storage failure. + async fn operation_locked_rate( + &self, + tenant: Uuid, + intent: &ApprovalIntent, + ) -> Result, DomainError> { + let (business_id, doc_type): (&str, SourceDocType) = match intent { + ApprovalIntent::Refund(i) => (i.payment_id.as_str(), SourceDocType::PaymentSettle), + ApprovalIntent::RefundWithCreditNote(i) => { + (i.refund.payment_id.as_str(), SourceDocType::PaymentSettle) + } + ApprovalIntent::CreditNote(i) => { + (i.origin_invoice_id.as_str(), SourceDocType::InvoicePost) + } + ApprovalIntent::DebitNote(i) => { + (i.origin_invoice_id.as_str(), SourceDocType::InvoicePost) + } + _ => return Ok(None), + }; + // Internal valuation read: a plain tenant scope (the entry is tenant-secured). + let scope = AccessScope::for_tenant(tenant); + self.journal + .locked_rate_micro_for(&scope, tenant, business_id, doc_type.as_str()) + .await + .map_err(|e| DomainError::Internal(format!("operation locked-rate lookup: {e}"))) + } + + /// Translate the comparand (`facts.amount_usd_eq_minor`, in `txn_currency`) into + /// the tenant's FUNCTIONAL (reporting) currency for the DC10 threshold compare. + /// Returns `facts` unchanged when there is no amount comparand, the caller did + /// not tag a transaction currency (a non-amount kind, or `Reverse` / + /// `RecognitionScheduleChange` whose currency is gate-time-derived), the tenant + /// is single-currency (no functional configured), or the operation currency + /// already IS the functional currency. Otherwise resolves the current rate and + /// translates the amount. + /// + /// # Errors + /// [`DomainError::FxRateUnavailable`] / [`DomainError::FxRateStaleNotAllowed`] + /// when a cross-currency op has no usable rate (the post would fail the same + /// way — the gate never silently mis-values the threshold); other + /// [`DomainError`] on a storage / translate fault. + async fn to_functional_facts( + &self, + scope: &AccessScope, + tenant: Uuid, + facts: OperationFacts, + txn_currency: Option<&str>, + now: DateTime, + locked_rate_micro: Option, + ) -> Result { + let (Some(amount), Some(txn_ccy)) = (facts.amount_usd_eq_minor, txn_currency) else { + return Ok(facts); + }; + let Some(functional_ccy) = self + .reference + .functional_currency(scope, tenant) + .await + .map_err(|e| { + DomainError::Internal(format!("dual-control functional-currency lookup: {e}")) + })? + else { + // Single-currency tenant: the threshold currency IS the operation currency. + return Ok(facts); + }; + if functional_ccy == txn_ccy { + return Ok(facts); + } + // D2: prefer the operation's OWN locked rate; fall back to the gate-time rate + // only when the operation carries no referenced snapshot (the documented + // residual). A missing/stale rate on the fallback fails here exactly as the + // post would. + let rate_micro = if let Some(locked) = locked_rate_micro { + locked + } else { + self.source + .resolve(scope, tenant, txn_ccy, &functional_ccy, now) + .await? + .rate_micro + }; + let functional_minor = translate_amount(amount, rate_micro) + .map_err(|e| DomainError::Internal(format!("dual-control FX translate: {e}")))?; + let mut facts = facts; + facts.amount_usd_eq_minor = Some(functional_minor); + Ok(facts) + } + + /// Read the tenant's effective dual-control policy *version* at `now` for the + /// read surface (`GET /dual-control-policy`): the row in force (greatest + /// `effective_from <= now`, highest `version` on a tie), or `None` when the + /// tenant has set no row and the ratified [`DualControlPolicy::DEFAULT`] + /// applies. Tenant-scoped — `scope` is the SQL-level BOLA filter, so a tenant + /// outside the caller's authorized subtree reads as no rows ⇒ `None` ⇒ the + /// platform defaults (the thresholds are public constants, so this leaks + /// neither row existence nor a configured value). + /// + /// # Errors + /// [`DomainError::Internal`] on a storage failure. + pub async fn read_effective_policy( + &self, + scope: &AccessScope, + tenant: Uuid, + now: DateTime, + ) -> Result, DomainError> { + let versions = self.repo.read_policy_versions(scope, tenant).await?; + Ok(effective_version(&versions, now)) + } + + /// Write a new effective-dated dual-control threshold policy version for the + /// caller's tenant (DC8). Validates the D2/A6/TTL ranges first (out-of-range → + /// [`DomainError::DualControlPolicyOutOfRange`], never clamped — DC9/DC11), then + /// appends a `(tenant, version = max + 1)` row in one serializable txn. The + /// resolver picks the latest `effective_from` (highest `version` on a tie), so a + /// later write supersedes without mutating history. Returns the new `version`. + /// + /// # Errors + /// [`DomainError::DualControlPolicyOutOfRange`] on an out-of-range threshold; + /// [`DomainError::Internal`] on a storage failure. + pub async fn set_policy( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + d2_threshold_minor: i64, + a6_backdating_biz_days: i32, + pending_ttl_seconds: i64, + effective_from: DateTime, + ) -> Result { + validate_config( + d2_threshold_minor, + a6_backdating_biz_days, + pending_ttl_seconds, + ) + .map_err(policy_config_to_domain)?; + let tenant = ctx.subject_tenant_id(); + let created_at_utc = Utc::now(); + let scope_c = scope.clone(); + let version = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let scope = scope_c.clone(); + Box::pin(async move { + let next = ApprovalRepo::max_policy_version(txn, &scope, tenant) + .await + .map_err(repo_to_db)? + .map_or(0, |v| v + 1); + ApprovalRepo::insert_policy_row( + txn, + &scope, + NewPolicyVersion { + tenant, + version: next, + effective_from, + d2_threshold_minor, + a6_backdating_biz_days, + pending_ttl_seconds, + created_at_utc, + }, + ) + .await + .map_err(repo_to_db)?; + Ok::(next) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("set dual-control policy txn: {e}")))?; + Ok(version) + } + + /// Approve an approval: latch `PENDING → APPROVING` (so a concurrent + /// reject/cancel/request-changes can no longer win), execute the stored + /// mutation (idempotent), then mark `APPROVING → APPROVED` + write the decision + /// audit. A row already `APPROVING` (a crash-recovery retry) skips the latch and + /// re-executes idempotently. A mutation failure reverts the latch to `PENDING`. + /// + /// # Errors + /// [`DomainError::ApprovalNotFound`] / [`DomainError::ApprovalNotActionable`] + /// (wrong state, expired, or lost the latch race) / [`DomainError::SelfApprovalForbidden`] + /// (`approver == preparer`); the mutation's own rejection propagates unchanged. + pub async fn approve( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, + ) -> Result<(), DomainError> { + let tenant = ctx.subject_tenant_id(); + let approver = ctx.subject_id(); + let row = self.load_for_approve(scope, tenant, approval_id).await?; + if approver == row.prepared_by { + self.metrics.dual_control_self_approval_denied(&row.kind); + return Err(DomainError::SelfApprovalForbidden(format!( + "approver must differ from preparer for approval {approval_id}" + ))); + } + let intent: ApprovalIntent = serde_json::from_value(row.intent.clone()) + .map_err(|e| DomainError::Internal(format!("deserialize approval intent: {e}")))?; + + // (0) Latch PENDING → APPROVING in its own txn. Once latched, the decision + // verbs (reject/cancel/request-changes — all keyed on PENDING) match no + // row and fail, so the mutation about to execute can never be retro- + // actively rejected (H2). A row already APPROVING is a crash-recovery + // retry: skip the latch and re-execute idempotently. + if parse_state(&row.state)? == ApprovalState::Pending + && !self + .bare_transition( + tenant, + scope, + approval_id, + ApprovalState::Pending, + ApprovalState::Approving, + ) + .await? + { + return Err(DomainError::ApprovalNotActionable(format!( + "approval {approval_id} was not in PENDING (concurrent decision)" + ))); + } + + // (1) idempotent mutation in its own transaction. + if let Err(e) = self.executor.execute(ctx, scope, &intent).await { + // The mutation's txn rolled back (nothing committed), so the approval + // is safe to return to PENDING — actionable again (re-approve / reject / + // rework). Best-effort: a failed revert leaves the row APPROVING, which + // a later approve recovers idempotently. The original error propagates. + if let Err(revert) = self + .bare_transition( + tenant, + scope, + approval_id, + ApprovalState::Approving, + ApprovalState::Pending, + ) + .await + { + tracing::error!( + error = %revert, + %approval_id, + "bss-ledger: failed to revert APPROVING→PENDING after an executor error" + ); + } + return Err(e); + } + + // (2) mark APPROVED + decision audit (APPROVING → APPROVED) in one txn. + let body = decision_audit( + "approved", + &row.kind, + &row.business_key, + row.prepared_by, + approver, + None, + ); + self.commit_transition( + tenant, + scope, + approval_id, + ApprovalState::Approving, + ApprovalState::Approved, + approver, + row.revision, + body, + ) + .await?; + self.metrics.dual_control_decided(&row.kind, "approved"); + Ok(()) + } + + /// Reject a `PENDING` approval with a mandatory reason. The mutation never + /// runs. + /// + /// # Errors + /// As [`Self::approve`] (minus the executor), with the reason recorded. + pub async fn reject( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, + reason: String, + ) -> Result<(), DomainError> { + let tenant = ctx.subject_tenant_id(); + let decider = ctx.subject_id(); + let row = self.load_pending(scope, tenant, approval_id).await?; + if decider == row.prepared_by { + self.metrics.dual_control_self_approval_denied(&row.kind); + return Err(DomainError::SelfApprovalForbidden(format!( + "rejecter must differ from preparer for approval {approval_id}" + ))); + } + let body = decision_audit( + "rejected", + &row.kind, + &row.business_key, + row.prepared_by, + decider, + Some(&reason), + ); + self.commit_transition( + tenant, + scope, + approval_id, + ApprovalState::Pending, + ApprovalState::Rejected, + decider, + row.revision, + body, + ) + .await?; + self.metrics.dual_control_decided(&row.kind, "rejected"); + Ok(()) + } + + /// Cancel an active (`PENDING`/`NEEDS_REWORK`) approval — only the preparer + /// may withdraw their own request. + /// + /// # Errors + /// [`DomainError::ApprovalNotFound`] / [`DomainError::ApprovalNotActionable`] + /// (terminal, or caller is not the preparer / lost race). + pub async fn cancel( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, + ) -> Result<(), DomainError> { + let tenant = ctx.subject_tenant_id(); + let caller = ctx.subject_id(); + let row = self + .repo + .read(scope, tenant, approval_id) + .await? + .ok_or_else(|| DomainError::ApprovalNotFound(format!("approval {approval_id}")))?; + let state = parse_state(&row.state)?; + if !state.is_active() { + return Err(DomainError::ApprovalNotActionable(format!( + "approval {approval_id} is {} (terminal)", + row.state + ))); + } + if caller != row.prepared_by { + return Err(DomainError::ApprovalNotActionable(format!( + "only the preparer may cancel approval {approval_id}" + ))); + } + let body = decision_audit( + "cancelled", + &row.kind, + &row.business_key, + row.prepared_by, + caller, + None, + ); + self.commit_transition( + tenant, + scope, + approval_id, + state, + ApprovalState::Cancelled, + caller, + row.revision, + body, + ) + .await?; + self.metrics.dual_control_decided(&row.kind, "cancelled"); + Ok(()) + } + + /// Return a `PENDING` approval to the preparer for rework, with a mandatory + /// reason. The mutation never runs; the preparer edits and `resubmit`s. + /// + /// # Errors + /// As [`Self::reject`]. + pub async fn request_changes( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, + reason: String, + ) -> Result<(), DomainError> { + let tenant = ctx.subject_tenant_id(); + let decider = ctx.subject_id(); + let row = self.load_pending(scope, tenant, approval_id).await?; + if decider == row.prepared_by { + self.metrics.dual_control_self_approval_denied(&row.kind); + return Err(DomainError::SelfApprovalForbidden(format!( + "changer must differ from preparer for approval {approval_id}" + ))); + } + let body = decision_audit( + "needs_rework", + &row.kind, + &row.business_key, + row.prepared_by, + decider, + Some(&reason), + ); + self.commit_transition( + tenant, + scope, + approval_id, + ApprovalState::Pending, + ApprovalState::NeedsRework, + decider, + row.revision, + body, + ) + .await?; + self.metrics.dual_control_decided(&row.kind, "needs_rework"); + Ok(()) + } + + /// Resubmit a `NEEDS_REWORK` approval back to `PENDING` with the preparer's + /// edited intent, bumping `revision`. The kind cannot change. Re-evaluates the + /// threshold on the edited intent and re-snapshots the policy in force (DC17); + /// an approval, once required, is never dropped by shrinking the amount — + /// resubmit always returns to `PENDING`, never auto-applies. + /// + /// # Errors + /// [`DomainError::ApprovalNotFound`] / [`DomainError::ApprovalNotActionable`] + /// (not awaiting rework, not the preparer, kind changed, or lost race). + pub async fn resubmit( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, + new_intent: ApprovalIntent, + ) -> Result<(), DomainError> { + let tenant = ctx.subject_tenant_id(); + let caller = ctx.subject_id(); + let row = self + .repo + .read(scope, tenant, approval_id) + .await? + .ok_or_else(|| DomainError::ApprovalNotFound(format!("approval {approval_id}")))?; + if parse_state(&row.state)? != ApprovalState::NeedsRework { + return Err(DomainError::ApprovalNotActionable(format!( + "approval {approval_id} is {}, expected NEEDS_REWORK", + row.state + ))); + } + if caller != row.prepared_by { + return Err(DomainError::ApprovalNotActionable(format!( + "only the preparer may resubmit approval {approval_id}" + ))); + } + if new_intent.kind().as_str() != row.kind { + return Err(DomainError::ApprovalNotActionable( + "cannot change the approval kind on resubmit".to_owned(), + )); + } + // DC #1: pin the target identity. Re-hydrate the stored intent + // and require the resubmitted one to address the SAME target — only the + // scalar amount may be edited (DC17). Without this a preparer could, after a + // request-changes, swap the recipient (payer_tenant_id / entry_id / + // payment_id) under the still-frozen business_key, and the approver would + // book the credit / reversal / chargeback to the swapped party: the executor + // replays the stored body and `ApprovalDto` never surfaces the recipient. + let original_intent: ApprovalIntent = serde_json::from_value(row.intent.clone()) + .map_err(|e| DomainError::Internal(format!("deserialize approval intent: {e}")))?; + if !new_intent.same_target(&original_intent) { + return Err(DomainError::ApprovalNotActionable( + "resubmit cannot change the approval target; only the amount may be edited" + .to_owned(), + )); + } + // DC17: re-evaluate the threshold against the EDITED intent and re-snapshot + // the policy in force NOW (not a stub). The recorded `threshold_snapshot` + // must reflect the policy that applied at resubmit time; the approval is + // never silently dropped (it always returns to PENDING, so an approver is + // still required even if the edited amount is now below threshold). + let versions = self.repo.read_policy_versions(scope, tenant).await?; + let resolved_at = Utc::now(); + let policy = resolve_policy(&versions, resolved_at); + let new_threshold_snapshot = threshold_snapshot(&policy, resolved_at); + let new_amount_usd_eq_minor = new_intent.amount_minor(); + let new_revision = row.revision + 1; + let intent_json = serde_json::to_value(&new_intent) + .map_err(|e| DomainError::Internal(format!("serialize approval intent: {e}")))?; + let body = decision_audit( + "resubmitted", + &row.kind, + &row.business_key, + row.prepared_by, + caller, + None, + ); + let scope_c = scope.clone(); + let now = Utc::now(); + let comment_id = Uuid::now_v7(); + let applied = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let scope = scope_c.clone(); + let intent_json = intent_json.clone(); + let snapshot = new_threshold_snapshot.clone(); + let body = body.clone(); + Box::pin(async move { + let rows = ApprovalRepo::resubmit( + txn, + &scope, + tenant, + approval_id, + intent_json, + snapshot, + new_amount_usd_eq_minor, + new_revision, + ) + .await + .map_err(repo_to_db)?; + if rows == 0 { + return Ok::(false); + } + ApprovalRepo::append_comment( + txn, + &scope, + comment_id, + approval_id, + tenant, + new_revision, + caller, + body, + now, + ) + .await + .map_err(repo_to_db)?; + Ok(true) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("approval resubmit txn: {e}")))?; + if !applied { + return Err(DomainError::ApprovalNotActionable(format!( + "approval {approval_id} was not in NEEDS_REWORK (concurrent decision)" + ))); + } + self.metrics.dual_control_decided(&row.kind, "resubmitted"); + Ok(()) + } + + /// Append a free comment / question to an approval's thread (no state change). + /// The author is the caller; authz (preparer or `entry_approve.v1`) is gated + /// at the REST layer. + /// + /// # Errors + /// [`DomainError::ApprovalNotFound`] / [`DomainError::Internal`]. + pub async fn add_comment( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, + body_text: String, + ) -> Result<(), DomainError> { + let tenant = ctx.subject_tenant_id(); + let author = ctx.subject_id(); + let row = self + .repo + .read(scope, tenant, approval_id) + .await? + .ok_or_else(|| DomainError::ApprovalNotFound(format!("approval {approval_id}")))?; + let revision = row.revision; + let scope_c = scope.clone(); + let now = Utc::now(); + let comment_id = Uuid::now_v7(); + self.db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let scope = scope_c.clone(); + let body = body_text.clone(); + Box::pin(async move { + ApprovalRepo::append_comment( + txn, + &scope, + comment_id, + approval_id, + tenant, + revision, + author, + body, + now, + ) + .await + .map_err(repo_to_db)?; + Ok::<(), DbError>(()) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("add comment txn: {e}")))?; + Ok(()) + } + + /// List the tenant's approval queue (newest-first), optionally filtered. + /// + /// # Errors + /// [`DomainError::Internal`] on a storage failure. + pub async fn list( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + state: Option<&str>, + kind: Option<&str>, + ) -> Result, DomainError> { + self.repo + .list(scope, ctx.subject_tenant_id(), state, kind) + .await + } + + /// Read a single approval. + /// + /// # Errors + /// [`DomainError::Internal`] on a storage failure. + pub async fn get( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, + ) -> Result, DomainError> + { + self.repo + .read(scope, ctx.subject_tenant_id(), approval_id) + .await + } + + /// Read an approval's comment thread (oldest-first). + /// + /// # Errors + /// [`DomainError::Internal`] on a storage failure. + pub async fn thread( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + approval_id: Uuid, + ) -> Result, DomainError> { + self.repo + .read_thread(scope, ctx.subject_tenant_id(), approval_id) + .await + } + + /// Read + validate that an approval is approvable: `PENDING` (must be + /// unexpired) or `APPROVING` (a crash-recovery retry — the decision was already + /// latched, so expiry no longer gates it). Any other state is not actionable. + async fn load_for_approve( + &self, + scope: &AccessScope, + tenant: Uuid, + approval_id: Uuid, + ) -> Result { + let row = self + .repo + .read(scope, tenant, approval_id) + .await? + .ok_or_else(|| DomainError::ApprovalNotFound(format!("approval {approval_id}")))?; + match parse_state(&row.state)? { + ApprovalState::Pending => { + if row.expires_at <= Utc::now() { + return Err(DomainError::ApprovalNotActionable(format!( + "approval {approval_id} expired at {}", + row.expires_at + ))); + } + } + // Recovery: a prior approve latched APPROVING then crashed before the + // mark; re-execute idempotently and complete it. Expiry no longer gates + // a decision already taken. + ApprovalState::Approving => {} + _ => { + return Err(DomainError::ApprovalNotActionable(format!( + "approval {approval_id} is {}, expected PENDING", + row.state + ))); + } + } + Ok(row) + } + + /// Apply a bare state transition (NO audit comment) in its own serializable + /// txn; returns `true` iff the row was in `expected`. The H2 `APPROVING` latch + /// and its revert are control-flow latches, not audited decisions, so they skip + /// the audit append `commit_transition` writes; `approved_by`/`decided_at` stay + /// `NULL` (stamped only by the final `APPROVING → APPROVED` `commit_transition`). + async fn bare_transition( + &self, + tenant: Uuid, + scope: &AccessScope, + approval_id: Uuid, + expected: ApprovalState, + new_state: ApprovalState, + ) -> Result { + let scope = scope.clone(); + let expected_s = expected.as_str(); + let new_s = new_state.as_str(); + let applied = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let scope = scope.clone(); + Box::pin(async move { + let rows = ApprovalRepo::transition( + txn, + &scope, + tenant, + approval_id, + expected_s, + new_s, + None, + None, + ) + .await + .map_err(repo_to_db)?; + Ok::(rows > 0) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("approval latch txn: {e}")))?; + Ok(applied) + } + + /// Read + validate that an approval is `PENDING` and not expired. + async fn load_pending( + &self, + scope: &AccessScope, + tenant: Uuid, + approval_id: Uuid, + ) -> Result { + let row = self + .repo + .read(scope, tenant, approval_id) + .await? + .ok_or_else(|| DomainError::ApprovalNotFound(format!("approval {approval_id}")))?; + let state = parse_state(&row.state)?; + if state != ApprovalState::Pending { + return Err(DomainError::ApprovalNotActionable(format!( + "approval {approval_id} is {}, expected PENDING", + row.state + ))); + } + if row.expires_at <= Utc::now() { + return Err(DomainError::ApprovalNotActionable(format!( + "approval {approval_id} expired at {}", + row.expires_at + ))); + } + Ok(row) + } + + /// Apply a state transition + append the decision audit in one serializable + /// transaction. The optimistic `expected_state` filter is the in-txn race + /// backstop: `0` rows means another decision won (or the row moved), which + /// surfaces as [`DomainError::ApprovalNotActionable`]. + #[allow(clippy::too_many_arguments)] // a decision is intrinsically wide; a struct adds churn + async fn commit_transition( + &self, + tenant: Uuid, + scope: &AccessScope, + approval_id: Uuid, + expected: ApprovalState, + new_state: ApprovalState, + decider: Uuid, + revision: i32, + audit_body: String, + ) -> Result<(), DomainError> { + let scope = scope.clone(); + let expected_s = expected.as_str(); + let new_s = new_state.as_str(); + // `approved_by` is stamped ONLY for the actual APPROVED transition. A + // reject / request-changes / CANCEL is not an approval — stamping the actor + // there would, for a preparer self-cancel, set `approved_by == prepared_by` + // and trip the `approved_by <> prepared_by` CHECK (surfacing as a 500). The + // audit comment below records who acted regardless of the lifecycle state. + let approved_by = (new_state == ApprovalState::Approved).then_some(decider); + let now = Utc::now(); + let comment_id = Uuid::now_v7(); + let applied = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let scope = scope.clone(); + let body = audit_body.clone(); + Box::pin(async move { + let rows = ApprovalRepo::transition( + txn, + &scope, + tenant, + approval_id, + expected_s, + new_s, + approved_by, + Some(now), + ) + .await + .map_err(repo_to_db)?; + if rows == 0 { + return Ok::(false); + } + ApprovalRepo::append_comment( + txn, + &scope, + comment_id, + approval_id, + tenant, + revision, + decider, + body, + now, + ) + .await + .map_err(repo_to_db)?; + Ok(true) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("approval decision txn: {e}")))?; + if applied { + Ok(()) + } else { + Err(DomainError::ApprovalNotActionable(format!( + "approval {approval_id} was not in {expected_s} (concurrent decision or stale state)" + ))) + } + } +} + +/// Parse a stored state token, mapping an unknown literal to an internal fault. +fn parse_state(s: &str) -> Result { + ApprovalState::parse(s) + .ok_or_else(|| DomainError::Internal(format!("unknown approval state {s:?}"))) +} + +/// The threshold-snapshot recorded on a pending approval: which policy values +/// applied + when resolved (audit trail; DC8/DC17). Built identically by `gate` +/// (on create) and `resubmit` (on re-evaluation against the edited intent), so the +/// recorded snapshot is never a stub. +fn threshold_snapshot(policy: &DualControlPolicy, now: DateTime) -> serde_json::Value { + serde_json::json!({ + "d2_threshold_minor": policy.d2_threshold_minor, + "a6_backdating_biz_days": policy.a6_backdating_biz_days, + "pending_ttl_seconds": policy.pending_ttl_seconds, + "resolved_at": now.to_rfc3339(), + }) +} + +/// Map a pure policy-config range rejection (DC9/DC11) to the domain error: an +/// out-of-range D2/A6/TTL is `DualControlPolicyOutOfRange` (→ 409, no clamp). +fn policy_config_to_domain(e: PolicyConfigError) -> DomainError { + let detail = match e { + PolicyConfigError::D2OutOfRange(v) => { + format!("d2_threshold_minor {v} out of range [10000..100000000]") + } + PolicyConfigError::A6OutOfRange(v) => { + format!("a6_backdating_biz_days {v} out of range [1..30]") + } + PolicyConfigError::TtlNotPositive(v) => format!("pending_ttl_seconds {v} must be > 0"), + }; + DomainError::DualControlPolicyOutOfRange(detail) +} + +/// The structured decision-audit body recorded on the append-only comment thread +/// (the Slice-2 stand-in for `secured_audit_record`, DC7). `event_type` mirrors +/// the design's `exception-resolution` audit class. +fn decision_audit( + decision: &str, + kind: &str, + business_key: &str, + prepared_by: Uuid, + decided_by: Uuid, + reason: Option<&str>, +) -> String { + serde_json::json!({ + "event_type": "exception-resolution", + "decision": decision, + "kind": kind, + "business_key": business_key, + "prepared_by": prepared_by, + "decided_by": decided_by, + "reason": reason, + }) + .to_string() +} + +/// Retry-extractor: a wrapped `DbErr` is recognised as retryable serialization +/// contention (mirrors `PostingService::as_db_err`). +fn as_db_err(e: &DbError) -> Option<&DbErr> { + match e { + DbError::Sea(db_err) => Some(db_err), + _ => None, + } +} + +/// Encode a repo failure as a non-retryable `DbError` so the decision txn rolls +/// back and surfaces as an internal fault (business outcomes are carried by the +/// `bool`/`rows` path, not by error). +#[allow(clippy::needless_pass_by_value)] // used as a `map_err` fn pointer (FnOnce(RepoError)) +fn repo_to_db(e: RepoError) -> DbError { + DbError::Sea(DbErr::Custom(format!("approval repo: {e:?}"))) +} diff --git a/gears/bss/ledger/ledger/src/infra/audit.rs b/gears/bss/ledger/ledger/src/infra/audit.rs new file mode 100644 index 000000000..88bbf0606 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/audit.rs @@ -0,0 +1,19 @@ +//! Secured audit (Slice 6 Phase 2 Group 2A store + the Slice 3 ↔ Slice 6 sink seam). +//! +//! Slice 6 (audit, VHP-1858) owns the append-only `secured_audit_record` store +//! with its own per-tenant tamper-evidence hash chain (`event_type` / `retrieval` +//! / `store`). Each appended record is born sealed (`row_hash` / `prev_hash` +//! non-NULL) and is never updated. +//! +//! Slice 3 depends on the secured-audit append only through a local **port** +//! ([`secured_audit_sink::SecuredAuditSink`]) whose method signature mirrors +//! `store::SecuredAuditStore::append` (design §2.1). The wired implementation is +//! [`secured_audit_sink::NoopSecuredAuditSink`] — it records nothing durable +//! (logs + emits a metric only). Slice 3 uses it for the `unknown_final` refund +//! disposition (clear `REFUND_CLEARING` → documented loss line + an audit record, +//! design §4.4 / K-1) and — in Phase 3 — the attempted-write-off capture (§6 A4). + +pub mod event_type; +pub mod retrieval; +pub mod secured_audit_sink; +pub mod store; diff --git a/gears/bss/ledger/ledger/src/infra/audit/event_type.rs b/gears/bss/ledger/ledger/src/infra/audit/event_type.rs new file mode 100644 index 000000000..7c45bc0ac --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/audit/event_type.rs @@ -0,0 +1,70 @@ +//! The fixed catalogue of secured-audit event types. The `as_str` tokens are +//! the canonical wire codes — they MUST match the `chk_secured_audit_event_type` +//! CHECK in migration 000009 (a code not in the set is rejected by the DB). + +/// A secured-audit event type. The 12 variants match the migration's CHECK set. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum AuditEventType { + /// A conflicting concurrent change was captured for later resolution. + ConflictCapture, + /// Mutable metadata on a record changed. + MetadataChange, + /// A cross-tenant access took place. + CrossTenantAccess, + /// A manual adjustment was posted. + ManualAdjustment, + /// PII was erased (GDPR right-to-erasure). + Erasure, + /// A previously-erased subject was re-identified. + ReIdentification, + /// An account lifecycle state changed (open/close/suspend). + AccountLifecycleChange, + /// An exception (e.g. a hold) was resolved. + ExceptionResolution, + /// A scope freeze was set or cleared. + FreezeSetClear, + /// A configuration value changed. + ConfigChange, + /// Data was restored from backup. + RestoreEvent, + /// A closed fiscal period was reopened. + PeriodReopen, +} + +impl AuditEventType { + /// Every event-type wire token, in the migration CHECK-set order. Pinned + /// equal to `chk_secured_audit_event_type` by the migration's drift test. + pub const ALL: &'static [&'static str] = &[ + "conflict-capture", + "metadata-change", + "cross-tenant-access", + "manual-adjustment", + "erasure", + "re-identification", + "account-lifecycle-change", + "exception-resolution", + "freeze-set-clear", + "config-change", + "restore-event", + "period-reopen", + ]; + + /// Stable wire token for this event type (matches the migration CHECK set). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ConflictCapture => "conflict-capture", + Self::MetadataChange => "metadata-change", + Self::CrossTenantAccess => "cross-tenant-access", + Self::ManualAdjustment => "manual-adjustment", + Self::Erasure => "erasure", + Self::ReIdentification => "re-identification", + Self::AccountLifecycleChange => "account-lifecycle-change", + Self::ExceptionResolution => "exception-resolution", + Self::FreezeSetClear => "freeze-set-clear", + Self::ConfigChange => "config-change", + Self::RestoreEvent => "restore-event", + Self::PeriodReopen => "period-reopen", + } + } +} diff --git a/gears/bss/ledger/ledger/src/infra/audit/retrieval.rs b/gears/bss/ledger/ledger/src/infra/audit/retrieval.rs new file mode 100644 index 000000000..383430b39 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/audit/retrieval.rs @@ -0,0 +1,252 @@ +//! `AuditRetrievalReader` — scoped reads backing the audit-retrieval REST +//! surface (Slice 6 Phase 2 Group 2C, architecture AC #8): the who/when/source/ +//! correlation dims of a posted entry, a document's full posting history, and a +//! scope's tamper-status (active freezes + a derived `verified` flag). +//! +//! Stateless over one [`DBProvider`]; every read goes through the `SecureORM` +//! layer (`.secure().scope_with(scope)`), so the scope the caller passes IS the +//! SQL-level BOLA filter — a row outside it is simply not returned. The +//! tamper-status read may run under a TARGET-tenant scope handed back by the +//! [`crate::infra::authz::cross_tenant::CrossTenantGateway`]; the per-row reads +//! run under the caller's HOME scope. +//! +//! **§10 NFR target (ratified):** audit retrieval p95 ≤ 2 s. The bounded, +//! index-backed scoped reads here meet it; the §9 latency is observed via the +//! shared posting/inquiry latency instruments (`infra::metrics`). + +use chrono::{DateTime, Utc}; +use sea_orm::{ColumnTrait, Condition, EntityTrait, Order}; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::model::RepoError; +use crate::infra::storage::entity::{journal_entry, scope_freeze}; + +/// The audit who/when/source/correlation dims of one posted journal entry +/// (AC #8). A pure read projection of `journal_entry`; carries no lines. +#[derive(Clone, Debug)] +pub struct AuditEntryRecord { + pub entry_id: Uuid, + pub tenant_id: Uuid, + pub period_id: String, + pub posted_by_actor_id: Uuid, + pub origin: String, + pub posted_at_utc: DateTime, + pub source_doc_type: String, + pub source_business_id: String, + pub correlation_id: Uuid, + pub reverses_entry_id: Option, + pub created_seq: i64, +} + +impl From for AuditEntryRecord { + fn from(m: journal_entry::Model) -> Self { + Self { + entry_id: m.entry_id, + tenant_id: m.tenant_id, + period_id: m.period_id, + posted_by_actor_id: m.posted_by_actor_id, + origin: m.origin, + posted_at_utc: m.posted_at_utc, + source_doc_type: m.source_doc_type, + source_business_id: m.source_business_id, + correlation_id: m.correlation_id, + reverses_entry_id: m.reverses_entry_id, + created_seq: m.created_seq, + } + } +} + +/// One active-or-historical scope-freeze row in a tamper-status read. +#[derive(Clone, Debug)] +pub struct FreezeRecord { + pub scope: String, + pub period_id: String, + pub reason: String, + pub frozen_at: DateTime, + pub set_by: String, + pub cleared_by: Option, + pub cleared_at: Option>, +} + +impl From for FreezeRecord { + fn from(m: scope_freeze::Model) -> Self { + Self { + scope: m.scope, + period_id: m.period_id, + reason: m.reason, + frozen_at: m.frozen_at, + set_by: m.set_by, + cleared_by: m.cleared_by, + cleared_at: m.cleared_at, + } + } +} + +/// The tamper-status of a resolved scope: every freeze row for the tenant, a +/// `scope_frozen` flag (any ACTIVE freeze), and a derived `verified` flag. +#[derive(Clone, Debug)] +pub struct TamperStatusRecord { + pub scope_frozen: bool, + pub freezes: Vec, + pub verified: bool, +} + +/// Scoped reader over one [`DBProvider`]. Stateless. +#[derive(Clone)] +pub struct AuditRetrievalReader { + db: DBProvider, +} + +impl AuditRetrievalReader { + /// Build the reader over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// The underlying provider (the audit REST surface opens its own elevation + /// transaction on it for the tamper-status path). + #[must_use] + pub fn db(&self) -> &DBProvider { + &self.db + } + + /// Read the audit dims of one entry by `(tenant_id, entry_id)` under `scope`. + /// SQL-level BOLA: a foreign-tenant scope (or an absent entry) yields `None`. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn audit_entry( + &self, + scope: &AccessScope, + tenant_id: Uuid, + entry_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let header = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::EntryId.eq(entry_id)) + .add(journal_entry::Column::TenantId.eq(tenant_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("find audit journal_entry: {e}")))?; + Ok(header.map(AuditEntryRecord::from)) + } + + /// Read a document's full posting history under `scope`: every + /// `journal_entry` row for `tenant_id` with that `(source_doc_type, + /// source_business_id)`, PLUS any entries that `reverses_entry_id`-link to + /// them (reversals / mapping-corrections of the same document), ordered by + /// `created_seq`. SQL-level BOLA via the scoped select. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn document_history( + &self, + scope: &AccessScope, + tenant_id: Uuid, + source_doc_type: &str, + source_business_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + + // 1. The document's own entries (by source key). + let base = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::SourceDocType.eq(source_doc_type.to_owned())) + .add(journal_entry::Column::SourceBusinessId.eq(source_business_id.to_owned())), + ) + .order_by(journal_entry::Column::CreatedSeq, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("find document history (base): {e}")))?; + + // 2. Any entries that reverse one of the base entries (reversals / + // mapping-corrections link back via `reverses_entry_id`). Tenant- + // scoped like the base read. + let base_ids: Vec = base.iter().map(|e| e.entry_id).collect(); + let linked = if base_ids.is_empty() { + Vec::new() + } else { + journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::ReversesEntryId.is_in(base_ids.clone())), + ) + .order_by(journal_entry::Column::CreatedSeq, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("find document history (linked): {e}")))? + }; + + // Merge + de-dup (a linked entry sharing the source key already appears + // in `base`), then order by `created_seq`. + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut out: Vec = Vec::with_capacity(base.len() + linked.len()); + for m in base.into_iter().chain(linked) { + if seen.insert(m.entry_id) { + out.push(AuditEntryRecord::from(m)); + } + } + out.sort_by_key(|e| e.created_seq); + Ok(out) + } + + /// Read the tamper-status of `tenant` under `scope` inside `txn` (the audit + /// surface runs this in the elevation transaction so the forensic record and + /// the read share one transaction). Returns every freeze row for the tenant, + /// a `scope_frozen` flag (any ACTIVE freeze — `cleared_at IS NULL`), and a + /// derived `verified = !scope_frozen`. + /// + /// `verified` is an MVP derivation: the gear persists no per-run verifier + /// result, so "not currently frozen" is the only chain-health signal + /// available at read time. The daily chain verifier is what SETS a freeze on + /// a broken chain, so an unfrozen scope is one the verifier has not faulted. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn tamper_status_in_txn( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + ) -> Result { + let rows = scope_freeze::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(scope_freeze::Column::TenantId.eq(tenant))) + .order_by(scope_freeze::Column::FrozenAt, Order::Asc) + .all(txn) + .await + .map_err(|e| RepoError::Db(format!("read tamper-status scope_freeze: {e}")))?; + + let scope_frozen = rows.iter().any(|r| r.cleared_at.is_none()); + let freezes = rows.into_iter().map(FreezeRecord::from).collect(); + Ok(TamperStatusRecord { + scope_frozen, + freezes, + // MVP: no per-run verifier result is persisted, so a scope that is + // not currently frozen is treated as verified. + verified: !scope_frozen, + }) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/audit/secured_audit_sink.rs b/gears/bss/ledger/ledger/src/infra/audit/secured_audit_sink.rs new file mode 100644 index 000000000..91044c010 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/audit/secured_audit_sink.rs @@ -0,0 +1,220 @@ +//! `SecuredAuditSink` — the Slice-3-local **port** for Slice 6's secured-audit +//! store (design §2.1, F1). Slice 6 (VHP-1858) owns the durable +//! `secured_audit_record` table + its append-only / hash-chained / retention +//! semantics, but its code is not in our base. So Slice 3 depends on this trait +//! whose method signature mirrors Slice 6's `SecuredAuditStore::append` +//! **exactly**: +//! +//! ```ignore +//! async fn append( +//! &self, +//! txn: &DbTx<'_>, +//! scope: &AccessScope, +//! tenant: Uuid, +//! event_type: AuditEventType, +//! actor_ref: Option<&str>, +//! reason_code: Option<&str>, +//! before_after: &serde_json::Value, +//! correlation_id: Option, +//! retain_until: Option>, +//! ) -> Result; +//! ``` +//! +//! [`NoopSecuredAuditSink`] is wired: it writes nothing durable, logging the +//! would-be record at `info` so the call is observable in traces. The +//! `before_after` payload is asserted PII-clean by the caller (ids + amounts + +//! enum codes only) before it reaches here (design §2.3). +//! +//! GAP (post-VHP-1858 rebase, tracked follow-up — NOT a runtime defect today): +//! Slice 6's real [`crate::infra::audit::store::SecuredAuditStore`] now lives in +//! the base, but the Slice-3 dispositions (the `unknown_final` refund clear in +//! `adjustment::refund_service` and the write-off capture in +//! `adjustment::manual_adjustment_service`) are STILL bound to the no-op sink — +//! the seam was NOT flipped during the rebase. A drop-in swap is not yet safe: +//! (a) the real `store::AuditEventType` taxonomy uses lowercase-hyphen tokens +//! (`"manual-adjustment"`, pinned by the `secured_audit_record` CHECK) whereas +//! this local mirror emits `SCREAMING_SNAKE` (`"MANUAL_ADJUSTMENT"`); (b) the +//! append signatures differ. Unifying onto the real store (or reconciling the +//! token + signature) is its own follow-up; until then nothing durable is +//! written for these two Slice-3 dispositions (the event/intent trail still +//! records them). +//! +//! [`AuditEventType`] is a LOCAL enum-mirror of Slice 6's audit-event taxonomy; +//! Slice 3 only needs the [`AuditEventType::ManualAdjustment`] variant (the same +//! variant Slice 6 stamps for a governed disposition / write-off — re-using it +//! avoids touching Slice 6's secured-audit migration enum at merge, design §2.1). + +use chrono::{DateTime, Utc}; +use toolkit_db::DbError; +use toolkit_db::secure::{AccessScope, DbTx}; +use uuid::Uuid; + +/// The secured-audit event taxonomy (a LOCAL mirror of Slice 6's +/// `AuditEventType`). The stored literal is `SCREAMING_SNAKE_CASE` (matching +/// Slice 6's serde + durable enum), so a record Slice 3 writes through the no-op +/// is byte-identical to one the real store would persist post-merge. +/// +/// Slice 3 only emits [`Self::ManualAdjustment`] — the variant Slice 6 stamps +/// for a **ledger-side governed disposition** (the `unknown_final` refund +/// write-off to a loss line, design §4.4 / K-1; and the Phase-3 attempted +/// write-off capture, §6 A4). The other variants are declared so the local +/// mirror is a superset that cannot conflict with Slice 6's enum at merge. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuditEventType { + /// A governed manual posting / disposition (the `unknown_final` refund + /// loss-line write-off; the attempted-write-off capture). The ONLY variant + /// Slice 3 emits. + ManualAdjustment, + /// A dual-control approval was granted (Slice 6 records the approver). + ApprovalGranted, + /// A dual-control approval was rejected. + ApprovalRejected, + /// A PII-minimization redaction was applied before persist. + Redaction, + /// A closed fiscal period was reopened (Slice 7 dual-control seam, design + /// §7 / N-core-3). Stamped `"period-reopen"` — matching the real + /// `secured_audit_record` CHECK + Slice 6 taxonomy (the lowercase-hyphen + /// token, not this mirror's legacy `SCREAMING_SNAKE` — the new variant is + /// forward-correct for the seam flip). + PeriodReopen, +} + +impl AuditEventType { + /// Stable `SCREAMING_SNAKE_CASE` literal (the durable event-type code + + /// Slice 6's serde wire form). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ManualAdjustment => "MANUAL_ADJUSTMENT", + Self::ApprovalGranted => "APPROVAL_GRANTED", + Self::ApprovalRejected => "APPROVAL_REJECTED", + Self::Redaction => "REDACTION", + Self::PeriodReopen => "period-reopen", + } + } +} + +/// Port for the secured-audit store (Slice 6's `SecuredAuditStore`). The method +/// signature mirrors Slice 6's `append` exactly so the real store binds at +/// merge with no call-site change. Implementors persist one append-only audit +/// record **in the supplied posting transaction** (so the record commits +/// atomically with the disposition it audits, or rolls back with it). +#[async_trait::async_trait] +pub trait SecuredAuditSink: Send + Sync { + /// Append one secured-audit record in `txn`, returning its id. + /// + /// - `event_type` — the taxonomy variant ([`AuditEventType::ManualAdjustment`] + /// for Slice 3's dispositions). + /// - `actor_ref` — the acting subject (the approver/operator id), `None` for + /// a system-initiated record. + /// - `reason_code` — a closed reason literal (e.g. `"REFUND_UNKNOWN_FINAL"`). + /// - `before_after` — the PII-clean state delta (ids + amounts + codes only); + /// the caller guarantees no PII before the call. + /// - `correlation_id` — links the record to the posted entry / disposition. + /// - `retain_until` — the retention horizon (`None` ⇒ the store's default). + /// + /// # Errors + /// [`DbError`] on a storage / scope failure (rolls the disposition back — + /// the audit record and the books effect are atomic). + // Mirrors Slice 6 `SecuredAuditStore::append` signature verbatim (seam + // contract): the arity is fixed by the upstream port so the real store binds + // at merge with no call-site change — narrowing it here would break that. + #[allow(clippy::too_many_arguments)] + async fn append( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + event_type: AuditEventType, + actor_ref: Option<&str>, + reason_code: Option<&str>, + before_after: &serde_json::Value, + correlation_id: Option, + retain_until: Option>, + ) -> Result; +} + +/// The pre-Slice-6 implementation: records **nothing durable** (the +/// `secured_audit_record` table is Slice 6's migration). It logs the would-be +/// record at `info` and returns a fresh id, so the disposition completes and the +/// call is observable in traces. (An operator-visibility counter for dropped +/// secured records is a Slice-6 follow-up — none is wired today.) NO transaction +/// effect (it does +/// not touch `txn`), so it can never roll the disposition back. Wired in +/// `module` until the real `SecuredAuditStore` replaces it at merge. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoopSecuredAuditSink; + +impl NoopSecuredAuditSink { + #[must_use] + pub fn new() -> Self { + Self + } +} + +#[async_trait::async_trait] +impl SecuredAuditSink for NoopSecuredAuditSink { + // Mirrors Slice 6 `SecuredAuditStore::append` signature verbatim (seam + // contract) — the arity is fixed by the trait it implements. + #[allow(clippy::too_many_arguments)] + async fn append( + &self, + _txn: &DbTx<'_>, + _scope: &AccessScope, + tenant: Uuid, + event_type: AuditEventType, + actor_ref: Option<&str>, + reason_code: Option<&str>, + before_after: &serde_json::Value, + correlation_id: Option, + _retain_until: Option>, + ) -> Result { + let record_id = Uuid::now_v7(); + // No PII in the structured fields — ids + enum codes only. `before_after` + // is logged as a compact JSON string (the caller asserts it PII-clean). + tracing::info!( + secured_audit_record_id = %record_id, + tenant_id = %tenant, + event_type = event_type.as_str(), + actor_ref = actor_ref.unwrap_or(""), + reason_code = reason_code.unwrap_or(""), + correlation_id = ?correlation_id, + before_after = %before_after, + "bss-ledger: secured-audit append (NO-OP until Slice 6 — nothing durable persisted)" + ); + Ok(record_id) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[test] + fn audit_event_type_codes_are_screaming_snake() { + assert_eq!( + AuditEventType::ManualAdjustment.as_str(), + "MANUAL_ADJUSTMENT" + ); + assert_eq!(AuditEventType::ApprovalGranted.as_str(), "APPROVAL_GRANTED"); + assert_eq!( + AuditEventType::ApprovalRejected.as_str(), + "APPROVAL_REJECTED" + ); + assert_eq!(AuditEventType::Redaction.as_str(), "REDACTION"); + } + + #[test] + fn noop_sink_is_constructible_as_trait_object() { + // The sink is wired as `Arc` in `module` + the + // handler, so assert it is object-safe + default-constructible. + let sink: std::sync::Arc = + std::sync::Arc::new(NoopSecuredAuditSink::new()); + // A trait object whose only method needs a live `DbTx` can't be invoked + // here (no txn in a pure unit test); the postgres `#[ignore]` test drives + // the real append. This asserts the port is object-safe. + let _ = sink; + } +} diff --git a/gears/bss/ledger/ledger/src/infra/audit/store.rs b/gears/bss/ledger/ledger/src/infra/audit/store.rs new file mode 100644 index 000000000..2294119c7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/audit/store.rs @@ -0,0 +1,233 @@ +//! `SecuredAuditStore` — the append-only secured-audit writer. `append` +//! generates a v7 audit id, reads the tenant's audit-chain tip (scoped, no +//! lock; genesis seed if absent), computes the record's `row_hash` over the +//! canonical encoding linked to the tip's `prev_hash`, INSERTs the record born +//! sealed (`row_hash` / `prev_hash` `non-NULL` — the row is never updated later), +//! and advances the `audit_chain_state` tip (`INSERT … ON CONFLICT DO UPDATE`). +//! +//! Stateless — every method runs inside the caller's transaction (`txn`), so it +//! holds no `DBProvider` (mirrors +//! [`crate::infra::posting::idempotency::IdempotencyGate`] / +//! [`crate::infra::storage::repo::ChainStateRepo`]). Tenant isolation runs +//! through the `SecureORM` layer: reads use `.secure().scope_with(scope)`; +//! the record + tip inserts use `.secure().scope_with_model(scope, &am)`, which +//! validates the `ActiveModel`'s `tenant_id` against the scope so a mismatched +//! `(scope, tenant)` pair is rejected rather than silently written cross-tenant. +//! +//! ## Concurrency +//! +//! The tip read takes no row lock (the `SecureORM` builder exposes none; see +//! [`crate::infra::storage::repo::ChainStateRepo`] module docs). Two concurrent +//! appends onto the same tenant tip overlap on the `audit_chain_state` row they +//! both read-then-write, so a `SERIALIZABLE` transaction makes Postgres SSI +//! abort the loser (which retries from a fresh tip); the `tenant_id` primary key +//! makes the `ON CONFLICT` upsert itself atomic regardless. + +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::OnConflict; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::DbError; +use toolkit_db::secure::{AccessScope, DbTx, ScopeError, SecureEntityExt, SecureInsertExt}; +use uuid::Uuid; + +use crate::domain::audit_chain::{AuditHashInput, audit_genesis_prev_hash, audit_row_hash}; +use crate::infra::audit::event_type::AuditEventType; +use crate::infra::posting::service::infra; +use crate::infra::storage::entity::{audit_chain_state, secured_audit_record}; + +/// Map a [`ScopeError`] to [`DbError`] **preserving the inner `sea_orm::DbErr` +/// variant** (mirrors `period_close::scope_to_db`). This is load-bearing for +/// retry: a statement-time serialization failure (SSI 40001) surfaces as +/// `ScopeError::Db(DbErr::Exec | DbErr::Query)`; keeping that variant lets the +/// `transaction_with_retry` contention classifier recognise it and retry the +/// append from a fresh tip. Stringifying it (the old `infra(format!(…))` / +/// `RepoError::Db(format!(…))`) buried it in a `DbErr::Custom`, which the +/// classifier treats as the NON-retryable business sentinel — so two concurrent +/// appends on one tenant's audit-chain tip surfaced a statement-time abort as a +/// 500 instead of retrying. +fn scope_to_db(e: ScopeError) -> DbError { + match e { + ScopeError::Db(db_err) => DbError::Sea(db_err), + other => DbError::Other(anyhow::anyhow!("audit-store scope: {other}")), + } +} + +/// The append-only secured-audit store. Stateless (holds no `DBProvider`). +#[derive(Clone, Default)] +pub struct SecuredAuditStore; + +impl SecuredAuditStore { + #[must_use] + pub fn new() -> Self { + Self + } + + /// Append a sealed secured-audit record for `tenant` inside `txn`: link it + /// onto the tenant's audit-chain tip (or genesis), INSERT it born sealed, + /// and advance the tip. Returns the generated `audit_id`. + /// + /// # Errors + /// [`DbError`] on any storage / scope failure: a tip read/advance failure, a + /// tip `row_hash` that is not 32 bytes, or the INSERT. The `?`/`Err` rolls + /// the caller's transaction back. + #[allow( + clippy::too_many_arguments, + reason = "one sealed audit record's full field set (event/actor/reason/before_after/correlation/retention) over the caller's txn/scope" + )] + pub async fn append( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + event_type: AuditEventType, + actor_ref: Option<&str>, + reason_code: Option<&str>, + before_after: &serde_json::Value, + correlation_id: Option, + retain_until: Option>, + ) -> Result { + let audit_id = Uuid::now_v7(); + let at_utc = Utc::now(); + + // 1. Read the current tip (None at genesis). + let tip = self.read_tip(txn, scope, tenant).await?; + + // 2. The prev_hash is the tip's row_hash, or the tenant genesis seed. + let prev_hash: [u8; 32] = match &tip { + Some(t) => t.last_row_hash[..] + .try_into() + .map_err(|_| infra("audit chain tip row_hash not 32 bytes"))?, + None => audit_genesis_prev_hash(tenant), + }; + + // 3. Compute this record's row_hash over the canonical encoding. A + // serialize failure fails the append rather than sealing a + // record that hashes like an empty object. + let row_hash = audit_row_hash( + &AuditHashInput { + audit_id, + tenant_id: tenant, + event_type: event_type.as_str(), + actor_ref, + reason_code, + correlation_id, + at_utc, + before_after, + }, + &prev_hash, + ) + .map_err(|e| infra(format!("audit row_hash canonicalize: {e}")))?; + + // 4. INSERT the record born sealed (append-only — never UPDATEd later). + let am = secured_audit_record::ActiveModel { + audit_id: Set(audit_id), + tenant_id: Set(tenant), + event_type: Set(event_type.as_str().to_owned()), + actor_ref: Set(actor_ref.map(str::to_owned)), + reason_code: Set(reason_code.map(str::to_owned)), + before_after: Set(before_after.clone()), + correlation_id: Set(correlation_id), + row_hash: Set(row_hash.to_vec()), + prev_hash: Set(prev_hash.to_vec()), + at_utc: Set(at_utc), + retain_until: Set(retain_until), + }; + secured_audit_record::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(scope_to_db)? + .exec(txn) + .await + .map_err(scope_to_db)?; + + // 5. Advance the tip to this record's sealed values. + let next_seq = tip.as_ref().map_or(1, |t| t.last_seq + 1); + self.advance_tip(txn, scope, tenant, &row_hash, audit_id, next_seq) + .await?; + + Ok(audit_id) + } + + /// Read the audit-chain tip for `tenant` inside `txn` under `scope`. Returns + /// `None` at genesis (no tip row yet). Takes no row lock. + async fn read_tip( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + ) -> Result, DbError> { + audit_chain_state::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(audit_chain_state::Column::TenantId.eq(tenant))) + .one(txn) + .await + .map_err(scope_to_db) + } + + /// Advance (upsert) the audit-chain tip for `tenant`: + /// `INSERT … ON CONFLICT (tenant_id) DO UPDATE SET` the three tip columns. + async fn advance_tip( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + row_hash: &[u8; 32], + audit_id: Uuid, + seq: i64, + ) -> Result<(), DbError> { + let am = audit_chain_state::ActiveModel { + tenant_id: Set(tenant), + last_row_hash: Set(row_hash.to_vec()), + last_audit_id: Set(audit_id), + last_seq: Set(seq), + }; + let on_conflict = OnConflict::column(audit_chain_state::Column::TenantId) + .update_columns([ + audit_chain_state::Column::LastRowHash, + audit_chain_state::Column::LastAuditId, + audit_chain_state::Column::LastSeq, + ]) + .to_owned(); + + audit_chain_state::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(scope_to_db)? + .on_conflict_raw(on_conflict) + .exec(txn) + .await + .map_err(scope_to_db)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use sea_orm::DbErr; + + use super::{DbError, ScopeError, scope_to_db}; + + /// A `ScopeError::Db` keeps its inner `DbErr` variant — the property that + /// lets the retry classifier recognise a serialization failure + /// (`DbErr::Exec`/`Query`) instead of a stringified `DbErr::Custom`. + #[test] + fn scope_to_db_preserves_the_inner_dberr_variant() { + let mapped = scope_to_db(ScopeError::Db(DbErr::RecordNotFound("r".to_owned()))); + assert!( + matches!(mapped, DbError::Sea(DbErr::RecordNotFound(_))), + "ScopeError::Db must map to DbError::Sea preserving the variant, got {mapped:?}" + ); + } + + /// A non-`Db` scope error is a non-retryable infra fault → `DbError::Other` + /// (not the `DbErr::Custom` business sentinel, not retryable). + #[test] + fn scope_to_db_maps_non_db_scope_error_to_other() { + let mapped = scope_to_db(ScopeError::Invalid("bad scope")); + assert!( + matches!(mapped, DbError::Other(_)), + "a non-Db scope error must be DbError::Other, got {mapped:?}" + ); + } +} diff --git a/gears/bss/ledger/ledger/src/infra/authz.rs b/gears/bss/ledger/ledger/src/infra/authz.rs new file mode 100644 index 000000000..b4ea13094 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/authz.rs @@ -0,0 +1,8 @@ +//! Infrastructure authorization helpers for the ledger's audit surface. +//! +//! Hosts the cross-tenant elevation gateway (Slice 6 Phase 2 Group 2C): the +//! forensic-gated path that turns an explicit `targetScope` + role + reason into +//! a same-transaction `cross-tenant-access` secured-audit record before the +//! target tenant is ever read. + +pub mod cross_tenant; diff --git a/gears/bss/ledger/ledger/src/infra/authz/cross_tenant.rs b/gears/bss/ledger/ledger/src/infra/authz/cross_tenant.rs new file mode 100644 index 000000000..5e3628d37 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/authz/cross_tenant.rs @@ -0,0 +1,234 @@ +//! `CrossTenantGateway` — the forensic-gated cross-tenant read elevation (Slice +//! 6 Phase 2 Group 2C, architecture §8 / §G7). +//! +//! A routine audit read stays inside the caller's own (home) tenant. Opening a +//! DIFFERENT tenant's audit data is an elevated, forensic action: it requires an +//! explicit `targetScope`, an authorized role, AND an investigation reason. When +//! all three hold the gateway writes a `cross-tenant-access` secured-audit +//! record IN THE SAME TRANSACTION as the subsequent read — so the forensic trail +//! and the foreign read commit (or roll back) together. No reason, or an +//! unauthorized role, fails the request BEFORE any foreign row is read. +//! +//! The forensic record is written under a scope for the HOME tenant (the actor's +//! own tenant): the actor's tenant owns the cross-tenant-access record, and the +//! audit chain it links onto is the home tenant's chain. The returned +//! [`AccessScope`] is then the TARGET tenant's scope, which the caller binds to +//! the foreign read. + +use std::sync::Arc; + +use toolkit_db::DbError; +use toolkit_db::secure::{AccessScope, DbTx}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::ports::metrics::{LedgerMetricsPort, NoopLedgerMetrics}; +use crate::infra::audit::event_type::AuditEventType; +use crate::infra::audit::store::SecuredAuditStore; +use crate::infra::posting::service::business; + +/// The tenant a cross-tenant audit read targets (the `payerTenantId` / tenant +/// being opened). MVP carries just the `tenant_id`; a richer scope (legal +/// entity, period) is a future extension. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TargetScope { + /// The tenant whose audit data is being opened. + pub tenant_id: Uuid, +} + +/// The cross-tenant elevation gateway. Holds the append-only +/// [`SecuredAuditStore`] (stateless); the forensic record is appended inside the +/// caller's transaction. +#[derive(Clone)] +pub struct CrossTenantGateway { + audit: SecuredAuditStore, + metrics: Arc, +} + +impl Default for CrossTenantGateway { + fn default() -> Self { + Self::new() + } +} + +impl CrossTenantGateway { + #[must_use] + pub fn new() -> Self { + Self { + audit: SecuredAuditStore::new(), + metrics: Arc::new(NoopLedgerMetrics), + } + } + + /// Bind the §9 metrics sink (`ledger_cross_tenant_access_total{reason_code}` + /// is emitted on each successful elevation). Defaults to no-op until wired. + #[must_use] + pub fn with_metrics(mut self, metrics: Arc) -> Self { + self.metrics = metrics; + self + } + + /// Resolve the [`AccessScope`] a forensic audit read runs under, writing a + /// `cross-tenant-access` record first when (and only when) the read crosses + /// a tenant boundary. Runs inside the caller's `txn` so the record and the + /// subsequent read share one transaction. + /// + /// Branch order (security-critical — keep it exactly): + /// 1. **routine**: `target` is `None`, or names the home tenant → return the + /// home scope, write NO forensic record. + /// 2. **denied**: a cross-tenant target with `role_authorized == false` → + /// [`DomainError::CrossTenantAccessDenied`] (before any read or write). + /// 3. **missing-reason**: a cross-tenant target whose `reason` / + /// `reason_code` is missing or empty → [`DomainError::MissingInvestigationReason`] + /// (before any read or write — the forensic record is never half-written). + /// 4. **elevated + record**: append the `cross-tenant-access` record under + /// the HOME tenant scope, then return the TARGET scope. A failed append + /// propagates and fails the whole request. + /// + /// # Errors + /// A sentinel [`DbError`] carrying [`DomainError::CrossTenantAccessDenied`] + /// (role) or [`DomainError::MissingInvestigationReason`] (reason); an + /// infrastructure [`DbError`] when the forensic audit append fails (rolls the + /// caller's transaction back). + #[allow( + clippy::too_many_arguments, + reason = "the full elevation contract (home/target/role/actor/reason/reason_code/correlation) threaded into one txn-scoped decision" + )] + pub async fn resolve_read_scope( + &self, + txn: &DbTx<'_>, + home_tenant: Uuid, + target: Option, + role_authorized: bool, + actor_ref: &str, + reason: Option<&str>, + reason_code: Option<&str>, + correlation_id: Option, + ) -> Result { + // 1. Routine: no target, or the target is the caller's own tenant. No + // forensic record — this is an ordinary same-tenant audit read. + let Some(target) = target.filter(|t| t.tenant_id != home_tenant) else { + return Ok(AccessScope::for_tenant(home_tenant)); + }; + + // 2. Denied: the caller's role is not authorized to cross the boundary. + // Fail BEFORE any read or any forensic write. + if !role_authorized { + return Err(business(DomainError::CrossTenantAccessDenied(format!( + "subject not authorized to open audit data for tenant {}", + target.tenant_id + )))); + } + + // 3. Missing-reason: a cross-tenant read must carry an investigation + // reason (both a free-text `reason` and a machine `reason_code`). + // Fail BEFORE any read or any forensic write, so a reason-less request + // never leaves a half-written record. + let reason = reason.map(str::trim).filter(|s| !s.is_empty()); + let reason_code = reason_code.map(str::trim).filter(|s| !s.is_empty()); + let (Some(reason), Some(reason_code)) = (reason, reason_code) else { + return Err(business(DomainError::MissingInvestigationReason(format!( + "a cross-tenant audit read of tenant {} requires both an \ + X-Investigation-Reason and a reasonCode", + target.tenant_id + )))); + }; + + // 4. Elevated + record: append the forensic `cross-tenant-access` record + // under the HOME tenant scope (the actor's tenant owns the record and + // its chain), THEN return the TARGET scope for the foreign read. A + // failed append propagates and fails the whole request. + let home_scope = AccessScope::for_tenant(home_tenant); + let before_after = serde_json::json!({ + "targetScope": { "tenantId": target.tenant_id }, + "reason": reason, + }); + self.audit + .append( + txn, + &home_scope, + home_tenant, + AuditEventType::CrossTenantAccess, + Some(actor_ref), + Some(reason_code), + &before_after, + correlation_id, + None, + ) + // Propagate the append's `DbError` as-is — re-wrapping it (the old + // `infra(format!(…))`) would bury a retryable SSI serialization + // failure in a non-retryable `DbErr::Custom`. + .await?; + + // §9: count the successful cross-tenant elevation, keyed by reason_code + // (`ledger_cross_tenant_access_total{reason_code}`). Emitted only after + // the forensic record committed in this txn, so the metric never + // over-counts a rejected/half-written elevation. + self.metrics.cross_tenant_access(reason_code); + Ok(AccessScope::for_tenant(target.tenant_id)) + } +} + +/// Resolve the DATA scope a forensic **write** action (erasure / re-identification) +/// runs against, applying the same cross-tenant elevation contract as +/// [`CrossTenantGateway::resolve_read_scope`] — but WITHOUT writing a +/// `cross-tenant-access` record. These actions write their own typed forensic +/// record (`erasure` / `re-identification`) inside their service, in the same +/// transaction as the mutation, so the `cross-tenant-access` event would be a +/// duplicate. Centralizing the decision here keeps the elevation contract — and +/// its branch order — in one place instead of open-coded per handler. +/// +/// `role_authorized` is the caller's PEP decision for the TARGET tenant (the +/// handler computes it; the routine path passes `true`). `home_scope` is the +/// caller's own PEP-derived scope, returned unchanged for the routine path. +/// +/// Branch order (security-critical — identical to the read gateway): +/// 1. **routine**: `target` is `None` or names the home tenant → `(home_tenant, +/// home_scope)`, no elevation. +/// 2. **denied**: cross-tenant target with `role_authorized == false` → +/// [`DomainError::CrossTenantAccessDenied`] (role checked BEFORE reason, §5). +/// 3. **missing-reason**: cross-tenant target with an empty/missing `reason` → +/// [`DomainError::MissingInvestigationReason`]. +/// 4. **elevated**: `(target, AccessScope::for_tenant(target))`. +/// +/// # Errors +/// [`DomainError::CrossTenantAccessDenied`] or [`DomainError::MissingInvestigationReason`]. +pub fn resolve_action_scope( + home_tenant: Uuid, + home_scope: &AccessScope, + target: Option, + role_authorized: bool, + reason: Option<&str>, +) -> Result<(Uuid, AccessScope), DomainError> { + let Some(target) = target.filter(|t| t.tenant_id != home_tenant) else { + return Ok((home_tenant, home_scope.clone())); + }; + if !role_authorized { + return Err(DomainError::CrossTenantAccessDenied(format!( + "subject not authorized to open tenant {} for this action", + target.tenant_id + ))); + } + if reason.is_none_or(|s| s.trim().is_empty()) { + return Err(DomainError::MissingInvestigationReason(format!( + "a cross-tenant action on tenant {} requires an X-Investigation-Reason", + target.tenant_id + ))); + } + Ok((target.tenant_id, AccessScope::for_tenant(target.tenant_id))) +} + +/// Routine subtree read scope — the DEGRADED FALLBACK seam (architecture §8 / +/// §G7). Today it returns the caller's OWN tenant scope only: the "resolver +/// unavailable → own tenant only, flagged degraded" behavior. The drop-in future +/// is a `TenantResolverClient` (in `BarrierMode::Respect`) that expands the home +/// tenant to its authorized subtree; that wiring is deliberately omitted here to +/// keep the gear hermetic (no `tenant-resolver` dependency in tests). +#[must_use] +pub fn subtree_read_scope(home_tenant: Uuid) -> AccessScope { + AccessScope::for_tenant(home_tenant) +} + +#[cfg(test)] +#[path = "cross_tenant_tests.rs"] +mod cross_tenant_tests; diff --git a/gears/bss/ledger/ledger/src/infra/authz/cross_tenant_tests.rs b/gears/bss/ledger/ledger/src/infra/authz/cross_tenant_tests.rs new file mode 100644 index 000000000..57403f0d1 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/authz/cross_tenant_tests.rs @@ -0,0 +1,112 @@ +//! Unit tests for the pure parts of the cross-tenant elevation gateway: the +//! degraded-fallback `subtree_read_scope`, the `TargetScope` value, and the +//! shared `resolve_action_scope` elevation contract (branch order). The +//! transaction-bound branches (`resolve_read_scope`) need a database and are +//! exercised by `tests/postgres_cross_tenant.rs`. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use toolkit_db::secure::AccessScope; +use toolkit_security::pep_properties; +use uuid::Uuid; + +use super::{TargetScope, resolve_action_scope, subtree_read_scope}; +use crate::domain::error::DomainError; + +#[test] +fn subtree_read_scope_returns_home_tenant_only() { + let home = Uuid::now_v7(); + let other = Uuid::now_v7(); + let scope = subtree_read_scope(home); + // The degraded fallback authorizes the home tenant and nothing else. + assert!( + scope.contains_uuid(pep_properties::OWNER_TENANT_ID, home), + "subtree fallback must authorize the home tenant" + ); + assert!( + !scope.contains_uuid(pep_properties::OWNER_TENANT_ID, other), + "subtree fallback must NOT authorize any other tenant (own-tenant-only)" + ); +} + +#[test] +fn target_scope_carries_tenant_id() { + let t = Uuid::now_v7(); + let target = TargetScope { tenant_id: t }; + assert_eq!(target.tenant_id, t); +} + +#[test] +fn resolve_action_scope_routine_returns_home_scope() { + let home = Uuid::now_v7(); + let home_scope = AccessScope::for_tenant(home); + // No target → routine; no reason required. + let (t, _) = resolve_action_scope(home, &home_scope, None, true, None).unwrap(); + assert_eq!(t, home); + // Target == home → routine. + let (t2, _) = resolve_action_scope( + home, + &home_scope, + Some(TargetScope { tenant_id: home }), + true, + None, + ) + .unwrap(); + assert_eq!(t2, home); +} + +#[test] +fn resolve_action_scope_checks_role_before_reason() { + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + let home_scope = AccessScope::for_tenant(home); + // Unauthorized AND no reason → the role denial wins (role checked first, §5), + // so an unauthorized caller never learns whether a reason would have sufficed. + let err = resolve_action_scope( + home, + &home_scope, + Some(TargetScope { tenant_id: target }), + false, + None, + ) + .unwrap_err(); + assert!(matches!(err, DomainError::CrossTenantAccessDenied(_))); +} + +#[test] +fn resolve_action_scope_requires_reason_when_authorized() { + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + let home_scope = AccessScope::for_tenant(home); + for reason in [None, Some(""), Some(" ")] { + let err = resolve_action_scope( + home, + &home_scope, + Some(TargetScope { tenant_id: target }), + true, + reason, + ) + .unwrap_err(); + assert!( + matches!(err, DomainError::MissingInvestigationReason(_)), + "empty/blank reason must be rejected: {reason:?}" + ); + } +} + +#[test] +fn resolve_action_scope_elevates_to_target() { + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + let home_scope = AccessScope::for_tenant(home); + let (t, scope) = resolve_action_scope( + home, + &home_scope, + Some(TargetScope { tenant_id: target }), + true, + Some("dispute #4821"), + ) + .unwrap(); + assert_eq!(t, target); + assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, target)); + assert!(!scope.contains_uuid(pep_properties::OWNER_TENANT_ID, home)); +} diff --git a/gears/bss/ledger/ledger/src/infra/control_feed.rs b/gears/bss/ledger/ledger/src/infra/control_feed.rs new file mode 100644 index 000000000..ade838f4c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/control_feed.rs @@ -0,0 +1,120 @@ +//! In-process control-feed store (Slice 7 Phase 3, design §0 decision 3 / §3.4). +//! The v1 default source for the three launch-blocking control feeds (issued-invoice +//! manifest, bill-run-finished, PSP settlement report): the REST `…/control/*` ingest +//! endpoints push into it, the `ReconciliationFramework` + close gate read it back. Empty +//! ⇒ every read is `None` ⇒ the corresponding check is inert (the design's +//! inert-until-the-feed-lands contract). A real external adapter-gear, when present in +//! `ClientHub`, overrides this per port. No durable table (control feeds are not posting +//! sources, design §1.2; Phase 3 adds no tables). + +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; +use bss_ledger_sdk::{ + BillRunFinishedV1, ControlFeedError, IssuedInvoiceManifest, IssuedInvoiceManifestV1, + PspSettlementFeedV1, PspSettlementReport, +}; +use uuid::Uuid; + +/// In-process, per-`(tenant, period)` store backing all three control-feed ports. The +/// REST ingest endpoints push via `ingest_*`; the framework / close gate read via the +/// SDK port traits. Default-constructed empty ⇒ every read returns `Ok(None)`. +/// +/// Each feed has its own `Mutex>`: a control feed carries no cross-feed +/// invariant (the gate reads each independently), so independent locks avoid coupling +/// an ingest on one feed to a read on another. A poisoned lock here is non-fatal data +/// (last writer wins, no money invariant), so we recover the guard rather than panic. +#[derive(Default)] +pub struct InProcessControlFeeds { + manifests: Mutex>, + bill_runs: Mutex>, + psp_reports: Mutex>, +} + +impl InProcessControlFeeds { + /// An empty store (every read is `None` until something is ingested). + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Push the latest issued-invoice manifest for `(tenant, period)` (REST ingest). + /// Last writer wins — the feed is a snapshot, not an append log. + pub fn ingest_manifest(&self, tenant: Uuid, period: &str, manifest: IssuedInvoiceManifest) { + self.manifests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert((tenant, period.to_owned()), manifest); + } + + /// Push the owning Orchestration's bill-run-finished assertion for + /// `(tenant, period)` (REST ingest). Last writer wins. + pub fn ingest_bill_run_finished(&self, tenant: Uuid, period: &str, finished: bool) { + self.bill_runs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert((tenant, period.to_owned()), finished); + } + + /// Push the PSP settlement report for `(tenant, period)` (REST ingest). Last + /// writer wins (the `report_id` carries the external idempotency grain). + pub fn ingest_psp_report(&self, tenant: Uuid, period: &str, report: PspSettlementReport) { + self.psp_reports + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert((tenant, period.to_owned()), report); + } +} + +#[async_trait] +impl IssuedInvoiceManifestV1 for InProcessControlFeeds { + async fn latest_manifest( + &self, + tenant: Uuid, + period: &str, + ) -> Result, ControlFeedError> { + Ok(self + .manifests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&(tenant, period.to_owned())) + .cloned()) + } +} + +#[async_trait] +impl BillRunFinishedV1 for InProcessControlFeeds { + async fn is_finished( + &self, + tenant: Uuid, + period: &str, + ) -> Result, ControlFeedError> { + Ok(self + .bill_runs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&(tenant, period.to_owned())) + .copied()) + } +} + +#[async_trait] +impl PspSettlementFeedV1 for InProcessControlFeeds { + async fn settlement_report( + &self, + tenant: Uuid, + period: &str, + ) -> Result, ControlFeedError> { + Ok(self + .psp_reports + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&(tenant, period.to_owned())) + .cloned()) + } +} + +#[cfg(test)] +#[path = "control_feed_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/control_feed_tests.rs b/gears/bss/ledger/ledger/src/infra/control_feed_tests.rs new file mode 100644 index 000000000..d4400bd60 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/control_feed_tests.rs @@ -0,0 +1,167 @@ +use bss_ledger_sdk::{ + BillRunFinishedV1, IssuedInvoiceManifest, IssuedInvoiceManifestV1, PspSettlementFeedV1, + PspSettlementReport, +}; +use uuid::Uuid; + +use super::InProcessControlFeeds; + +fn manifest() -> IssuedInvoiceManifest { + IssuedInvoiceManifest { + invoice_ids: vec!["inv-1".to_owned(), "inv-2".to_owned()], + count: 2, + gross_total_minor: 4_200, + } +} + +fn report() -> PspSettlementReport { + PspSettlementReport { + report_id: "psp-rep-1".to_owned(), + settled_minor: 9_900, + currency: "EUR".to_owned(), + } +} + +// --- empty store: every read is None (the inert-until-the-feed-lands contract) --- + +#[tokio::test] +async fn empty_store_manifest_is_none() { + let feeds = InProcessControlFeeds::new(); + assert_eq!( + feeds + .latest_manifest(Uuid::now_v7(), "2026-06") + .await + .unwrap(), + None + ); +} + +#[tokio::test] +async fn empty_store_bill_run_is_none() { + let feeds = InProcessControlFeeds::new(); + assert_eq!( + feeds.is_finished(Uuid::now_v7(), "2026-06").await.unwrap(), + None + ); +} + +#[tokio::test] +async fn empty_store_psp_report_is_none() { + let feeds = InProcessControlFeeds::new(); + assert_eq!( + feeds + .settlement_report(Uuid::now_v7(), "2026-06") + .await + .unwrap(), + None + ); +} + +// --- ingest then read returns exactly the value pushed --- + +#[tokio::test] +async fn ingest_then_read_manifest() { + let feeds = InProcessControlFeeds::new(); + let tenant = Uuid::now_v7(); + feeds.ingest_manifest(tenant, "2026-06", manifest()); + assert_eq!( + feeds.latest_manifest(tenant, "2026-06").await.unwrap(), + Some(manifest()) + ); +} + +#[tokio::test] +async fn ingest_then_read_bill_run() { + let feeds = InProcessControlFeeds::new(); + let tenant = Uuid::now_v7(); + feeds.ingest_bill_run_finished(tenant, "2026-06", true); + assert_eq!( + feeds.is_finished(tenant, "2026-06").await.unwrap(), + Some(true) + ); +} + +#[tokio::test] +async fn ingest_then_read_psp_report() { + let feeds = InProcessControlFeeds::new(); + let tenant = Uuid::now_v7(); + feeds.ingest_psp_report(tenant, "2026-06", report()); + assert_eq!( + feeds.settlement_report(tenant, "2026-06").await.unwrap(), + Some(report()) + ); +} + +// --- last writer wins (the feed is a snapshot, not an append log) --- + +#[tokio::test] +async fn ingest_overwrites_prior_manifest() { + let feeds = InProcessControlFeeds::new(); + let tenant = Uuid::now_v7(); + feeds.ingest_manifest(tenant, "2026-06", manifest()); + let newer = IssuedInvoiceManifest { + invoice_ids: vec!["inv-9".to_owned()], + count: 1, + gross_total_minor: 100, + }; + feeds.ingest_manifest(tenant, "2026-06", newer.clone()); + assert_eq!( + feeds.latest_manifest(tenant, "2026-06").await.unwrap(), + Some(newer) + ); +} + +// --- per-(tenant, period) isolation: a push for one key never bleeds into another --- + +#[tokio::test] +async fn manifest_is_isolated_per_tenant() { + let feeds = InProcessControlFeeds::new(); + let a = Uuid::now_v7(); + let b = Uuid::now_v7(); + feeds.ingest_manifest(a, "2026-06", manifest()); + assert_eq!( + feeds.latest_manifest(a, "2026-06").await.unwrap(), + Some(manifest()) + ); + assert_eq!(feeds.latest_manifest(b, "2026-06").await.unwrap(), None); +} + +#[tokio::test] +async fn manifest_is_isolated_per_period() { + let feeds = InProcessControlFeeds::new(); + let tenant = Uuid::now_v7(); + feeds.ingest_manifest(tenant, "2026-06", manifest()); + assert_eq!( + feeds.latest_manifest(tenant, "2026-06").await.unwrap(), + Some(manifest()) + ); + assert_eq!( + feeds.latest_manifest(tenant, "2026-07").await.unwrap(), + None + ); +} + +#[tokio::test] +async fn bill_run_is_isolated_per_tenant_period() { + let feeds = InProcessControlFeeds::new(); + let a = Uuid::now_v7(); + let b = Uuid::now_v7(); + feeds.ingest_bill_run_finished(a, "2026-06", true); + assert_eq!(feeds.is_finished(a, "2026-06").await.unwrap(), Some(true)); + // Same tenant, other period and other tenant, same period: both untouched. + assert_eq!(feeds.is_finished(a, "2026-07").await.unwrap(), None); + assert_eq!(feeds.is_finished(b, "2026-06").await.unwrap(), None); +} + +#[tokio::test] +async fn psp_report_is_isolated_per_tenant() { + let feeds = InProcessControlFeeds::new(); + let a = Uuid::now_v7(); + let b = Uuid::now_v7(); + feeds.ingest_psp_report(a, "2026-06", report()); + assert_eq!( + feeds.settlement_report(a, "2026-06").await.unwrap(), + Some(report()) + ); + assert_eq!(feeds.settlement_report(b, "2026-06").await.unwrap(), None); +} diff --git a/gears/bss/ledger/ledger/src/infra/currency_scale.rs b/gears/bss/ledger/ledger/src/infra/currency_scale.rs new file mode 100644 index 000000000..aeaf57037 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/currency_scale.rs @@ -0,0 +1,50 @@ +//! Resolves the minor-unit scale for a (tenant, currency): registry row +//! first (tenant overrides + non-ISO codes), then the ISO-4217 default; +//! a non-ISO currency with no row is an error (no implicit scale). + +use toolkit_db::secure::AccessScope; +use uuid::Uuid; + +use crate::domain::money::ScaleError; +use crate::domain::scale::iso_default_scale; +use crate::infra::storage::repo::ReferenceRepo; + +/// Registry-backed currency-scale resolver. +pub struct CurrencyScaleResolver { + reference: ReferenceRepo, +} + +impl CurrencyScaleResolver { + #[must_use] + pub fn new(reference: ReferenceRepo) -> Self { + Self { reference } + } + + /// Resolve the scale for `(tenant_id, currency)`: a registry row wins, + /// else the ISO-4217 default, else [`ScaleError::UnknownCurrencyScale`]. + /// + /// # Errors + /// [`ScaleError::Repo`] on a storage failure; [`ScaleError::UnknownCurrencyScale`] + /// for a non-ISO currency with no registry row; [`ScaleError::CorruptStoredScale`] + /// when a registry row's stored scale is itself out of range. + pub async fn resolve( + &self, + scope: &AccessScope, + tenant_id: Uuid, + currency: &str, + ) -> Result { + if let Some(row) = self + .reference + .find_currency_scale(scope, tenant_id, currency) + .await + .map_err(|e| ScaleError::Repo(e.to_string()))? + { + return u8::try_from(row.minor_units).map_err(|_| ScaleError::CorruptStoredScale { + currency: currency.to_owned(), + minor_units: row.minor_units, + }); + } + iso_default_scale(currency) + .ok_or_else(|| ScaleError::UnknownCurrencyScale(currency.to_owned())) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/error_mapping.rs b/gears/bss/ledger/ledger/src/infra/error_mapping.rs new file mode 100644 index 000000000..32d6111a7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/error_mapping.rs @@ -0,0 +1,388 @@ +//! The SINGLE authoritative `DomainError` → AIP-193 `CanonicalError` ladder +//! (ADR-0005). Both surfaces consume it: REST handlers via `?`/explicit map, +//! and the in-process `LedgerClientV1` via `.map_err(CanonicalError::from)`. +//! There is no parallel `DomainError → LedgerError` path — the SDK's typed +//! `LedgerError` is projected from `CanonicalError`, so a domain variant is +//! assigned a canonical category in exactly one place: here. +//! +//! Wire codes (the `field_violation` reason / `precondition` type / `aborted` +//! reason / `quota` subject strings) are the machine-readable discriminators a +//! consumer matches on after the coarse category; they mirror the old +//! `LedgerErrorCode` `SCREAMING_SNAKE` names so the contract is unchanged. + +use toolkit::api::canonical_prelude::{CanonicalError, resource_error}; + +use crate::domain::error::DomainError; + +#[resource_error("gts.cf.bss.ledger.entry.v1~")] +struct EntryResource; +#[resource_error("gts.cf.bss.ledger.ledger.v1~")] +struct LedgerResource; +#[resource_error("gts.cf.bss.ledger.fiscal_period.v1~")] +struct FiscalPeriodResource; + +impl From for CanonicalError { + #[allow( + clippy::too_many_lines, + reason = "one match arm per DomainError variant — the error taxonomy is flat by design" + )] + fn from(err: DomainError) -> Self { + use DomainError as D; + match err { + // ── InvalidArgument ── + D::Unbalanced(d) => EntryResource::invalid_argument() + .with_field_violation("lines", d, "LEDGER_ENTRY_UNBALANCED") + .create(), + D::Empty(d) => EntryResource::invalid_argument() + .with_field_violation("lines", d, "LEDGER_ENTRY_EMPTY") + .create(), + D::MixedPayer(d) => EntryResource::invalid_argument() + .with_field_violation("lines", d, "MIXED_PAYER_TENANT") + .create(), + D::MissingPayer(d) => EntryResource::invalid_argument() + .with_field_violation("lines", d, "MISSING_PAYER") + .create(), + D::MixedLegalEntity(d) => EntryResource::invalid_argument() + .with_field_violation("lines", d, "MIXED_LEGAL_ENTITY") + .create(), + D::InconsistentScale(d) => EntryResource::invalid_argument() + .with_field_violation("lines", d, "AMOUNT_OUT_OF_RANGE") + .create(), + D::AmountOutOfRange(d) => EntryResource::invalid_argument() + .with_field_violation("amount_minor", d, "AMOUNT_OUT_OF_RANGE") + .create(), + D::EntryTooLarge(d) => EntryResource::invalid_argument() + .with_field_violation("lines", d, "LEDGER_ENTRY_TOO_LARGE") + .create(), + D::InvalidRequest(d) => LedgerResource::invalid_argument() + .with_constraint(d) + .create(), + D::ScaleOutOfRange(d) => LedgerResource::invalid_argument() + .with_field_violation("currency_scales", d, "CURRENCY_SCALE_OUT_OF_RANGE") + .create(), + D::CreditResidualUndisposed(d) => EntryResource::invalid_argument() + .with_field_violation("lines", d, "CREDIT_RESIDUAL_UNDISPOSED") + .create(), + D::AllocationTooLarge(d) => EntryResource::invalid_argument() + .with_field_violation("splits", d, "ALLOCATION_TOO_LARGE") + .create(), + D::AllocationCurrencyMismatch(d) => EntryResource::invalid_argument() + .with_field_violation("currency", d, "ALLOCATION_CURRENCY_MISMATCH") + .create(), + D::CurrencyMismatch(d) => EntryResource::invalid_argument() + .with_field_violation("currency", d, "CURRENCY_MISMATCH") + .create(), + // FX rate stale + tenant forbids fallback. Design asks for 422; the + // platform CanonicalError ladder has no 422, so it lands on + // InvalidArgument (400) like the other config-gap codes. + D::FxRateStaleNotAllowed(d) => EntryResource::invalid_argument() + .with_field_violation("rate_snapshot_ref", d, "FX_RATE_STALE_NOT_ALLOWED") + .create(), + D::AllocationSplitInvalid(d) => EntryResource::invalid_argument() + .with_field_violation("splits", d, "ALLOCATION_SPLIT_INVALID") + .create(), + D::ScheduleTooLong(d) => EntryResource::invalid_argument() + .with_field_violation("segments", d, "SCHEDULE_TOO_LONG") + .create(), + D::SspSnapshotRequired(d) => EntryResource::invalid_argument() + .with_field_violation("ssp_snapshot_ref", d, "SSP_SNAPSHOT_REQUIRED") + .create(), + D::MissingPoAllocationGroup(d) => EntryResource::invalid_argument() + .with_field_violation("po_allocation_group", d, "MISSING_PO_ALLOCATION_GROUP") + .create(), + D::RecognitionPolicyConflict(d) => EntryResource::invalid_argument() + .with_field_violation("policy_ref", d, "RECOGNITION_POLICY_CONFLICT") + .create(), + // The recognized-vs-deferred split basis is indeterminable + // (block-on-ambiguous, design §4.2 — never a silent pro-rata). A + // design-422; like the other adjustment/recognition config-gap codes + // it lands on the InvalidArgument category (400) — the platform has no + // 422. The Group C handler additionally raises the + // `CreditNoteSplitBlocked` alarm + an exception stub (Slice 7). + D::CreditNoteSplitAmbiguous(d) => EntryResource::invalid_argument() + .with_field_violation("split", d, "CREDIT_NOTE_SPLIT_AMBIGUOUS") + .create(), + // A credit note (incl. tax) would push `credit_note_total_minor` past + // the invoice's headroom `original_total + Σ debit notes − Σ prior + // credit notes` — the `invoice_exposure` CHECK rejected the in-txn + // bump (design §4.2 / §4.7, AC #24). The design asks for a 422; like + // the other adjustment/recognition config-gap codes it lands on the + // InvalidArgument category (400) — the platform has no 422. (It is the + // headroom CAP, NOT a retriable balance race, so it is an + // InvalidArgument 400 rather than the ABORTED 409 the money-out caps + // use: an over-cap credit note must route via goodwill/non-revenue, + // never silently retried through S3 — design §4.2.) + D::CreditNoteExceedsHeadroom(d) => EntryResource::invalid_argument() + .with_field_violation("amount_minor", d, "CREDIT_NOTE_EXCEEDS_HEADROOM") + .create(), + // A refund stage-1 initiation whose cap would be exceeded: the total + // money-out cap (`refunded + clawed_back <= settled`, or the + // Pattern-A spendable-headroom `allocated + refunded_unallocated <= + // settled`) for `RefundExceedsSettled`, the Pattern-B per-`(payment, + // invoice)` cap (`refunded <= allocated`) for `RefundExceedsAllocated`. + // The `payment_settlement` / `payment_allocation_refund` CHECK rejected + // the in-txn increment under the rank-1 lock, BEFORE the cash left + // (design §4.4 / §4.7, §5). The design asks for a 422; like the other + // adjustment cap codes it lands on the InvalidArgument category (400) — + // the platform `CanonicalError` ladder has no 422. (An over-refund is a + // hard cap, not a retriable balance race, so it is InvalidArgument 400 + // rather than the ABORTED 409 the allocate/chargeback money-out caps + // use — a refund over the settled/allocated amount must be corrected, + // never silently retried.) + D::RefundExceedsSettled(d) => EntryResource::invalid_argument() + .with_field_violation("amount_minor", d, "REFUND_EXCEEDS_SETTLED") + .create(), + D::RefundExceedsAllocated(d) => EntryResource::invalid_argument() + .with_field_violation("amount_minor", d, "REFUND_EXCEEDS_ALLOCATED") + .create(), + // A schedule modification arrived with a `catch_up` / unknown + // treatment: the ledger does not own the catch-up decision and never + // silently applies a modification prospectively (design §3.6), so it + // surfaces for upstream review rather than mutating schedule state. + // The platform has no 422, so this design-422 lands on the + // InvalidArgument category (400) like the other recognition config-gap + // codes (`SCHEDULE_TOO_LONG` / `SSP_SNAPSHOT_REQUIRED` / …). + D::ModificationTreatmentReview(d) => EntryResource::invalid_argument() + .with_field_violation("treatment", d, "MODIFICATION_TREATMENT_REVIEW") + .create(), + // A deferred recognition line was assembled without a resolvable + // `source_invoice_item_ref` (the §4.7 invoice-link invariant — a + // deferred Contract-liability balance must anchor to the invoice line + // it draws down). Blocked BEFORE the post (no orphan schedule). Like + // the other recognition config-gap codes this design-422 lands on the + // InvalidArgument category (400) — the platform has no 422. + D::RecognitionWithoutInvoiceLink(d) => EntryResource::invalid_argument() + .with_field_violation( + "source_invoice_item_ref", + d, + "RECOGNITION_WITHOUT_INVOICE_LINK", + ) + .create(), + // A governed manual adjustment failed the code-owned allow-list, the + // global REVENUE/CONTRACT_LIABILITY ban, or the write-off structural + // guard (design §4.6 / Rev3 S3-minor). Both the generic + // `ManualAdjustmentReject::NotAllowed` and the `AttemptedWriteOff` + // signal collapse onto this one variant; the design asks for a 422 but + // the platform `CanonicalError` ladder has no 422, so — like the other + // adjustment config-gap codes — it lands on the InvalidArgument + // category (400). The Group 3 handler additionally treats the + // write-off SHAPE as an alarm: a `SecuredAuditSink` capture + page + // (`AttemptedWriteOff`) fired out-of-band, exactly as the credit-note + // split-block code raises its alarm. + D::ManualAdjustmentNotAllowed(d) => EntryResource::invalid_argument() + .with_field_violation("lines", d, "MANUAL_ADJUSTMENT_NOT_ALLOWED") + .create(), + // Group 2B controlled-metadata guard: the PATCH value carried raw + // customer PII (an email / phone / payment number, or a prohibited + // key). The audit chain is append-only, so the value is screened + // before any write. A 400 `InvalidArgument` carrying the + // `PII_IN_METADATA_VALUE` wire code on the `value` field. + D::PiiInMetadataValue(d) => EntryResource::invalid_argument() + .with_field_violation("value", d, "PII_IN_METADATA_VALUE") + .create(), + // Group 2C cross-tenant elevation guard: a cross-tenant audit read + // arrived without an investigation reason (`reason` + `reason_code`). + // Architecturally a 422 Unprocessable Entity; the toolkit + // CanonicalError model has no 422 category, so this is a + // `FailedPrecondition` (HTTP 400) carrying the + // `MISSING_INVESTIGATION_REASON` wire code. The code is the + // discriminator consumers match on. + D::MissingInvestigationReason(d) => EntryResource::failed_precondition() + .with_precondition_violation( + "investigation_reason", + d, + "MISSING_INVESTIGATION_REASON", + ) + .create(), + + // ── PermissionDenied ── + // Group 2C cross-tenant elevation guard: the caller's role is not + // authorized to open another tenant's audit data. A 403 + // `PermissionDenied` carrying the `CROSS_TENANT_ACCESS_DENIED` wire + // code (mirrors `authz_error_to_canonical`, which also carries the + // deny reason on a `permission_denied`). + D::CrossTenantAccessDenied(_d) => EntryResource::permission_denied() + .with_reason("CROSS_TENANT_ACCESS_DENIED") + .create(), + + // ── FailedPrecondition ── + D::PeriodClosed(d) => FiscalPeriodResource::failed_precondition() + .with_precondition_violation("fiscal_period", d, "PERIOD_CLOSED") + .create(), + D::AccountClosed(d) => EntryResource::failed_precondition() + .with_precondition_violation("account", d, "ACCOUNT_CLOSED") + .create(), + D::AccountMappingMissing(d) => EntryResource::failed_precondition() + .with_precondition_violation("account_mapping", d, "ACCOUNT_MAPPING_MISSING") + .create(), + D::PayerClosed(d) => EntryResource::failed_precondition() + .with_precondition_violation("payer", d, "PAYER_CLOSED") + .create(), + D::NegativeBalance(d) => EntryResource::failed_precondition() + .with_precondition_violation("account_balance", d, "NEGATIVE_BALANCE_VIOLATION") + .create(), + D::SettlementReturnOverAllocated(d) => EntryResource::failed_precondition() + .with_precondition_violation("settled_minor", d, "SETTLEMENT_RETURN_OVER_ALLOCATED") + .create(), + D::InvalidDisputeTransition(d) => EntryResource::failed_precondition() + .with_precondition_violation("dispute_phase", d, "INVALID_DISPUTE_PHASE") + .create(), + D::ChargebackExceedsSettled(d) => EntryResource::failed_precondition() + .with_precondition_violation("clawed_back_minor", d, "CHARGEBACK_EXCEEDS_SETTLED") + .create(), + D::ChargebackOnRefunded(d) => EntryResource::failed_precondition() + .with_precondition_violation("clawed_back_minor", d, "CHARGEBACK_ON_REFUNDED") + .create(), + D::ClockSkewQuarantine(d) => EntryResource::failed_precondition() + .with_precondition_violation("clock", d, "CLOCK_SKEW_QUARANTINE") + .create(), + D::PeriodNotOpen(d) => FiscalPeriodResource::failed_precondition() + .with_precondition_violation("fiscal_period", d, "PERIOD_NOT_OPEN") + .create(), + + // ── Aborted (409) ── + // Balance / headroom caps are retriable conflicts on mutable state + // ("re-read the balance and retry"): the request is well-formed but + // raced or exceeded the *current* cap, so they map to ABORTED → 409, + // not InvalidArgument → 400. The platform has no 422, so the design's + // 422/409 cap codes all land on 409 here (design doc updated to match). + D::IdempotencyConflict(d) => EntryResource::aborted(d) + .with_reason("IDEMPOTENCY_PAYLOAD_CONFLICT") + .create(), + D::CurrencyScaleLocked(d) => LedgerResource::aborted(d) + .with_reason("CURRENCY_SCALE_LOCKED") + .create(), + D::MoneyOutCapExceeded(d) => EntryResource::aborted(d) + .with_reason("ALLOCATION_EXCEEDS_SETTLED") + .create(), + D::GrantExceedsUnallocated(d) => EntryResource::aborted(d) + .with_reason("GRANT_EXCEEDS_UNALLOCATED") + .create(), + D::CreditExceedsOpenAr(d) => EntryResource::aborted(d) + .with_reason("CREDIT_EXCEEDS_OPEN_AR") + .create(), + D::CreditExceedsWallet(d) => EntryResource::aborted(d) + .with_reason("CREDIT_EXCEEDS_WALLET") + .create(), + // No acceptable local FX rate for the pair (all providers stale/absent, + // no allowed stale fallback). A transient conflict — a later RateSyncJob + // tick may resolve it — so ABORTED → 409 (design §5). + D::FxRateUnavailable(d) => EntryResource::aborted(d) + .with_reason("FX_RATE_UNAVAILABLE") + .create(), + // A concurrent close holds the single-active `coord` lease — a + // transient conflict the caller may retry once the holder finishes. + D::PeriodCloseInProgress(d) => FiscalPeriodResource::aborted(d) + .with_reason("PERIOD_CLOSE_IN_PROGRESS") + .create(), + // The close gate found a blocking condition (tie-out variance, an open + // close-blocking exception, a pending mapping, a due-but-unrecognised + // segment, or a control-feed gate). The body carries the accumulated + // `blocked_reasons`; resolve them and retry. A conflict on the period's + // current state ⇒ ABORTED → 409 (spec §3.5, the sibling of + // PeriodCloseInProgress), NOT FailedPrecondition → 400. + D::PeriodCloseBlocked(d) => FiscalPeriodResource::aborted(d) + .with_reason("PERIOD_CLOSE_BLOCKED") + .create(), + // The per-schedule `recognized_minor <= total_deferred_minor` CHECK + // rejected a release (a concurrent run already drew the schedule down, + // or a stale-"satisfied" gate over-released). A conflict the caller can + // retry against the then-current `recognized_minor` ⇒ 409, mirroring + // `IdempotencyConflict` (design §4.3 / §5). + D::OverRecognition(d) => EntryResource::aborted(d) + .with_reason("OVER_RECOGNITION") + .create(), + // Refund-of-refund claw-back DEFERRED (Group E, design §4.4 / Rev3): the + // PSP claw-back arrived BEFORE / without the matching outbound refund + // stage-1 (or claws back more than was refunded), so applying its + // money-out decrement now would underflow the counter. It was durably + // QUEUED (never hard-failed) and the drain retries it once the matching + // outbound lands — a transient conflict the caller observes / retries + // (the future REST surface maps it to a 202-like accepted-but-queued). + D::RefundClawbackDeferred(d) => EntryResource::aborted(d) + .with_reason("REFUND_CLAWBACK_DEFERRED") + .create(), + // Cross-currency operation not yet supported (Slice 5 remediation): a + // refund-of-refund claw-back or a mapping-correction whose functional + // carry-forward needs a prior locked rate (Slice 7) is rejected up front + // — NOT queued (unlike `RefundClawbackDeferred`): a permanent precondition + // failure the caller resolves by routing the operation through a manual + // adjustment. + D::FxOperationUnsupported(d) => EntryResource::failed_precondition() + .with_precondition_violation("entry", d, "FX_OPERATION_UNSUPPORTED") + .create(), + // Refund dispute-hold (Z5-2, design §5): the origin payment has an OPEN + // dispute, so moving the refund's cash leg now would pay out funds that + // are sub judice (held in `DISPUTE_HOLD` / reclassed `DISPUTED`). The + // refund was durably HELD on the `REFUND_DISPUTE_HOLD` queue (never + // hard-failed, never posted) and the hold drain re-drives it once the + // dispute resolves WON (or cancels it on LOST — the chargeback already + // returned the money). A transient conflict the caller observes / the + // REST surface maps to a 202-like accepted-but-held (mirrors + // `RefundClawbackDeferred`). + D::RefundDisputeHeld(d) => EntryResource::aborted(d) + .with_reason("REFUND_DISPUTE_HELD") + .create(), + // Dual-control (VHP-1852): an over-threshold mutation needs a second + // actor; self-approval, a non-actionable target (wrong state / lost + // race / expired), and out-of-range policy config are all well-formed + // requests that conflict with current governance state → 409. + D::DualControlRequired(d) => EntryResource::aborted(d) + .with_reason("DUAL_CONTROL_REQUIRED") + .create(), + D::SelfApprovalForbidden(d) => EntryResource::aborted(d) + .with_reason("SELF_APPROVAL_FORBIDDEN") + .create(), + D::ApprovalNotActionable(d) => EntryResource::aborted(d) + .with_reason("APPROVAL_NOT_ACTIONABLE") + .create(), + D::DualControlPolicyOutOfRange(d) => EntryResource::aborted(d) + .with_reason("DUAL_CONTROL_POLICY_OUT_OF_RANGE") + .create(), + + D::TamperVerificationFailed(d) => EntryResource::aborted(d) + .with_reason("TAMPER_VERIFICATION_FAILED") + .create(), + // Slice 6 §4.6 (AC #15): a correction invented a pinned evidence ref + // the original never had — financial corrections must reuse the + // original's pinned evidence. A 409 Conflict carrying the + // `POLICY_VERSION_VIOLATION` reason (same family as the other aborted + // conflicts). + D::PolicyVersionViolation(d) => EntryResource::aborted(d) + .with_reason("POLICY_VERSION_VIOLATION") + .create(), + // ── ResourceExhausted ── + D::TenantPostingLocked(d) => EntryResource::resource_exhausted(d.clone()) + .with_quota_violation("TENANT_POSTING_LOCKED", d) + .create(), + + // ── NotFound ── + D::PeriodNotFound(d) => FiscalPeriodResource::not_found(d.clone()) + .with_resource(d) + .create(), + // `ApprovalNotFound` (dual-control), `PayerPiiNotFound` (Group 3A PII + // erasure / re-identification), `NoteInvoiceNotFound`, and + // `RefundOriginNotFound` share the entry-resource 404 (resource-based, + // no distinct wire code — the gear's not-found shape). + // `NoteInvoiceNotFound`: a credit/debit note named an `origin_invoice_id` + // with no posted `INVOICE_POST` entry (design §4.2 / §5 — a note MUST + // link an originating posted invoice). `RefundOriginNotFound`: a refund + // named a `payment_id` with no `payment_settlement` (design §4.4 / §9 D7 + // — a refund MUST unwind a settled receipt). All are scoped existence, so + // a foreign-tenant row is indistinguishable from absent (no leak). + D::ApprovalNotFound(d) + | D::PayerPiiNotFound(d) + | D::NoteInvoiceNotFound(d) + | D::RefundOriginNotFound(d) => EntryResource::not_found(d.clone()) + .with_resource(d) + .create(), + + // ── Internal (diagnostic stays server-side) ── + D::Internal(d) => CanonicalError::internal(format!("ledger: {d}")).create(), + } + } +} + +#[cfg(test)] +#[path = "error_mapping_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/error_mapping_tests.rs b/gears/bss/ledger/ledger/src/infra/error_mapping_tests.rs new file mode 100644 index 000000000..3760bd870 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/error_mapping_tests.rs @@ -0,0 +1,490 @@ +//! Exhaustive `DomainError` → AIP-193 `CanonicalError` ladder check: every +//! variant maps to its expected canonical category, and a representative set +//! carry the agreed wire code. Locks the machine-readable error contract. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::doc_markdown +)] + +use toolkit::api::canonical_prelude::{CanonicalError, Problem}; + +use crate::domain::error::DomainError; + +/// The canonical category (HTTP-ish status family) of a projected error. +fn status(err: DomainError) -> u16 { + CanonicalError::from(err).status_code() +} + +#[test] +fn each_variant_maps_to_expected_category() { + let d = || "x".to_owned(); + // (variant, expected canonical HTTP status) + // FailedPrecondition maps to 400 (same as InvalidArgument per AIP-193 / toolkit). + let cases: Vec<(DomainError, u16)> = vec![ + (DomainError::Unbalanced(d()), 400), + (DomainError::Empty(d()), 400), + (DomainError::MixedPayer(d()), 400), + (DomainError::MissingPayer(d()), 400), + (DomainError::MixedLegalEntity(d()), 400), + (DomainError::InconsistentScale(d()), 400), + (DomainError::AmountOutOfRange(d()), 400), + (DomainError::EntryTooLarge(d()), 400), + (DomainError::InvalidRequest(d()), 400), + (DomainError::AllocationTooLarge(d()), 400), + (DomainError::AllocationCurrencyMismatch(d()), 400), + (DomainError::CurrencyMismatch(d()), 400), + (DomainError::AllocationSplitInvalid(d()), 400), + // FX (Slice 5): no acceptable rate is a transient conflict → ABORTED 409; + // stale-not-allowed is a design-422 → InvalidArgument 400 (no platform 422). + (DomainError::FxRateUnavailable(d()), 409), + (DomainError::FxRateStaleNotAllowed(d()), 400), + // Balance / headroom caps are retriable conflicts → ABORTED → 409 (1a). + (DomainError::GrantExceedsUnallocated(d()), 409), + (DomainError::CreditExceedsOpenAr(d()), 409), + (DomainError::CreditExceedsWallet(d()), 409), + (DomainError::MoneyOutCapExceeded(d()), 409), + (DomainError::ScheduleTooLong(d()), 400), + (DomainError::SspSnapshotRequired(d()), 400), + (DomainError::MissingPoAllocationGroup(d()), 400), + (DomainError::RecognitionPolicyConflict(d()), 400), + // Design-422 → InvalidArgument 400 (the platform has no 422): the headroom + // cap is NOT a retriable balance race (an over-cap credit note routes via + // goodwill/non-revenue), so it is a 400, not the ABORTED 409 the money-out + // caps use. + (DomainError::CreditNoteExceedsHeadroom(d()), 400), + // The refund stage-1 cap rejects are design-422 → InvalidArgument 400 (the + // platform has no 422), like the headroom cap — an over-refund must be + // corrected, not retried, so they are 400, not the ABORTED 409 the + // allocate/chargeback money-out caps use. + (DomainError::RefundExceedsSettled(d()), 400), + (DomainError::RefundExceedsAllocated(d()), 400), + (DomainError::ModificationTreatmentReview(d()), 400), + (DomainError::RecognitionWithoutInvoiceLink(d()), 400), + (DomainError::ScaleOutOfRange(d()), 400), + (DomainError::CreditResidualUndisposed(d()), 400), + // 2C: MissingInvestigationReason is architecturally a 422, but the + // toolkit CanonicalError has no 422 category — it projects as + // FailedPrecondition (400). The `MISSING_INVESTIGATION_REASON` wire code + // is the discriminator. + (DomainError::MissingInvestigationReason(d()), 400), + // 2C: an unauthorized cross-tenant elevation is a 403 PermissionDenied. + (DomainError::CrossTenantAccessDenied(d()), 403), + (DomainError::PeriodClosed(d()), 400), + (DomainError::AccountClosed(d()), 400), + (DomainError::PayerClosed(d()), 400), + (DomainError::NegativeBalance(d()), 400), + (DomainError::SettlementReturnOverAllocated(d()), 400), + (DomainError::InvalidDisputeTransition(d()), 400), + (DomainError::ChargebackExceedsSettled(d()), 400), + (DomainError::ChargebackOnRefunded(d()), 400), + (DomainError::ClockSkewQuarantine(d()), 400), + (DomainError::PeriodNotOpen(d()), 400), + (DomainError::IdempotencyConflict(d()), 409), + (DomainError::CurrencyScaleLocked(d()), 409), + (DomainError::OverRecognition(d()), 409), + // Dual-control (VHP-1852): over-threshold / self-approval / non-actionable + // target / out-of-range policy are all well-formed requests that conflict + // with current governance state → ABORTED → 409 (no platform 422). + (DomainError::DualControlRequired(d()), 409), + (DomainError::SelfApprovalForbidden(d()), 409), + (DomainError::ApprovalNotActionable(d()), 409), + (DomainError::DualControlPolicyOutOfRange(d()), 409), + (DomainError::TamperVerificationFailed(d()), 409), + // §4.6 (AC #15): a correction that fails to reuse the original's pinned + // evidence is a 409 Conflict (aborted). + (DomainError::PolicyVersionViolation(d()), 409), + // Group E: a deferred refund-of-refund claw-back is ABORTED (409) — accepted + // and queued, a transient conflict the caller observes / retries (the future + // REST surface maps it to a 202-like accepted-but-queued). + (DomainError::RefundClawbackDeferred(d()), 409), + (DomainError::TenantPostingLocked(d()), 429), + (DomainError::PeriodNotFound(d()), 404), + (DomainError::NoteInvoiceNotFound(d()), 404), + (DomainError::RefundOriginNotFound(d()), 404), + // Variants previously absent from this list: + // RefundDisputeHeld is an Aborted 409 (held, re-driven on dispute WON), + // the sibling of RefundClawbackDeferred. + (DomainError::RefundDisputeHeld(d()), 409), + (DomainError::CreditNoteSplitAmbiguous(d()), 400), + (DomainError::ManualAdjustmentNotAllowed(d()), 400), + (DomainError::PiiInMetadataValue(d()), 400), + (DomainError::ApprovalNotFound(d()), 404), + (DomainError::PayerPiiNotFound(d()), 404), + (DomainError::Internal(d()), 500), + ]; + for (variant, want) in cases { + let label = format!("{variant:?}"); + assert_eq!(status(variant), want, "wrong category for {label}"); + } +} + +#[test] +fn unbalanced_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::Unbalanced( + "dr!=cr".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("LEDGER_ENTRY_UNBALANCED"), + "missing wire code: {body}" + ); +} + +#[test] +fn note_invoice_not_found_carries_resource_wire_code_and_404() { + // F4: a credit/debit note against an unposted invoice → NOT_FOUND (404), + // the same not-found shape as `PeriodNotFound` / `ApprovalNotFound`. + let err = DomainError::NoteInvoiceNotFound("INV-NONE".into()); + assert_eq!(status(err.clone()), 404, "NoteInvoiceNotFound must be 404"); + let problem = Problem::from(CanonicalError::from(err)); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("INV-NONE"), + "missing the offending invoice id in the problem body: {body}" + ); +} + +#[test] +fn refund_origin_not_found_carries_resource_wire_code_and_404() { + // §4.4 / D7: a refund against a payment with no settlement → NOT_FOUND (404), + // the same not-found shape as `NoteInvoiceNotFound` / `ApprovalNotFound`. + let err = DomainError::RefundOriginNotFound("PAY-NONE".into()); + assert_eq!(status(err.clone()), 404, "RefundOriginNotFound must be 404"); + let problem = Problem::from(CanonicalError::from(err)); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("PAY-NONE"), + "missing the offending payment id in the problem body: {body}" + ); +} + +#[test] +fn allocation_split_invalid_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::AllocationSplitInvalid( + "over open".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("ALLOCATION_SPLIT_INVALID"), + "missing wire code: {body}" + ); +} + +#[test] +fn credit_note_exceeds_headroom_carries_field_violation_wire_code() { + // The headroom cap is a design-422 that lands on InvalidArgument (400) — a + // field violation, NOT the ABORTED reason the retriable money-out caps use. + let problem = Problem::from(CanonicalError::from( + DomainError::CreditNoteExceedsHeadroom("over headroom".into()), + )); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("CREDIT_NOTE_EXCEEDS_HEADROOM"), + "missing wire code: {body}" + ); +} + +#[test] +fn refund_exceeds_settled_carries_field_violation_wire_code() { + // The refund total-money-out / spendable-headroom cap is a design-422 that + // lands on InvalidArgument (400) — a field violation, NOT the ABORTED reason + // the retriable money-out caps use (an over-refund must be corrected). + let problem = Problem::from(CanonicalError::from(DomainError::RefundExceedsSettled( + "refund > settled".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("REFUND_EXCEEDS_SETTLED"), + "missing wire code: {body}" + ); +} + +#[test] +fn refund_exceeds_allocated_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::RefundExceedsAllocated( + "refund > allocated".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("REFUND_EXCEEDS_ALLOCATED"), + "missing wire code: {body}" + ); +} + +// Cap rejections now map to ABORTED (409) and carry their wire code as the +// `aborted` reason rather than a field violation (1a); the code string is still +// present in the serialized problem body. +#[test] +fn grant_exceeds_unallocated_carries_aborted_reason_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::GrantExceedsUnallocated( + "grant > pool".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("GRANT_EXCEEDS_UNALLOCATED"), + "missing wire code: {body}" + ); +} + +#[test] +fn credit_exceeds_open_ar_carries_aborted_reason_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::CreditExceedsOpenAr( + "target > open".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("CREDIT_EXCEEDS_OPEN_AR"), + "missing wire code: {body}" + ); +} + +#[test] +fn credit_exceeds_wallet_carries_aborted_reason_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::CreditExceedsWallet( + "debit > wallet".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("CREDIT_EXCEEDS_WALLET"), + "missing wire code: {body}" + ); +} + +#[test] +fn chargeback_exceeds_settled_carries_precondition_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::ChargebackExceedsSettled( + "clawback > settled".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("CHARGEBACK_EXCEEDS_SETTLED"), + "missing wire code: {body}" + ); +} + +#[test] +fn chargeback_on_refunded_carries_precondition_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::ChargebackOnRefunded( + "lost on refunded".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("CHARGEBACK_ON_REFUNDED"), + "missing wire code: {body}" + ); +} + +#[test] +fn idempotency_conflict_carries_aborted_reason() { + let problem = Problem::from(CanonicalError::from(DomainError::IdempotencyConflict( + "dup".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("IDEMPOTENCY_PAYLOAD_CONFLICT"), + "missing reason: {body}" + ); +} + +#[test] +fn over_recognition_carries_aborted_reason() { + let problem = Problem::from(CanonicalError::from(DomainError::OverRecognition( + "release > deferred".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!(body.contains("OVER_RECOGNITION"), "missing reason: {body}"); +} + +#[test] +fn refund_clawback_deferred_carries_aborted_reason() { + // Group E: a deferred claw-back surfaces the REFUND_CLAWBACK_DEFERRED abort + // reason (409 — accepted-but-queued, the caller retries / observes). + let problem = Problem::from(CanonicalError::from(DomainError::RefundClawbackDeferred( + "out-of-order claw-back deferred".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("REFUND_CLAWBACK_DEFERRED"), + "missing reason: {body}" + ); +} + +#[test] +fn currency_mismatch_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::CurrencyMismatch( + "EUR != USD".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("CURRENCY_MISMATCH"), + "missing wire code: {body}" + ); +} + +#[test] +fn schedule_too_long_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::ScheduleTooLong( + "121 > 120".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("SCHEDULE_TOO_LONG"), + "missing wire code: {body}" + ); +} + +#[test] +fn ssp_snapshot_required_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::SspSnapshotRequired( + "multi-PO without snapshot".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("SSP_SNAPSHOT_REQUIRED"), + "missing wire code: {body}" + ); +} + +#[test] +fn missing_po_allocation_group_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from(DomainError::MissingPoAllocationGroup( + "deferring line without a PO group".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("MISSING_PO_ALLOCATION_GROUP"), + "missing wire code: {body}" + ); +} + +#[test] +fn recognition_policy_conflict_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from( + DomainError::RecognitionPolicyConflict("Contract vs Catalog ambiguous".into()), + )); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("RECOGNITION_POLICY_CONFLICT"), + "missing wire code: {body}" + ); +} + +#[test] +fn missing_investigation_reason_carries_precondition_wire_code() { + let problem = Problem::from(CanonicalError::from( + DomainError::MissingInvestigationReason("reason required".into()), + )); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("MISSING_INVESTIGATION_REASON"), + "missing wire code: {body}" + ); +} + +#[test] +fn modification_treatment_review_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from( + DomainError::ModificationTreatmentReview("catch_up needs review".into()), + )); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("MODIFICATION_TREATMENT_REVIEW"), + "missing wire code: {body}" + ); +} + +#[test] +fn cross_tenant_access_denied_is_403_with_reason() { + let err = CanonicalError::from(DomainError::CrossTenantAccessDenied( + "role not authorized".into(), + )); + assert_eq!( + err.status_code(), + 403, + "an unauthorized cross-tenant elevation must be a 403 PermissionDenied" + ); + let body = serde_json::to_string(&Problem::from(err)).unwrap(); + assert!( + body.contains("CROSS_TENANT_ACCESS_DENIED"), + "missing reason: {body}" + ); +} + +#[test] +fn recognition_without_invoice_link_carries_field_violation_wire_code() { + let problem = Problem::from(CanonicalError::from( + DomainError::RecognitionWithoutInvoiceLink("deferred line without invoice_item_ref".into()), + )); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("RECOGNITION_WITHOUT_INVOICE_LINK"), + "missing wire code: {body}" + ); +} + +#[test] +fn policy_version_violation_is_409_with_aborted_reason() { + let err = CanonicalError::from(DomainError::PolicyVersionViolation( + "correction invented a pinned ref".into(), + )); + assert_eq!( + err.status_code(), + 409, + "a policy-version violation must be a 409 Conflict" + ); + let body = serde_json::to_string(&Problem::from(err)).unwrap(); + assert!( + body.contains("POLICY_VERSION_VIOLATION"), + "missing reason: {body}" + ); +} + +#[test] +fn tamper_verification_failed_is_409_with_aborted_reason() { + let err = CanonicalError::from(DomainError::TamperVerificationFailed("frozen".into())); + assert_eq!( + err.status_code(), + 409, + "tamper freeze must be a 409 Conflict" + ); + let body = serde_json::to_string(&Problem::from(err)).unwrap(); + assert!( + body.contains("TAMPER_VERIFICATION_FAILED"), + "missing reason: {body}" + ); +} + +#[test] +fn fx_rate_unavailable_is_409_with_aborted_reason() { + // No acceptable local FX rate for the pair → a transient conflict the caller + // may retry once the sync job catches up → ABORTED 409. + let err = CanonicalError::from(DomainError::FxRateUnavailable( + "no rate for EUR->USD".into(), + )); + assert_eq!( + err.status_code(), + 409, + "no acceptable FX rate must be a 409 Conflict" + ); + let body = serde_json::to_string(&Problem::from(err)).unwrap(); + assert!( + body.contains("FX_RATE_UNAVAILABLE"), + "missing reason: {body}" + ); +} + +#[test] +fn fx_rate_stale_not_allowed_carries_field_violation_wire_code() { + // Design-422 → InvalidArgument 400 (no platform 422): a field violation, not + // the ABORTED reason the transient FX-unavailable conflict uses. + let problem = Problem::from(CanonicalError::from(DomainError::FxRateStaleNotAllowed( + "stale 8d, tenant forbids fallback".into(), + ))); + let body = serde_json::to_string(&problem).unwrap(); + assert!( + body.contains("FX_RATE_STALE_NOT_ALLOWED"), + "missing wire code: {body}" + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/events.rs b/gears/bss/ledger/ledger/src/infra/events.rs new file mode 100644 index 000000000..89e7782f3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/events.rs @@ -0,0 +1,10 @@ +//! Events surface: the ledger's published event payloads and the publisher +//! adapter that writes them through the platform event broker. +//! +//! Payloads are infra types (serde + `TypedEvent`), not domain models and not +//! REST DTOs. They carry internal identifiers only — never PII. + +pub mod alarm_catalog; +pub mod payloads; +pub mod publisher; +pub mod schemas; diff --git a/gears/bss/ledger/ledger/src/infra/events/alarm_catalog.rs b/gears/bss/ledger/ledger/src/infra/events/alarm_catalog.rs new file mode 100644 index 000000000..7dbc5bd32 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/events/alarm_catalog.rs @@ -0,0 +1,190 @@ +//! The normative §4.7 alarm catalog over [`AlarmCategory`]. +//! +//! Three pure lookups translate an [`AlarmCategory`] into the design's §4.7 +//! columns: +//! - [`severity`] — the §4.7 "Severity" column (`Warn` / `Critical`). +//! - [`routing`] — a short, stable string of the §4.7 "Required behavior" / +//! route (e.g. who to page, what to block). Consumers MUST treat this as a +//! human-facing label, not a parseable contract. +//! - [`owning_slice`] — which architecture slice owns the row. +//! +//! All three are **exhaustive** `match`es on [`AlarmCategory`]. That is the +//! point: adding a variant to the enum forces an entry in each of these +//! functions (the compiler refuses a non-exhaustive `match`), so the catalog +//! can never silently fall out of step with the enum. +//! +//! This module is data-only: it does not emit, route, or page. The emitters +//! (the posting service's `alarm_for`, the tie-out job, the chain Verifier) own +//! WHEN an alarm fires; this catalog owns its severity + route metadata. + +use crate::infra::events::payloads::{AlarmCategory, AlarmSeverity}; + +/// The §4.7 "Severity" column for `cat`. +/// +/// `Critical` rows are the integrity-/cash-threatening defects that page +/// immediately: zero-sum (`EntryImbalance`), the negative-balance NO-class +/// (`NegativeBalanceViolation`), `RecognitionDoubleCredit`, `OverRecognition`, +/// `FxSnapshotMissing`, `FxSnapshotStaleBlocked`, the idempotency collision +/// (`IdempotencyPayloadConflict`), `NegativeTaxSubbalance`, the tamper failure +/// (`TamperVerifyFailed`), `AttemptedWriteOff`, and `FxRevaluationIncomplete`. +/// Every other row is `Warn` (some are "Warn→Page" in the design — still a +/// `Warn` severity here; escalation is the consumer's routing concern). +#[must_use] +pub fn severity(cat: AlarmCategory) -> AlarmSeverity { + use AlarmCategory as C; + use AlarmSeverity::{Critical, Warn}; + match cat { + // ── Critical (§4.7 Severity = Critical) ────────────────────────────── + C::EntryImbalance + | C::NegativeBalanceViolation + | C::RecognitionDoubleCredit + | C::OverRecognition + | C::FxSnapshotMissing + | C::FxSnapshotStaleBlocked + | C::IdempotencyPayloadConflict + | C::NegativeTaxSubbalance + | C::TamperVerifyFailed + | C::AttemptedWriteOff + | C::ClawbackUnderflow + | C::StuckRefundClearing + | C::FxRevaluationIncomplete => Critical, + // ── Warn (everything else, incl. the "Warn→Page" rows) ─────────────── + C::TieOutVariance + | C::ChargebackCashNegative + | C::FxSnapshotStaleAllowed + | C::CreditNoteSplitBlocked + | C::RefundQuarantined + | C::RecognitionPeriodQueued + | C::ReconciliationVariance + | C::ExportFailedAged + | C::AgedAllocationQueue + | C::AgedUnallocated + | C::RefundClearingAged + | C::DisputePhaseQueued + | C::Stage1RefundOrphan + | C::BillrunPartialFailure + | C::PartitionDetachBlocked + | C::RelayLag + | C::ClockSkew + | C::PayerAttributionDrift + | C::DormantOpenCredit + | C::ChainLag + | C::SubtreeTooLarge + | C::SubtreeResolutionDegraded + | C::MissedPosting => Warn, + } +} + +/// The §4.7 "Required behavior" / route for `cat`, as a short stable label. +/// +/// Human-facing only (a page/route hint for operators); never parse it. Every +/// arm returns a non-empty string — the `alarm_catalog_tests` enforce that, so +/// a new variant cannot ship with an empty route. +#[must_use] +#[allow( + clippy::match_same_arms, + reason = "the catalog names each category explicitly for traceability; distinct categories may legitimately share a route/slice" +)] +pub fn routing(cat: AlarmCategory) -> &'static str { + use AlarmCategory as C; + match cat { + C::IdempotencyPayloadConflict => "Reject the conflicting post; page Revenue Assurance", + C::NegativeBalanceViolation => "Route to Revenue Assurance", + C::TieOutVariance => "Route to Revenue Assurance; block period close above tolerance", + C::EntryImbalance => "Reject the entry; page Revenue Assurance", + C::TamperVerifyFailed => "Freeze write path on scope; page Audit + Architecture", + C::ChargebackCashNegative => "Route to Revenue Assurance; review chargeback handling", + C::RecognitionDoubleCredit => "Block recognition; page Revenue Assurance", + C::OverRecognition => "Block recognition; page Revenue Assurance", + C::FxSnapshotMissing => "Block FX-dependent post; page Revenue Assurance", + C::FxSnapshotStaleAllowed => "Allow within tolerance; record warning for Revenue Assurance", + C::FxSnapshotStaleBlocked => "Block FX-dependent post; page Revenue Assurance", + C::NegativeTaxSubbalance => "Block; page Tax + Revenue Assurance", + C::CreditNoteSplitBlocked => "Block credit-note split; queue for Billing review", + C::RefundQuarantined => "Quarantine refund; queue for Billing review", + C::RecognitionPeriodQueued => "Queue for the recognition period sweep", + C::ReconciliationVariance => "Block period close above tolerance; route to Reconciliation", + C::ExportFailedAged => "Retry export; escalate to Reconciliation when aged", + C::AgedAllocationQueue => "Queue for allocation; escalate when aged", + C::AgedUnallocated => "Queue for allocation; escalate to Billing when aged", + C::RefundClearingAged => "Escalate aged refund-clearing balance to Billing", + C::DisputePhaseQueued => "Queue for the dispute-phase sweep", + C::Stage1RefundOrphan => "Queue orphaned stage-1 refund for Billing review", + C::BillrunPartialFailure => "Retry the failed bill-run shard; page Billing on repeat", + C::PartitionDetachBlocked => "Block partition detach; route to Platform Operations", + C::RelayLag => "Monitor relay backlog; page Platform Operations when sustained", + C::ClockSkew => "Quarantine the skewed post; route to Platform Operations", + C::AttemptedWriteOff => "Reject the write-off; page Revenue Assurance + Audit", + C::ClawbackUnderflow => "Reconcile the unmatched refund claw-back; page Finance", + C::StuckRefundClearing => { + "Block period close; escalate the stuck refund-clearing balance to Billing + Reconciliation" + } + C::PayerAttributionDrift => { + "INACTIVE in MVP (detective seam, no-op until the AuthZ/Tenant resolver is wired — \ + design F-9); when active: route to Revenue Assurance, review payer attribution" + } + C::FxRevaluationIncomplete => "Block period close; page Revenue Assurance", + C::DormantOpenCredit => "Queue dormant open credit for Billing review", + C::ChainLag => "Monitor chain-seal backlog; page Platform Operations when sustained", + C::SubtreeTooLarge => "Route to Platform Operations; review tenant subtree size", + C::SubtreeResolutionDegraded => "Route to Platform Operations; review resolver health", + C::MissedPosting => { + "Block period close; page Reconciliation on a missed posting aged past threshold" + } + } +} + +/// The architecture slice that owns `cat` (e.g. `"Slice 6"`). +#[must_use] +pub fn owning_slice(cat: AlarmCategory) -> &'static str { + use AlarmCategory as C; + match cat { + // Slice 1 — core posting / structural invariants + tenant resolution. + C::ClockSkew + | C::PayerAttributionDrift + | C::SubtreeTooLarge + | C::SubtreeResolutionDegraded => "Slice 1", + // Slice 2 — bill-run / invoicing. + C::BillrunPartialFailure => "Slice 2", + // Slice 3 — revenue recognition + FX. + C::RecognitionDoubleCredit + | C::OverRecognition + | C::FxSnapshotMissing + | C::FxSnapshotStaleAllowed + | C::FxSnapshotStaleBlocked + | C::RecognitionPeriodQueued + | C::FxRevaluationIncomplete => "Slice 3", + // Slice 4 — credits / refunds / disputes / write-offs. + C::ChargebackCashNegative + | C::CreditNoteSplitBlocked + | C::RefundQuarantined + | C::AgedAllocationQueue + | C::AgedUnallocated + | C::RefundClearingAged + | C::DisputePhaseQueued + | C::Stage1RefundOrphan + | C::AttemptedWriteOff + | C::ClawbackUnderflow + | C::StuckRefundClearing + | C::DormantOpenCredit => "Slice 4", + // Slice 5 — tax. + C::NegativeTaxSubbalance => "Slice 5", + // Slice 6 — ledger integrity / tamper chain / event relay. + C::IdempotencyPayloadConflict + | C::NegativeBalanceViolation + | C::EntryImbalance + | C::TamperVerifyFailed + | C::RelayLag + | C::ChainLag => "Slice 6", + // Slice 7 — reconciliation / export / period close / partitions. + C::TieOutVariance + | C::ReconciliationVariance + | C::ExportFailedAged + | C::PartitionDetachBlocked + | C::MissedPosting => "Slice 7", + } +} + +#[cfg(test)] +#[path = "alarm_catalog_tests.rs"] +mod alarm_catalog_tests; diff --git a/gears/bss/ledger/ledger/src/infra/events/alarm_catalog_tests.rs b/gears/bss/ledger/ledger/src/infra/events/alarm_catalog_tests.rs new file mode 100644 index 000000000..b88a5db46 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/events/alarm_catalog_tests.rs @@ -0,0 +1,105 @@ +//! Tests for the §4.7 alarm catalog: every [`AlarmCategory`] has a total +//! `severity` / `routing` / `owning_slice` (no panic, no empty route), and the +//! `as_str` wire tokens are unique. (DE1101: kept out of the impl file.) +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::collections::HashSet; + +use super::{owning_slice, routing, severity}; +use crate::infra::events::payloads::AlarmCategory; + +/// Every catalogued category. A NEW `AlarmCategory` variant must be added here +/// too — the totality assertions below then exercise it through all three +/// lookups. (The `as_str`/`severity`/`routing`/`owning_slice` `match`es are the +/// compiler-enforced safety net; this list drives the runtime checks.) +const ALL: &[AlarmCategory] = &[ + AlarmCategory::IdempotencyPayloadConflict, + AlarmCategory::NegativeBalanceViolation, + AlarmCategory::TieOutVariance, + AlarmCategory::EntryImbalance, + AlarmCategory::TamperVerifyFailed, + AlarmCategory::ChargebackCashNegative, + AlarmCategory::RecognitionDoubleCredit, + AlarmCategory::OverRecognition, + AlarmCategory::FxSnapshotMissing, + AlarmCategory::FxSnapshotStaleAllowed, + AlarmCategory::FxSnapshotStaleBlocked, + AlarmCategory::NegativeTaxSubbalance, + AlarmCategory::CreditNoteSplitBlocked, + AlarmCategory::RefundQuarantined, + AlarmCategory::RecognitionPeriodQueued, + AlarmCategory::ReconciliationVariance, + AlarmCategory::ExportFailedAged, + AlarmCategory::AgedAllocationQueue, + AlarmCategory::AgedUnallocated, + AlarmCategory::RefundClearingAged, + AlarmCategory::DisputePhaseQueued, + AlarmCategory::Stage1RefundOrphan, + AlarmCategory::ClawbackUnderflow, + AlarmCategory::StuckRefundClearing, + AlarmCategory::BillrunPartialFailure, + AlarmCategory::PartitionDetachBlocked, + AlarmCategory::RelayLag, + AlarmCategory::ClockSkew, + AlarmCategory::AttemptedWriteOff, + AlarmCategory::PayerAttributionDrift, + AlarmCategory::FxRevaluationIncomplete, + AlarmCategory::DormantOpenCredit, + AlarmCategory::ChainLag, + AlarmCategory::SubtreeTooLarge, + AlarmCategory::SubtreeResolutionDegraded, + AlarmCategory::MissedPosting, +]; + +#[test] +fn catalog_is_total_over_every_category() { + for &cat in ALL { + // `severity` / `owning_slice` must not panic and must return a usable + // value. `routing` must be a non-empty label. + let _ = severity(cat); + let slice = owning_slice(cat); + assert!( + !slice.trim().is_empty(), + "owning_slice empty for {}", + cat.as_str() + ); + let route = routing(cat); + assert!( + !route.trim().is_empty(), + "routing empty for {}", + cat.as_str() + ); + } +} + +#[test] +fn as_str_tokens_are_unique() { + let mut seen: HashSet<&str> = HashSet::new(); + for &cat in ALL { + let token = cat.as_str(); + assert!( + seen.insert(token), + "duplicate AlarmCategory wire token: {token}" + ); + } + // Sanity: the list and the enum agree in size — a new variant absent from + // `ALL` would make this drift. (36 = the original 5 + the 28 §4.7 rows + the + // Slice-3 ClawbackUnderflow + StuckRefundClearing refund-family rows + the + // Slice-7 MissedPosting invoice-completeness row.) + assert_eq!(ALL.len(), 36, "ALL must list every AlarmCategory variant"); +} + +#[test] +fn owning_slice_values_are_known() { + let known = [ + "Slice 1", "Slice 2", "Slice 3", "Slice 4", "Slice 5", "Slice 6", "Slice 7", + ]; + for &cat in ALL { + let slice = owning_slice(cat); + assert!( + known.contains(&slice), + "unexpected owning_slice {slice:?} for {}", + cat.as_str() + ); + } +} diff --git a/gears/bss/ledger/ledger/src/infra/events/payloads.rs b/gears/bss/ledger/ledger/src/infra/events/payloads.rs new file mode 100644 index 000000000..99066491c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/events/payloads.rs @@ -0,0 +1,856 @@ +//! Event payloads published by the ledger. +//! +//! These are infra types: plain serde structs (the ledger event payloads). +//! They are **not** domain models (no +//! TODO(broker): these implemented `event_broker_sdk::TypedEvent` (TYPE_ID / +//! TOPIC / subject / partition-key); the impls are parked until the event +//! broker lands in gears-rust. Restore them alongside the publisher producers. +//! +//! //! `#[domain_model]`) and **not** REST DTOs (no `#[api_dto]`). Every field is +//! an internal identifier, enum code, or amount — there is **no PII** +//! (no names, emails, or free-text business content). + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// One leg of a posted ledger entry, summarised for downstream consumers. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerLineSummary { + /// Account class the leg posts against (enum code, e.g. `"AR"`). + pub account_class: String, + /// Debit/credit side (enum code, `"DR"` or `"CR"`). + pub side: String, + /// Line amount in minor units; `side` carries the debit/credit direction. + /// Wire-encoded as a JSON number (i64): consumers must use a 64-bit integer + /// reader — an f64/JS parser loses precision above 2^53 (reachable only for + /// very large amounts at high `currency_scale`). + pub amount_minor: i64, + /// ISO-4217 currency code. + pub currency: String, + /// Minor-unit scale for `amount_minor` (e.g. `2` ⇒ `1000` = 10.00). Without + /// it `amount_minor` is ambiguous for non-default-scale currencies. + pub currency_scale: u8, +} + +/// Emitted when a balanced entry is freshly posted (never on replay). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerEntryPosted { + /// Posted entry id. + pub entry_id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Owning legal entity. + pub legal_entity_id: Uuid, + /// Accounting period the entry lands in. + pub period_id: String, + /// Source document type code. + pub source_doc_type: String, + /// Caller-supplied source business id (the idempotency key). + pub source_business_id: String, + /// Post timestamp (UTC). + pub posted_at_utc: DateTime, + /// Monotonic creation sequence within the period. + pub created_seq: i64, + /// The entry's legs. + pub lines: Vec, +} + +/// Emitted when a balanced ledger entry is reversed via the explicit reversal +/// path (`POST /journal-entries/{id}/reversals`), never on replay (architecture +/// §6 `billing.ledger.entry.reversed`). Internal ids + the audit `reason` only; +/// no PII, no amounts. Published in-txn via the transactional outbox alongside +/// the reversal's `entry.posted` event (the `ReversalEventSidecar`), so it +/// commits atomically with the reversing entry. A `MAPPING_CORRECTION` (which +/// also posts a reversal leg) does NOT emit this — it is a correction, not a +/// §6 reversal. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerEntryReversed { + /// The reversing (compensating) entry's id. + pub entry_id: Uuid, + /// The original entry this reverses. + pub reverses_entry_id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Operator-supplied audit reason for the reversal. + pub reason: String, +} + +/// Emitted on every successful dispute-phase post (architecture §6 +/// `billing.ledger.dispute.recorded`) — opened / won / lost, never on replay. +/// Ids + enum codes only; no PII (no amounts, no invoice / payment business +/// content beyond the ids). Published in-txn via the transactional outbox +/// alongside the `entry.posted` event, so it commits atomically with the +/// dispute entry. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerDisputeRecorded { + /// External dispute id (the first `source_business_id` token). + pub dispute_id: String, + /// The disputed payment id. + pub payment_id: String, + /// Owning (seller) tenant. + pub tenant_id: Uuid, + /// Re-entrancy cycle number (`>= 1`). + pub cycle: i32, + /// The phase recorded (`OPENED` | `WON` | `LOST` | `PARTIAL`). + pub phase: String, + /// The dispute variant (`CASH_HOLD` | `AR_RECLASS`). + pub variant: String, +} + +/// Emitted on every successful settlement-return post (architecture §4.2 +/// `billing.ledger.settlement.returned`) — never on replay. Ids + enum codes + +/// the returned amount only; no PII (no names / free-text business content). +/// Published in-txn via the transactional outbox alongside the `entry.posted` +/// event, so it commits atomically with the settlement-return entry (or rolls +/// back with it). Mirrors [`LedgerDisputeRecorded`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerSettlementReturned { + /// The original settled payment that was clawed back. + pub payment_id: String, + /// External return identity (the `SETTLEMENT_RETURN` `source_business_id`). + pub psp_return_id: String, + /// Owning (seller) tenant. + pub tenant_id: Uuid, + /// The returned gross amount in minor units. Wire-encoded as a JSON number + /// (i64): consumers must use a 64-bit integer reader. + pub amount_minor: i64, + /// ISO-4217 currency of the return. + pub currency: String, +} + +/// Emitted on every recognition **release** post (design §3.7 / Group I1 +/// `billing.ledger.revenue.recognized`) — when a recognition run releases one +/// segment (`DR CONTRACT_LIABILITY / CR REVENUE`), never on replay. Ids + +/// amount + stream + period only; no PII (no names / free-text business +/// content). Published in-txn via the transactional outbox alongside the +/// `entry.posted` event, so it commits atomically with the recognition entry +/// (or rolls back with it). Mirrors [`LedgerDisputeRecorded`] / +/// [`LedgerSettlementReturned`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerRevenueRecognized { + /// The seller tenant whose ledger recognized the revenue — envelope routing + + /// consumer-side tenant scoping (an id, not PII; matches the other ledger + /// events, which all carry `tenant_id`). + pub tenant_id: Uuid, + /// The owning recognition schedule's id. + pub schedule_id: String, + /// The released segment's number (immutable, 1:1 with `period_id`). + pub segment_no: i32, + /// The accounting period the recognized revenue lands in (`YYYYMM`). May + /// differ from the segment's target period on an E-2 missed-close + /// reassignment. + pub period_id: String, + /// The recognized amount in minor units (`= the entry's DR/CR amount`). + /// Wire-encoded as a JSON number (i64): consumers must use a 64-bit integer + /// reader. + pub amount_minor: i64, + /// The revenue stream both legs draw (per-stream disaggregation, §4.5). + pub revenue_stream: String, + /// ISO-4217 currency of the recognition entry. + pub currency: String, +} + +/// Emitted on every recognition **reversal / clawback** post (design §3.7 / +/// Group I1 `billing.ledger.revenue.recognition_reversed`) — when a reversal +/// compensates a prior release (`DR REVENUE / CR CONTRACT_LIABILITY`), never on +/// replay. Ids + amount + stream + period only; no PII. The mirror of +/// [`LedgerRevenueRecognized`]: same identity / amount, opposite legs. +/// Published in-txn via the transactional outbox alongside the `entry.posted` +/// event, so it commits atomically with the reversing entry (or rolls back +/// with it). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerRevenueRecognitionReversed { + /// The seller tenant whose ledger the reversal lands in — envelope routing + + /// consumer-side tenant scoping (an id, not PII; matches the other ledger + /// events, which all carry `tenant_id`). + pub tenant_id: Uuid, + /// The owning recognition schedule's id. + pub schedule_id: String, + /// The reversed segment's number (the segment stays `DONE`; design §4.3). + pub segment_no: i32, + /// The accounting period the reversal lands in (`YYYYMM`). + pub period_id: String, + /// Signed delta to cumulative recognized revenue, in minor units — NEGATIVE on a + /// reversal, the mirror of [`LedgerRevenueRecognized::amount_minor`] (positive). + /// A consumer nets recognition against reversals by summing `amount_minor` across + /// both event types, with no special-casing of the type-id; + /// the magnitude equals the reversing entry's DR/CR amount. Wire-encoded as a + /// JSON number (i64): consumers must use a 64-bit integer reader. + pub amount_minor: i64, + /// The revenue stream both legs draw (per-stream disaggregation, §4.5). + pub revenue_stream: String, + /// ISO-4217 currency of the reversal entry. + pub currency: String, +} + +/// Emitted when a recognition schedule is changed or cancelled (design §3.7 / +/// Group I1 `billing.ledger.schedule.changed`) — e.g. a schedule superseded by +/// a new version (a fresh `schedule_id`) or cancelled outright (Phase 3 / Group +/// H). Ids + enum codes only; no PII. Group H owns the emit site (it CALLS +/// [`super::publisher::LedgerEventPublisher::publish_schedule_changed`]); the +/// payload + producer + schema + lockstep are defined here. Unlike the +/// recognition release / reversal events this carries no amount — a +/// schedule-change is a lifecycle transition, not a posting. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerScheduleChanged { + /// The seller tenant whose schedule changed — envelope routing + consumer-side + /// tenant scoping (an id, not PII; matches the other ledger events, which all + /// carry `tenant_id`). + pub tenant_id: Uuid, + /// The schedule whose lifecycle changed (the one being superseded / + /// cancelled). + pub schedule_id: String, + /// The id of the successor schedule version when the change supersedes this + /// schedule with a fresh version (`REPLACED`); `None` on a cancellation (no + /// successor) or any change that mints no new schedule. + pub new_schedule_id: Option, + /// The modification-accounting treatment that let the change proceed — + /// `prospective` or `separate_contract` (the wire values `gate_treatment` + /// accepts; a `catch_up`/unknown treatment is refused before any emit). A stable + /// enum code, not free text. (Do NOT name a recognition-timing code like + /// `POINT_IN_TIME`/`OVER_TIME` here — the producer never emits those; + /// `change_service` ships `cmd.treatment`.) + pub treatment: String, + /// The resulting schedule status (`ACTIVE` | `COMPLETED` | `REPLACED` | + /// `CANCELLED`) — the durable lifecycle enum the change transitioned to. + pub status: String, +} + +/// Emitted on every successful credit-note post (Slice 3 §4.2 / Group F +/// `billing.ledger.credit_note.posted`) — never on replay. Ids + amount + the +/// recognized/deferred split parts + period only; no PII (no names / free-text +/// business content — `reason_code` is a closed enum code, NOT carried here to +/// keep the payload strictly ids/amounts). Published in-txn via the +/// transactional outbox alongside the `entry.posted` event, so it commits +/// atomically with the credit-note entry (or rolls back with it). Mirrors +/// [`LedgerSettlementReturned`] / [`LedgerRevenueRecognized`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CreditNotePosted { + /// The seller tenant whose ledger posted the credit note — envelope routing + + /// consumer-side tenant scoping (an id, not PII; matches the other ledger + /// events, which all carry `tenant_id`). + pub tenant_id: Uuid, + /// The business id of the posted credit note (the `(tenant, CREDIT_NOTE, + /// credit_note_id)` idempotency key + the `credit_note` row PK). + pub credit_note_id: String, + /// The originating posted invoice the note compensates. + pub origin_invoice_id: String, + /// The posted entry id of the credit-note journal entry (the `entry.posted` + /// event's subject — lets a consumer correlate the two). + pub entry_id: Uuid, + /// ISO-4217 currency of the note. + pub currency: String, + /// The note amount **incl-tax**, in minor units. Wire-encoded as a JSON + /// number (i64): consumers must use a 64-bit integer reader. + pub amount_minor: i64, + /// The ex-tax recognized part (debited `CONTRA_REVENUE` / `GOODWILL`). + pub recognized_part_minor: i64, + /// The ex-tax deferred part (debited `CONTRACT_LIABILITY`, reducing the + /// schedule's deferred remainder). + pub deferred_part_minor: i64, + /// Post timestamp (UTC). + pub posted_at_utc: DateTime, +} + +/// Emitted on every successful debit-note post (Slice 3 §4.3 / Group F +/// `billing.ledger.debit_note.posted`) — an additional charge against a posted +/// invoice; never on replay. Ids + amount + the recognized/deferred split parts +/// plus period only; no PII. Published in-txn via the transactional outbox +/// alongside the `entry.posted` event, so it commits atomically with the +/// debit-note entry (or rolls back with it). The mirror of [`CreditNotePosted`] +/// (an additional charge rather than a compensating reduction). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DebitNotePosted { + /// The seller tenant whose ledger posted the debit note — envelope routing + + /// consumer-side tenant scoping (an id, not PII; matches the other ledger + /// events, which all carry `tenant_id`). + pub tenant_id: Uuid, + /// The business id of the posted debit note (the `(tenant, DEBIT_NOTE, + /// debit_note_id)` idempotency key + the `debit_note` row PK). + pub debit_note_id: String, + /// The originating posted invoice the note charges against. + pub origin_invoice_id: String, + /// The posted entry id of the debit-note journal entry (the `entry.posted` + /// event's subject — lets a consumer correlate the two). + pub entry_id: Uuid, + /// ISO-4217 currency of the note. + pub currency: String, + /// The note amount **incl-tax**, in minor units. Wire-encoded as a JSON + /// number (i64): consumers must use a 64-bit integer reader. + pub amount_minor: i64, + /// The ex-tax recognized part (credited `REVENUE` at post). + pub recognized_part_minor: i64, + /// The ex-tax deferred part (credited `CONTRACT_LIABILITY`, anchoring a fresh + /// recognition schedule). + pub deferred_part_minor: i64, + /// Post timestamp (UTC). + pub posted_at_utc: DateTime, +} + +/// Emitted on every successful refund-phase post (Slice 3 §4.4 / §6 / Group G +/// `billing.ledger.refund.recorded`) — never on replay. Ids + enum codes + +/// amount + clearing-state only; no PII. Published in-txn via the transactional +/// outbox alongside the `entry.posted` event on a refund's stage post (and on +/// the `unknown_final` disposition), so it commits atomically with the refund +/// entry (or rolls back with it). The `phase` discriminates the lifecycle stage +/// (`initiated` / `confirmed` / `rejected` / `voided` / `unknown_final`, K-1 incl. +/// `unknown_final`); the `pattern` the economic shape (`A_UNALLOCATED` / +/// `B_RESTORE_AR`). Mirrors [`CreditNotePosted`] / [`LedgerSettlementReturned`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RefundRecorded { + /// The seller tenant whose ledger posted the refund — envelope routing + + /// consumer-side tenant scoping (an id, not PII; matches the other ledger + /// events, which all carry `tenant_id`). + pub tenant_id: Uuid, + /// The business id of the refund (the `refund` row's surrogate PK + /// `(tenant, refund_id)` + the `GET /refunds/{refundId}` handle). + pub refund_id: String, + /// The PSP's refund id (the `(tenant, psp_refund_id, phase)` idempotency + /// grain + the PSP-correlation handle a reconciler keys on). + pub psp_refund_id: String, + /// The posted entry id of the refund-stage journal entry (the `entry.posted` + /// event's subject — lets a consumer correlate the two). + pub entry_id: Uuid, + /// The lifecycle phase recorded (`initiated` / `confirmed` / `rejected` / + /// `voided` / `unknown_final`). + pub phase: String, + /// The economic pattern (`A_UNALLOCATED` / `B_RESTORE_AR`). + pub pattern: String, + /// The origin settled payment the refund unwinds. + pub payment_id: String, + /// The refund amount in minor units. Wire-encoded as a JSON number (i64): + /// consumers must use a 64-bit integer reader. + pub amount_minor: i64, + /// ISO-4217 currency of the refund. + pub currency: String, + /// The resulting clearing state stamped on the `refund` row (`PENDING` / + /// `SETTLED` / `REVERSED`). + pub clearing_state: String, +} + +/// Emitted on every successful governed manual-adjustment post (Slice 3 §4.6 / +/// Phase 3 / Group 4 `billing.ledger.manual_adjustment.posted`) — never on +/// replay. Ids + enum codes + amount only; no PII. Published in-txn via the +/// transactional outbox alongside the `entry.posted` event on a governed manual +/// post, so it commits atomically with the manual-adjustment entry (or rolls back +/// with it). A manual adjustment is the ledger's governed escape hatch (rounding +/// residue / suspense clean-up); the `action` carries which governed capability +/// posted and the `reason_code` its mandatory justification. The `actor_ref` is +/// the preparer's internal subject id (a uuid string, PII-free), NOT a name. +/// Mirrors [`CreditNotePosted`] / [`RefundRecorded`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ManualAdjustmentPosted { + /// The seller tenant whose ledger posted the adjustment — envelope routing + + /// consumer-side tenant scoping (an id, not PII; matches the other ledger + /// events, which all carry `tenant_id`). + pub tenant_id: Uuid, + /// The business id of the posted adjustment (the `(tenant, MANUAL_ADJUSTMENT, + /// adjustment_id)` idempotency key + the `GET` handle). + pub adjustment_id: String, + /// The posted entry id of the manual-adjustment journal entry (the + /// `entry.posted` event's subject — lets a consumer correlate the two). + pub entry_id: Uuid, + /// The governed action that posted (`ManualAdjustmentAction::as_str()`, e.g. + /// `ROUNDING_CORRECTION` / `SUSPENSE_CLEAR`) — a stable enum code, not free + /// text. + pub action: String, + /// The mandatory business reason code (a closed reason literal, not free + /// text — AC #14). + pub reason_code: String, + /// The preparer's internal subject id (a uuid string, PII-free) — the actor + /// who posted the adjustment. NOT a name / email. + pub actor_ref: String, + /// The gross adjustment amount in minor units (`Σ DR == Σ CR`; `govern` + /// guarantees the legs net to zero). Wire-encoded as a JSON number (i64): + /// consumers must use a 64-bit integer reader. + pub amount_minor: i64, + /// ISO-4217 currency of the adjustment (every leg shares it). + pub currency: String, +} + +/// Emitted on every successful unrealized-revaluation post (Slice 5 Phase 3 §3.6 / +/// §4.5 / design §6 `billing.ledger.fx.revaluation_completed`) — when a Mode-B +/// period-end run posts one functional-only `FX_UNREALIZED` entry for a moved +/// `(period, scope, payer)` grain set, never on replay. Ids + scope code + signed +/// functional amount + grain count only; no PII (the whole entry is +/// functional-only, `amount_minor = 0`, so there is no transaction amount to carry +/// — only the remeasurement movement). Published in-txn via the transactional +/// outbox alongside the `entry.posted` event (the `RevaluationCompletedSidecar`), +/// so it commits atomically with the revaluation entry (or rolls back with it). +/// Mirrors [`LedgerRevenueRecognized`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerFxRevaluationCompleted { + /// The seller tenant whose ledger ran the revaluation — envelope routing + + /// consumer-side tenant scoping (an id, not PII; matches the other ledger + /// events, which all carry `tenant_id`). + pub tenant_id: Uuid, + /// The posted `FX_UNREALIZED` entry id (the `entry.posted` event's subject — + /// lets a consumer correlate the two). + pub entry_id: Uuid, + /// The accounting period revalued (`YYYYMM`) — the period this entry posts INTO. + pub period_id: String, + /// The monetary scope revalued (`AR` / `UNALLOCATED` / `REUSABLE_CREDIT`) — a + /// stable enum code (`RevaluationScope::as_token`), not free text. + pub scope: String, + /// The payer tenant the entry is scoped to (one entry spans one payer — the + /// `MixedPayer` invariant; an id, not PII). + pub payer_id: Uuid, + /// ISO-4217 functional (reporting) currency the remeasurement is valued in. + pub functional_currency: String, + /// The net `FX_UNREALIZED` movement in functional minor units, SIGNED: positive + /// = an unrealized GAIN (the net contra posted CREDIT), negative = an unrealized + /// LOSS (posted DEBIT). The reversal next period carries its NEGATION. Wire- + /// encoded as a JSON number (i64): consumers must use a 64-bit integer reader. + pub fx_unrealized_minor: i64, + /// The number of grains moved (remeasured with a non-zero delta) in this entry. + pub grains_moved: i32, + /// Post timestamp (UTC). + pub posted_at_utc: DateTime, +} + +/// Emitted on every successful unrealized-revaluation REVERSAL post (Slice 5 +/// Phase 3 §4.5 / decision 7 / design §6 `billing.ledger.fx.revaluation_reversed`) +/// — when the run negates a prior `FX_REVALUATION` entry as a fresh +/// `FX_REVAL_REVERSAL` JE in the next OPEN period, never on replay. Ids + scope +/// code + signed functional amount only; no PII. Published in-txn via the +/// transactional outbox alongside the `entry.posted` event (the +/// `RevaluationReversedSidecar`), so it commits atomically with the reversal entry +/// (or rolls back with it). The mirror of [`LedgerFxRevaluationCompleted`]: same +/// scope/payer, opposite legs, in a later period. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerFxRevaluationReversed { + /// The seller tenant whose ledger posted the reversal — envelope routing + + /// consumer-side tenant scoping (an id, not PII). + pub tenant_id: Uuid, + /// The posted `FX_REVAL_REVERSAL` entry id (the `entry.posted` event's subject). + pub entry_id: Uuid, + /// The original `FX_REVALUATION` entry this reversal negates (lets a consumer + /// pair the reversal with the forward run it unwinds). + pub reverses_entry_id: Uuid, + /// The original revaluation period being reversed (`YYYYMM`). + pub reval_period_id: String, + /// The next OPEN period the reversal posts INTO (`YYYYMM`, strictly later than + /// `reval_period_id`). + pub reversal_period_id: String, + /// The monetary scope reversed (`AR` / `UNALLOCATED` / `REUSABLE_CREDIT`) — a + /// stable enum code (`RevaluationScope::as_token`), not free text. + pub scope: String, + /// The payer tenant the entry is scoped to (one entry spans one payer). + pub payer_id: Uuid, + /// ISO-4217 functional (reporting) currency. + pub functional_currency: String, + /// The net `FX_UNREALIZED` movement UNWOUND, in functional minor units, SIGNED — + /// the NEGATION of the original revaluation's `fx_unrealized_minor` (the mirror + /// of [`LedgerFxRevaluationCompleted::fx_unrealized_minor`]). A consumer nets a + /// revaluation against its reversal by summing `fx_unrealized_minor` across both + /// to zero. Wire-encoded as a JSON number (i64). + pub fx_unrealized_minor: i64, + /// Post timestamp (UTC). + pub posted_at_utc: DateTime, +} + +/// Emitted on every successful fiscal-period close (Slice 7 Group C / design §6 +/// `billing.ledger.period.closed`) — when the two-phase gate passes and the close +/// flips `fiscal_period OPEN→CLOSED` + `period_close → CLOSED` in one commit, never +/// on an idempotent re-close (an already-`CLOSED` period emits nothing). Published +/// **in-txn** via the transactional outbox in the SAME `SERIALIZABLE` transaction +/// as the flip, so the event commits atomically with the close (or rolls back with +/// it). Ids + period code + actor + timestamp only; no PII (a close posts no +/// financial lines, so there is no amount to carry). Mirrors +/// [`LedgerFxRevaluationCompleted`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerPeriodClosed { + /// The seller tenant whose ledger owns the closed period — envelope routing + + /// consumer-side tenant scoping (an id, not PII). + pub tenant_id: Uuid, + /// The selling legal-entity the period belongs to (the close unit; design §8). + /// In v1 one legal entity per tenant, so this equals `tenant_id`. + pub legal_entity_id: Uuid, + /// The accounting period closed (`YYYYMM`). + pub period_id: String, + /// The Finance actor who initiated the close (an id, not PII). + pub closed_by: Uuid, + /// Close commit timestamp (UTC). + pub closed_at_utc: DateTime, +} + +/// Emitted on every completed reconciliation check (Slice 7 Phase 3 / design §6 +/// `billing.ledger.reconciliation.completed`) — when the `ReconciliationFramework` +/// finalizes a `reconciliation_run` (AR↔derived, Payments↔PSP, or invoice-completeness), +/// carrying the check type + the variance result. Emitted on a DONE run, never on a +/// still-RUNNING / FAILED one. Published **in-txn** via the transactional outbox alongside +/// the run's `finalize` write, so the event commits atomically with the run row (or rolls +/// back with it). Ids + check-type code + signed variance + tolerance flag only; no PII (a +/// reconciliation posts no financial lines). Mirrors [`LedgerPeriodClosed`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerReconciliationCompleted { + /// The seller tenant whose ledger the run reconciled — envelope routing + + /// consumer-side tenant scoping (an id, not PII). + pub tenant_id: Uuid, + /// The reconciliation-run id (the `reconciliation_run` PK + the event subject). + pub run_id: Uuid, + /// The accounting period reconciled (`YYYYMM`). + pub period_id: String, + /// The check type (`AR_DERIVED` | `PAYMENTS_PSP` | `INVOICE_COMPLETENESS`) — a stable + /// enum code, not free text. + pub check_type: String, + /// The variance the run found, in minor units, SIGNED (`computed − cached`; `0` on a + /// clean run). Wire-encoded as a JSON number (i64): consumers must use a 64-bit integer + /// reader. + pub variance_minor: i64, + /// Whether the variance is within tolerance (X4) — `false` opens a close-blocking + /// exception (`RECON_MISMATCH` / `PSP_VARIANCE` / `MISSED_POSTING`). + pub within_tolerance: bool, + /// Run completion timestamp (UTC). + pub at_utc: DateTime, +} + +/// Category of an invariant-violation alarm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AlarmCategory { + /// Two posts share an idempotency key but disagree on payload (architecture I-9). + IdempotencyPayloadConflict, + /// A balance would go negative where the schema forbids it. + NegativeBalanceViolation, + /// A recomputed `account_balance` grain disagrees with the cached value + /// (tie-out variance; architecture §4.10, zero tolerance). + TieOutVariance, + /// A posted entry fails the entry-balance backstop (net debits ≠ net + /// credits per currency, no lines, or multiple payers). + EntryImbalance, + /// The daily chain Verifier re-walked a tenant's tamper-evidence hash chain + /// and found a `row_hash` recompute mismatch or a broken back-link + /// (architecture §4.2; the tenant is frozen tenant-wide on detection). + TamperVerifyFailed, + + // ── §4.7 catalog rows not represented by the 5 above ───────────────────── + // Each new variant is doc-commented with its owning slice + the design's + // kebab token (the §4.7 row id) so the catalog stays traceable. The wire + // token (`as_str`) is the SCREAMING_SNAKE form of the same kebab id. + /// §4.7 `chargeback-cash-negative` — owned by Slice 4. + ChargebackCashNegative, + /// §4.7 `recognition-double-credit` — owned by Slice 3. + RecognitionDoubleCredit, + /// §4.7 `over-recognition` — owned by Slice 3. + OverRecognition, + /// §4.7 `fx-snapshot-missing` — owned by Slice 3. + FxSnapshotMissing, + /// §4.7 `fx-snapshot-stale-allowed` — owned by Slice 3. + FxSnapshotStaleAllowed, + /// §4.7 `fx-snapshot-stale-blocked` — owned by Slice 3. + FxSnapshotStaleBlocked, + /// §4.7 `negative-tax-subbalance` — owned by Slice 5. + NegativeTaxSubbalance, + /// §4.7 `credit-note-split-blocked` — owned by Slice 4. + CreditNoteSplitBlocked, + /// §4.7 `refund-quarantined` — owned by Slice 4. + RefundQuarantined, + /// §4.7 `recognition-period-queued` — owned by Slice 3. + RecognitionPeriodQueued, + /// §4.7 `reconciliation-variance` — owned by Slice 7. + ReconciliationVariance, + /// §4.7 `export-failed-aged` — owned by Slice 7. + ExportFailedAged, + /// §4.7 `aged-allocation-queue` — owned by Slice 4. + AgedAllocationQueue, + /// §4.7 `aged-unallocated` — owned by Slice 4. + AgedUnallocated, + /// §4.7 `refund-clearing-aged` — owned by Slice 4. + RefundClearingAged, + /// §4.7 `dispute-phase-queued` — owned by Slice 4. + DisputePhaseQueued, + /// §4.7 `stage1-refund-orphan` — owned by Slice 4. + Stage1RefundOrphan, + /// A refund-of-refund CLAW-BACK never reconciled (Slice 3 §4.4 / Rev3 / Group + /// E). A claw-back whose money-out decrement would underflow (the PSP claw-back + /// arrived before / without the matching outbound refund stage-1, or claws back + /// more than was refunded) is DEFERRED on the `REFUND_CLAWBACK` queue, never + /// hard-failed; when it has sat `QUEUED` past the aging horizon without its + /// matching outbound landing it is CANCELLED and ESCALATED. `Critical`: a + /// books-affecting money-out that could not be netted against any outbound refund + /// — Finance must reconcile it (the `refunded_minor >= 0` CHECK never fired + /// because the underflow was deferred, not applied). Raised EXPLICITLY by the + /// `RefundHandler` on the never-reconcile escalation, out-of-band on the + /// CANCELLED queue row (an exception stub — full `exception_queue` is Slice 7 / + /// VHP-1859), mirroring the `CreditNoteSplitBlocked` explicit raise. + ClawbackUnderflow, + /// A `REFUND_CLEARING` balance aged past the PAGE threshold (14 days, design + /// §4.4 / §13) — the close-blocking `STUCK_REFUND_CLEARING` exception. The + /// 7-day `RefundClearingAged` Warn escalates to this `Critical` page at 14 + /// days: the clearing has been open long enough that it blocks the period + /// close (the Slice-7 close gate, §4.5) and joins the Payments↔PSP + /// reconciliation. Raised by the `AgedAlarmJob` alongside an + /// `// exception stub (full exception_queue is Slice 7)` marker (the + /// additive `exception_queue.type = STUCK_REFUND_CLEARING` lands in Slice 7 / + /// VHP-1859). `Critical`. Slice 6 catalog seam (the additive type name, + /// verbatim). + StuckRefundClearing, + /// §4.7 `billrun-partial-failure` — owned by Slice 2. + BillrunPartialFailure, + /// §4.7 `partition-detach-blocked` — owned by Slice 7. + PartitionDetachBlocked, + /// §4.7 `relay-lag` — owned by Slice 6. + RelayLag, + /// §4.7 `clock-skew` — owned by Slice 1. + ClockSkew, + /// §4.7 `attempted-write-off` — owned by Slice 4. + AttemptedWriteOff, + /// §4.7 `payer-attribution-drift` — owned by Slice 1. + PayerAttributionDrift, + /// §4.7 `fx-revaluation-incomplete` — owned by Slice 3. + FxRevaluationIncomplete, + /// §4.7 `dormant-open-credit` — owned by Slice 4. + DormantOpenCredit, + /// §4.7 `chain-lag` — owned by Slice 6 (post-MVP; defined-but-dormant). + ChainLag, + /// §4.7 `subtree-too-large` — owned by Slice 1 (post-MVP; defined-but-dormant). + SubtreeTooLarge, + /// §4.7 `subtree-resolution-degraded` — owned by Slice 1 + /// (post-MVP; defined-but-dormant). + SubtreeResolutionDegraded, + /// An issued invoice in the upstream issued-invoice manifest with no committed + /// `INVOICE_POST` (Rev3 / N-recon-1) — owned by Slice 7. Raised by the Phase 3 + /// invoice-completeness check when a `MISSED_POSTING` exception ages past the + /// configurable threshold (Warn → Page); the durable close-blocking row is the + /// `exception_queue.type = MISSED_POSTING` (design §4.3 / §4.6). + MissedPosting, +} + +impl AlarmCategory { + /// Every category, in declaration order — the canonical enumeration the + /// lockstep test folds over to assert the Rust enum and the vendored schema + /// `category` enum agree exactly. A new variant must be added here too (the + /// schema-lockstep test enumerates this slice), keeping enum ↔ schema in sync. + pub const ALL: &'static [Self] = &[ + Self::IdempotencyPayloadConflict, + Self::NegativeBalanceViolation, + Self::TieOutVariance, + Self::EntryImbalance, + // Slice 6 tamper-verification: the chain verifier emits this on a hash-chain + // mismatch (a Critical integrity alarm). It must be in `ALL` + the vendored + // schema or the eager validator drops the durable event when events are on. + Self::TamperVerifyFailed, + Self::ChargebackCashNegative, + Self::AgedAllocationQueue, + Self::DisputePhaseQueued, + Self::AgedUnallocated, + Self::RecognitionDoubleCredit, + Self::OverRecognition, + Self::RecognitionPeriodQueued, + Self::CreditNoteSplitBlocked, + Self::ClawbackUnderflow, + Self::RefundQuarantined, + Self::RefundClearingAged, + Self::Stage1RefundOrphan, + Self::StuckRefundClearing, + Self::AttemptedWriteOff, + Self::NegativeTaxSubbalance, + // Slice 5 (FX) — the rate missing/staleness family. `FxSnapshotMissing` + // is emitted by the `RateSyncJob` on a configured-provider sync failure; + // the two staleness rows are emitted at the lock-time `RateSource` path + // (live S1/S2 hook deferred). All three carry their severity/routing/ + // owning-slice in `alarm_catalog`, so they join the canonical catalog + + // vendored schema here. + Self::FxSnapshotMissing, + Self::FxSnapshotStaleAllowed, + Self::FxSnapshotStaleBlocked, + // Slice 7 (reconciliation) — the recon framework is the first emitter of + // both: an out-of-tolerance reconciliation run raises `ReconciliationVariance` + // (alongside the close-blocking `RECON_MISMATCH`/`PSP_VARIANCE` exception), + // and an aged invoice-completeness gap raises `MissedPosting`. Both join the + // canonical catalog + the vendored schema here so they validate when events + // are enabled. + Self::ReconciliationVariance, + Self::MissedPosting, + ]; + + /// Stable `SCREAMING_SNAKE_CASE` code for this category (the durable event + /// `category` field + the schema enum value). Matches the existing categories' + /// `as_str` convention — the kebab-case `chargeback-cash-negative` from the + /// architecture is the human/wire `alarmCategory` label, while the persisted + /// event enum stays `SCREAMING_SNAKE_CASE` like its siblings. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::IdempotencyPayloadConflict => "IDEMPOTENCY_PAYLOAD_CONFLICT", + Self::NegativeBalanceViolation => "NEGATIVE_BALANCE_VIOLATION", + Self::TieOutVariance => "TIE_OUT_VARIANCE", + Self::EntryImbalance => "ENTRY_IMBALANCE", + Self::TamperVerifyFailed => "TAMPER_VERIFY_FAILED", + Self::ChargebackCashNegative => "CHARGEBACK_CASH_NEGATIVE", + Self::RecognitionDoubleCredit => "RECOGNITION_DOUBLE_CREDIT", + Self::OverRecognition => "OVER_RECOGNITION", + Self::FxSnapshotMissing => "FX_SNAPSHOT_MISSING", + Self::FxSnapshotStaleAllowed => "FX_SNAPSHOT_STALE_ALLOWED", + Self::FxSnapshotStaleBlocked => "FX_SNAPSHOT_STALE_BLOCKED", + Self::NegativeTaxSubbalance => "NEGATIVE_TAX_SUBBALANCE", + Self::CreditNoteSplitBlocked => "CREDIT_NOTE_SPLIT_BLOCKED", + Self::RefundQuarantined => "REFUND_QUARANTINED", + Self::RecognitionPeriodQueued => "RECOGNITION_PERIOD_QUEUED", + Self::ReconciliationVariance => "RECONCILIATION_VARIANCE", + Self::ExportFailedAged => "EXPORT_FAILED_AGED", + Self::AgedAllocationQueue => "AGED_ALLOCATION_QUEUE", + Self::AgedUnallocated => "AGED_UNALLOCATED", + Self::RefundClearingAged => "REFUND_CLEARING_AGED", + Self::DisputePhaseQueued => "DISPUTE_PHASE_QUEUED", + Self::Stage1RefundOrphan => "STAGE1_REFUND_ORPHAN", + Self::ClawbackUnderflow => "CLAWBACK_UNDERFLOW", + Self::StuckRefundClearing => "STUCK_REFUND_CLEARING", + Self::BillrunPartialFailure => "BILLRUN_PARTIAL_FAILURE", + Self::PartitionDetachBlocked => "PARTITION_DETACH_BLOCKED", + Self::RelayLag => "RELAY_LAG", + Self::ClockSkew => "CLOCK_SKEW", + Self::AttemptedWriteOff => "ATTEMPTED_WRITE_OFF", + Self::PayerAttributionDrift => "PAYER_ATTRIBUTION_DRIFT", + Self::FxRevaluationIncomplete => "FX_REVALUATION_INCOMPLETE", + Self::DormantOpenCredit => "DORMANT_OPEN_CREDIT", + Self::ChainLag => "CHAIN_LAG", + Self::SubtreeTooLarge => "SUBTREE_TOO_LARGE", + Self::SubtreeResolutionDegraded => "SUBTREE_RESOLUTION_DEGRADED", + Self::MissedPosting => "MISSED_POSTING", + } + } +} + +/// Severity of an invariant-violation alarm. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AlarmSeverity { + /// Recoverable / deterministically re-detectable on retry. + Warn, + /// Integrity-threatening; requires operator attention. + Critical, +} + +impl AlarmSeverity { + /// Stable `SCREAMING_SNAKE_CASE` code for this severity. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Warn => "WARN", + Self::Critical => "CRITICAL", + } + } +} + +/// One grain/entry that tripped an invariant — identifies WHICH balance +/// diverged and by how much, so an operator paged on an alarm needn't re-run +/// tie-out by hand. Ids only; no PII. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AffectedItem { + /// Account id, entry id (uuid string), or a sub-grain key. + pub id: String, + /// Grain currency (empty where the defect has no single currency). + pub currency: String, + /// Recomputed-from-journal ("truth") minor units; `0` where not applicable. + pub expected_minor: i64, + /// Cached / observed minor units (the cached balance, or an entry's net). + pub actual_minor: i64, +} + +/// Emitted out-of-band when a posting attempt trips a ledger invariant. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerInvariantAlarm { + /// Invariant category. + pub category: AlarmCategory, + /// Severity. + pub severity: AlarmSeverity, + /// Owning tenant. + pub tenant_id: Uuid, + /// Diagnostic scope. Hot posting-path alarms use + /// `"tenant:{uuid}/flow:{flow}/business:{id}"`; tie-out alarms carry just + /// `"tenant:{uuid}"` (the divergent grains are named in `affected`). + pub scope: String, + /// Ledger error code that triggered the alarm. + pub code: String, + /// Internal diagnostic detail — no PII. + pub detail: String, + /// The specific grains/entries that diverged (capped). Empty for alarms + /// raised on the hot posting path, where `detail` already names the defect. + pub affected: Vec, +} + +// ─── §6 dormant event payloads (Slice 6 Phase 4 Group 4C) ──────────────────── +// +// These five payloads are the DURABLE SHAPES a future producer will emit for the +// audit / tamper / privacy lifecycle. They are defined now (so the wire contract +// is fixed and round-trip-tested) but DORMANT: the gear ships with +// `events_enabled = false` (`module.rs::build_event_publisher`) because the +// platform event-type GTS model is incomplete, so NO broker producer is wired +// for them and they carry no vendored JSON-Schema yet. The authoritative record +// of each of these facts ALREADY exists in the secured-audit / tamper-evidence +// chain; these structs are the future relay shape, not the source of truth. +// Every field is an id, token, count, or timestamp — there is NO PII. + +/// Emitted when the daily chain Verifier re-walks a tenant's chain and it +/// verifies clean (dormant; see the §6 note above). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerTamperVerified { + /// Tenant whose chain was verified. + pub tenant_id: Uuid, + /// Number of entries the walk visited (the observed chain length). + pub chain_length: i64, + /// When the verification completed (UTC). + pub verified_at_utc: DateTime, +} + +/// Emitted when the daily chain Verifier detects a break (dormant; see the §6 +/// note above). The tenant is frozen tenant-wide on detection. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerTamperFailed { + /// Tenant whose chain failed verification. + pub tenant_id: Uuid, + /// The entry at which the break was detected. + pub break_entry_id: Uuid, + /// Period of that entry. + pub period_id: String, + /// When the break was detected (UTC). + pub detected_at_utc: DateTime, +} + +/// Emitted when a GDPR right-to-erasure is applied to a payer's PII map +/// (dormant; see the §6 note above). Carries a stable reference token, never the +/// erased PII itself. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerErasureApplied { + /// Tenant that owns the PII map. + pub tenant_id: Uuid, + /// Opaque reference / token for the erased payer record (NOT the PII). + pub payer_pii_ref: String, + /// When the erasure was applied (UTC). + pub applied_at_utc: DateTime, +} + +/// Emitted when a forensic re-identification of a payer is recorded (dormant; +/// see the §6 note above). Carries reference tokens, never the resolved PII. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerReidentificationRecorded { + /// Tenant that owns the PII map. + pub tenant_id: Uuid, + /// Opaque reference / token for the re-identified payer record (NOT the PII). + pub payer_pii_ref: String, + /// Opaque reference / token for the investigator who performed the read. + pub investigator_ref: String, + /// When the re-identification was recorded (UTC). + pub recorded_at_utc: DateTime, +} + +/// Emitted when an audit-pack CSV export completes (dormant; see the §6 note +/// above). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerAuditPackExported { + /// Tenant under whose HOME scope the export ran. + pub tenant_id: Uuid, + /// Number of data rows in the pack (excludes the header). + pub row_count: i64, + /// For a cross-tenant (forensic) export, the TARGET tenant whose rows were + /// read; `None` for a routine own-tenant export. + pub target_scope_tenant_id: Option, + /// When the export completed (UTC). + pub exported_at_utc: DateTime, +} + +// Tests parked with the event broker: the `TypedEvent` impls + JSON-Schema +// lockstep checks return with `event-broker-sdk` (see +// `crate::infra::events::publisher`). diff --git a/gears/bss/ledger/ledger/src/infra/events/publisher.rs b/gears/bss/ledger/ledger/src/infra/events/publisher.rs new file mode 100644 index 000000000..2b99faec2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/events/publisher.rs @@ -0,0 +1,357 @@ +//! `LedgerEventPublisher` — the adapter that would front the platform event broker. +//! +//! Built once at `init()` (see [`crate::module`]) and threaded into the posting +//! engine and the background jobs. +//! +//! TODO(broker): the event broker (`event-broker-sdk`) is not yet available in +//! gears-rust, so this publisher is **parked**: every `publish_*` call logs and +//! returns `Ok(())` (the posting txn is unaffected), and `emit_invariant_alarm` +//! still mirrors the alarm into the Prometheus counter but does not publish. +//! When the broker lands, restore the transactional-outbox producers here: hold +//! one `event_broker_sdk::AsyncProducer` per event type (posted, entry-reversed, +//! dispute-recorded, settlement-returned, revenue-recognized, +//! revenue-recognition-reversed, schedule-changed, credit-note-posted, +//! debit-note-posted, refund-recorded, manual-adjustment-posted, +//! fx-revaluation-completed, fx-revaluation-reversed, period-closed, +//! reconciliation-completed, and the invariant alarm), and have each `publish_*` +//! call `producer.publish(ctx, txn, event).await` inside the caller's +//! transaction (transactional outbox: the outbox row commits atomically with the +//! entry, or not at all). + +use std::sync::Arc; + +use toolkit_security::SecurityContext; + +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::events::payloads::{ + CreditNotePosted, DebitNotePosted, LedgerDisputeRecorded, LedgerEntryPosted, + LedgerEntryReversed, LedgerFxRevaluationCompleted, LedgerFxRevaluationReversed, + LedgerInvariantAlarm, LedgerPeriodClosed, LedgerReconciliationCompleted, + LedgerRevenueRecognitionReversed, LedgerRevenueRecognized, LedgerScheduleChanged, + LedgerSettlementReturned, ManualAdjustmentPosted, RefundRecorded, +}; + +/// Publishes ledger events through the platform event broker. +/// +/// Broker parked (see module docs): the only live field is the optional metrics +/// handle used to mirror the invariant-alarm counter. When the broker lands, the +/// per-event `Option` fields + the alarm producer + the +/// `DBProvider` for the alarm's own transaction come back here. +pub struct LedgerEventPublisher { + /// Metrics handle for the alarm-counter mirror (`ledger_alarm_total`). + /// `Option` so the `noop` publisher (broker + metrics absent at `init()`) + /// needs no metrics dependency; when present, the counter increments even + /// with no broker. + metrics: Option>, +} + +/// Failure publishing the posted event into the caller's transaction. The +/// caller maps this into the txn-rollback path so the event and the entry are +/// all-or-nothing. +/// +/// Retained (even while the broker is parked) so the `publish_*` signatures and +/// the caller call sites are unchanged; every parked publish returns `Ok(())`. +#[derive(Debug, thiserror::Error)] +pub enum EventPublishError { + /// Outbox enqueue / schema validation failed. + #[error("event publish: {0}")] + Publish(String), +} + +/// Log a would-be event and return `Ok(())`. Broker parked: no outbox enqueue. +macro_rules! parked_publish { + ($self:ident, $ctx:ident, $txn:ident, $event:ident, $name:literal) => {{ + // TODO(broker): re-emit `$name` via the transactional outbox once + // `event-broker-sdk` lands — was `producer.publish($ctx, $txn, $event).await`. + tracing::trace!(event = $name, "bss-ledger: event skipped (broker parked)"); + let _ = (&$self.metrics, $ctx, $txn, $event); + Ok(()) + }}; +} + +impl LedgerEventPublisher { + /// Build a publisher that mirrors the invariant-alarm counter into `metrics` + /// but publishes no events (broker parked). Used at `init()` when events are + /// enabled; the alarm counter is still observable in Prometheus. + #[must_use] + pub fn with_metrics(metrics: Arc) -> Self { + Self { + metrics: Some(metrics), + } + } + + /// Build a no-op publisher: every event is skipped and no metric is mirrored. + /// Used when the broker is absent at `init()` and by the unit/integration + /// tests that exercise the broker-absent path. + #[must_use] + pub fn noop() -> Self { + Self { metrics: None } + } + + /// Publish the posted event into the caller's transaction. Broker parked: + /// logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] when the broker is wired and the outbox + /// enqueue or schema validation fails; currently never returns `Err`. + pub async fn publish_entry_posted( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerEntryPosted, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.entry.posted") + } + + /// Publish the entry-reversed event into the caller's transaction. Broker + /// parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_entry_reversed( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerEntryReversed, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.entry.reversed") + } + + /// Publish the dispute-recorded event into the caller's transaction. Broker + /// parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_dispute_recorded( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerDisputeRecorded, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.dispute.recorded") + } + + /// Publish the settlement-returned event into the caller's transaction. + /// Broker parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_settlement_returned( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerSettlementReturned, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.settlement.returned") + } + + /// Publish the revenue-recognized event into the caller's transaction. + /// Broker parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_revenue_recognized( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerRevenueRecognized, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.revenue.recognized") + } + + /// Publish the revenue-recognition-reversed event into the caller's + /// transaction. Broker parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_revenue_recognition_reversed( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerRevenueRecognitionReversed, + ) -> Result<(), EventPublishError> { + parked_publish!( + self, + ctx, + txn, + event, + "billing.ledger.revenue.recognition_reversed" + ) + } + + /// Publish the schedule-changed event into the caller's transaction. Broker + /// parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_schedule_changed( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerScheduleChanged, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.schedule.changed") + } + + /// Publish the credit-note-posted event into the caller's transaction. Broker + /// parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_credit_note_posted( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: CreditNotePosted, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.credit_note.posted") + } + + /// Publish the debit-note-posted event into the caller's transaction. Broker + /// parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_debit_note_posted( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: DebitNotePosted, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.debit_note.posted") + } + + /// Publish the refund-recorded event into the caller's transaction. Broker + /// parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_refund_recorded( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: RefundRecorded, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.refund.recorded") + } + + /// Publish the manual-adjustment-posted event into the caller's transaction. + /// Broker parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_manual_adjustment_posted( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: ManualAdjustmentPosted, + ) -> Result<(), EventPublishError> { + parked_publish!( + self, + ctx, + txn, + event, + "billing.ledger.manual_adjustment.posted" + ) + } + + /// Publish the fx-revaluation-completed event into the caller's transaction. + /// Broker parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_fx_revaluation_completed( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerFxRevaluationCompleted, + ) -> Result<(), EventPublishError> { + parked_publish!( + self, + ctx, + txn, + event, + "billing.ledger.fx.revaluation_completed" + ) + } + + /// Publish the fx-revaluation-reversed event into the caller's transaction. + /// Broker parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_fx_revaluation_reversed( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerFxRevaluationReversed, + ) -> Result<(), EventPublishError> { + parked_publish!( + self, + ctx, + txn, + event, + "billing.ledger.fx.revaluation_reversed" + ) + } + + /// Publish the period-closed event into the caller's transaction. Broker + /// parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_period_closed( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerPeriodClosed, + ) -> Result<(), EventPublishError> { + parked_publish!(self, ctx, txn, event, "billing.ledger.period.closed") + } + + /// Publish the reconciliation-completed event into the caller's transaction. + /// Broker parked: logs and returns `Ok(())`. + /// + /// # Errors + /// [`EventPublishError::Publish`] once the broker is wired; currently never. + pub async fn publish_reconciliation_completed( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + event: LedgerReconciliationCompleted, + ) -> Result<(), EventPublishError> { + parked_publish!( + self, + ctx, + txn, + event, + "billing.ledger.reconciliation.completed" + ) + } + + /// Emit an invariant alarm. The Prometheus counter mirror + /// (`ledger_alarm_total`) fires so the alarm is observable even with no + /// broker. Broker parked: the durable transactional-outbox publish is + /// skipped (logged). + /// + /// TODO(broker): when `event-broker-sdk` lands, open the alarm's OWN + /// transaction (the alarm fires after the post rolled back, so there is no + /// caller txn to ride) and enqueue the alarm into the transactional outbox so + /// the relay delivers it at-least-once. + pub async fn emit_invariant_alarm(&self, ctx: &SecurityContext, alarm: LedgerInvariantAlarm) { + // Counter mirror first, independent of the durable outbox: the alarm is + // observable in Prometheus/Alertmanager even when the broker is absent. + if let Some(metrics) = self.metrics.as_ref() { + metrics.invariant_alarm(alarm.category.as_str(), alarm.severity.as_str()); + } + tracing::warn!( + category = alarm.category.as_str(), + "bss-ledger: invariant alarm not published (broker parked)" + ); + let _ = ctx; + } +} + +#[cfg(test)] +#[path = "publisher_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/events/publisher_tests.rs b/gears/bss/ledger/ledger/src/infra/events/publisher_tests.rs new file mode 100644 index 000000000..f9c0f57b5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/events/publisher_tests.rs @@ -0,0 +1,169 @@ +//! Tests for the broker-absent (`noop`) publisher path. +//! +//! The noop publisher must accept every event method and return `Ok(())` (or +//! return without panicking for fire-and-forget calls) so a post never fails +//! just because events are disabled. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::doc_markdown +)] + +use chrono::{DateTime, Utc}; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::LedgerEventPublisher; +use crate::infra::events::payloads::{ + AffectedItem, AlarmCategory, AlarmSeverity, LedgerEntryPosted, LedgerEntryReversed, + LedgerInvariantAlarm, LedgerLineSummary, +}; + +fn sample_posted() -> LedgerEntryPosted { + LedgerEntryPosted { + entry_id: Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111), + tenant_id: Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222), + legal_entity_id: Uuid::from_u128(0x3333_3333_3333_3333_3333_3333_3333_3333), + period_id: "2026-06".to_owned(), + source_doc_type: "INVOICE".to_owned(), + source_business_id: "inv-42".to_owned(), + posted_at_utc: DateTime::::from_timestamp(1_700_000_000, 0).expect("ts"), + created_seq: 7, + lines: vec![ + LedgerLineSummary { + account_class: "AR".to_owned(), + side: "DR".to_owned(), + amount_minor: 1_000, + currency: "USD".to_owned(), + currency_scale: 2, + }, + LedgerLineSummary { + account_class: "REVENUE".to_owned(), + side: "CR".to_owned(), + amount_minor: 1_000, + currency: "USD".to_owned(), + currency_scale: 2, + }, + ], + } +} + +fn sample_alarm() -> LedgerInvariantAlarm { + LedgerInvariantAlarm { + category: AlarmCategory::NegativeBalanceViolation, + severity: AlarmSeverity::Critical, + tenant_id: Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222), + scope: "tenant:t/flow:f/business:b".to_owned(), + code: "NEGATIVE_BALANCE_VIOLATION".to_owned(), + detail: "balance would go negative".to_owned(), + affected: vec![AffectedItem { + id: Uuid::from_u128(0x4444_4444_4444_4444_4444_4444_4444_4444).to_string(), + currency: "USD".to_owned(), + expected_minor: 0, + actual_minor: -100, + }], + } +} + +/// Open an in-memory SQLite `DBProvider`. The noop publisher never writes to +/// the DB, so no migrations are needed. +async fn sqlite_provider() -> DBProvider { + let db = connect_db("sqlite::memory:", ConnectOpts::default()) + .await + .expect("open in-memory sqlite"); + DBProvider::::new(db) +} + +#[tokio::test] +async fn noop_publish_entry_posted_returns_ok() { + // The noop publisher returns `Ok(())` before touching the transaction, so + // an un-migrated in-memory DB is sufficient to obtain a `DbTx`. + let publisher = LedgerEventPublisher::noop(); + let ctx = SecurityContext::anonymous(); + let provider = sqlite_provider().await; + + let event = sample_posted(); + // The transaction closure must be `FnOnce` — move owned values in. + let result = provider + .transaction(|txn| { + Box::pin(async move { + publisher + .publish_entry_posted(&ctx, txn, event) + .await + .map_err(|e| toolkit_db::DbError::Sea(sea_orm::DbErr::Custom(e.to_string()))) + }) + }) + .await; + + assert!( + result.is_ok(), + "noop publisher must return Ok for publish_entry_posted: {result:?}" + ); +} + +#[tokio::test] +async fn noop_publish_entry_reversed_returns_ok() { + // The noop publisher returns `Ok(())` before touching the transaction, so an + // un-migrated in-memory DB is sufficient to obtain a `DbTx`. + let publisher = LedgerEventPublisher::noop(); + let ctx = SecurityContext::anonymous(); + let provider = sqlite_provider().await; + + let event = LedgerEntryReversed { + entry_id: Uuid::from_u128(0x8888_8888_8888_8888_8888_8888_8888_8888), + reverses_entry_id: Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111), + tenant_id: Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222), + reason: "duplicate charge backed out".to_owned(), + }; + let result = provider + .transaction(|txn| { + Box::pin(async move { + publisher + .publish_entry_reversed(&ctx, txn, event) + .await + .map_err(|e| toolkit_db::DbError::Sea(sea_orm::DbErr::Custom(e.to_string()))) + }) + }) + .await; + + assert!( + result.is_ok(), + "noop publisher must return Ok for publish_entry_reversed: {result:?}" + ); +} + +#[tokio::test] +async fn noop_emit_invariant_alarm_does_not_panic() { + // Fire-and-forget: noop path logs a warning and returns. No Err, no panic. + let publisher = LedgerEventPublisher::noop(); + let ctx = SecurityContext::anonymous(); + publisher.emit_invariant_alarm(&ctx, sample_alarm()).await; + // Reaching here without a panic means the noop path is correct. +} + +#[tokio::test] +async fn noop_emit_invariant_alarm_all_categories() { + // Ensure every AlarmCategory can be passed to the noop publisher without panic. + let publisher = LedgerEventPublisher::noop(); + let ctx = SecurityContext::anonymous(); + let tenant_id = Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222); + + // Drive EVERY category through the noop path (the canonical `ALL` slice, so a + // new variant is exercised automatically) — none may panic. + for category in AlarmCategory::ALL.iter().copied() { + let code = category.as_str().to_owned(); + let alarm = LedgerInvariantAlarm { + category, + severity: AlarmSeverity::Warn, + tenant_id, + scope: "tenant:t".to_owned(), + code, + detail: "test".to_owned(), + affected: vec![], + }; + publisher.emit_invariant_alarm(&ctx, alarm).await; + } +} diff --git a/gears/bss/ledger/ledger/src/infra/events/schemas.rs b/gears/bss/ledger/ledger/src/infra/events/schemas.rs new file mode 100644 index 000000000..9ec105a20 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/events/schemas.rs @@ -0,0 +1,288 @@ +//! Event-type GTS schemas and their types-registry registration helper. +//! +//! The JSON-Schema documents describe the [`payloads`](super::payloads) event +//! shapes and are embedded via `include_str!`. They are registered in +//! types-registry at `init()` (decision A — "make P5 real") so the +//! event-broker producers can validate every payload against a fetched schema +//! at `build_async` time (`ValidationTiming::Eager`). +//! +//! The schema `$id` carries the `gts://` URI prefix; the `*_TYPE_ID` consts +//! carry the bare GTS id (the prefix stripped) and match the corresponding +//! `TypedEvent::TYPE_ID` on the payload — a lockstep checked by the unit tests. + +use anyhow::Context; +use types_registry_sdk::{RegisterResult, TypesRegistryClient}; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.entry.posted` event type. +pub const ENTRY_POSTED_TYPE_ID: &str = "gts.cf.core.events.event.v1~cf.bss.ledger.entry_posted.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.entry.reversed` event type. +pub const ENTRY_REVERSED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.entry_reversed.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.invariant.alarm` event type. +pub const INVARIANT_ALARM_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.invariant_alarm.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.dispute.recorded` event type. +pub const DISPUTE_RECORDED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.dispute_recorded.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.settlement.returned` event type. +pub const SETTLEMENT_RETURNED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.settlement_returned.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.revenue.recognized` event type. +pub const REVENUE_RECOGNIZED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.revenue_recognized.v1"; + +/// Bare GTS id (no `gts://` prefix) of the +/// `billing.ledger.revenue.recognition_reversed` event type. +pub const REVENUE_RECOGNITION_REVERSED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.revenue_recognition_reversed.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.schedule.changed` event type. +pub const SCHEDULE_CHANGED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.schedule_changed.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.credit_note.posted` event type. +pub const CREDIT_NOTE_POSTED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.credit_note_posted.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.debit_note.posted` event type. +pub const DEBIT_NOTE_POSTED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.debit_note_posted.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.refund.recorded` event type. +pub const REFUND_RECORDED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.refund_recorded.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.manual_adjustment.posted` event type. +pub const MANUAL_ADJUSTMENT_POSTED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.manual_adjustment_posted.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.fx.revaluation_completed` event type. +pub const FX_REVALUATION_COMPLETED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.fx_revaluation_completed.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.fx.revaluation_reversed` event type. +pub const FX_REVALUATION_REVERSED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.fx_revaluation_reversed.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.period.closed` event type. +pub const PERIOD_CLOSED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.period_closed.v1"; + +/// Bare GTS id (no `gts://` prefix) of the `billing.ledger.reconciliation.completed` +/// event type. +pub const RECONCILIATION_COMPLETED_TYPE_ID: &str = + "gts.cf.core.events.event.v1~cf.bss.ledger.reconciliation_completed.v1"; + +/// Vendored JSON-Schema for the posted-entry event. +const ENTRY_POSTED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_entry_posted.v1.schema.json"); + +/// Vendored JSON-Schema for the entry-reversed event. +const ENTRY_REVERSED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_entry_reversed.v1.schema.json"); + +/// Vendored JSON-Schema for the invariant-alarm event. +const INVARIANT_ALARM_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_invariant_alarm.v1.schema.json"); + +/// Vendored JSON-Schema for the dispute-recorded event. +const DISPUTE_RECORDED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_dispute_recorded.v1.schema.json"); + +/// Vendored JSON-Schema for the settlement-returned event. +const SETTLEMENT_RETURNED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_settlement_returned.v1.schema.json"); + +/// Vendored JSON-Schema for the revenue-recognized event. +const REVENUE_RECOGNIZED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_revenue_recognized.v1.schema.json"); + +/// Vendored JSON-Schema for the revenue-recognition-reversed event. +const REVENUE_RECOGNITION_REVERSED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_revenue_recognition_reversed.v1.schema.json"); + +/// Vendored JSON-Schema for the schedule-changed event. +const SCHEDULE_CHANGED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_schedule_changed.v1.schema.json"); + +/// Vendored JSON-Schema for the credit-note-posted event. +const CREDIT_NOTE_POSTED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_credit_note_posted.v1.schema.json"); + +/// Vendored JSON-Schema for the debit-note-posted event. +const DEBIT_NOTE_POSTED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_debit_note_posted.v1.schema.json"); + +/// Vendored JSON-Schema for the refund-recorded event. +const REFUND_RECORDED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_refund_recorded.v1.schema.json"); + +/// Vendored JSON-Schema for the manual-adjustment-posted event. +const MANUAL_ADJUSTMENT_POSTED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_manual_adjustment_posted.v1.schema.json"); + +/// Vendored JSON-Schema for the fx-revaluation-completed event. +const FX_REVALUATION_COMPLETED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_fx_revaluation_completed.v1.schema.json"); + +/// Vendored JSON-Schema for the fx-revaluation-reversed event. +const FX_REVALUATION_REVERSED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_fx_revaluation_reversed.v1.schema.json"); + +/// Vendored JSON-Schema for the period-closed event. +const PERIOD_CLOSED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_period_closed.v1.schema.json"); + +/// Vendored JSON-Schema for the reconciliation-completed event. +const RECONCILIATION_COMPLETED_SCHEMA_JSON: &str = + include_str!("../../../schemas/billing_ledger_reconciliation_completed.v1.schema.json"); + +/// Register the ledger event-type GTS schemas with the types-registry. +/// +/// Mirrors the RBAC `register_schemas` pattern: parse each vendored schema, +/// register them in one batch, and abort on the first per-item failure. Must +/// run before the event-broker producers are built (Eager validation resolves +/// the just-registered schemas). +/// +/// # Errors +/// +/// Returns `Err` if a vendored schema fails to parse, if the batch +/// `register` call fails catastrophically (e.g. backend unavailable), or if +/// any individual schema is rejected by the registry +/// ([`RegisterResult::Err`]). +pub async fn register_event_schemas(registry: &dyn TypesRegistryClient) -> anyhow::Result<()> { + let entry_posted_schema: serde_json::Value = serde_json::from_str(ENTRY_POSTED_SCHEMA_JSON) + .context( + "bss-ledger: failed to parse vendored billing_ledger_entry_posted.v1.schema.json", + )?; + let entry_reversed_schema: serde_json::Value = serde_json::from_str(ENTRY_REVERSED_SCHEMA_JSON) + .context( + "bss-ledger: failed to parse vendored billing_ledger_entry_reversed.v1.schema.json", + )?; + let invariant_alarm_schema: serde_json::Value = + serde_json::from_str(INVARIANT_ALARM_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored billing_ledger_invariant_alarm.v1.schema.json", + )?; + let dispute_recorded_schema: serde_json::Value = + serde_json::from_str(DISPUTE_RECORDED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored billing_ledger_dispute_recorded.v1.schema.json", + )?; + let settlement_returned_schema: serde_json::Value = serde_json::from_str( + SETTLEMENT_RETURNED_SCHEMA_JSON, + ) + .context( + "bss-ledger: failed to parse vendored billing_ledger_settlement_returned.v1.schema.json", + )?; + let revenue_recognized_schema: serde_json::Value = + serde_json::from_str(REVENUE_RECOGNIZED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored billing_ledger_revenue_recognized.v1.schema.json", + )?; + let revenue_recognition_reversed_schema: serde_json::Value = + serde_json::from_str(REVENUE_RECOGNITION_REVERSED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored \ + billing_ledger_revenue_recognition_reversed.v1.schema.json", + )?; + let schedule_changed_schema: serde_json::Value = + serde_json::from_str(SCHEDULE_CHANGED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored billing_ledger_schedule_changed.v1.schema.json", + )?; + let credit_note_posted_schema: serde_json::Value = + serde_json::from_str(CREDIT_NOTE_POSTED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored billing_ledger_credit_note_posted.v1.schema.json", + )?; + let debit_note_posted_schema: serde_json::Value = + serde_json::from_str(DEBIT_NOTE_POSTED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored billing_ledger_debit_note_posted.v1.schema.json", + )?; + let refund_recorded_schema: serde_json::Value = + serde_json::from_str(REFUND_RECORDED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored billing_ledger_refund_recorded.v1.schema.json", + )?; + let manual_adjustment_posted_schema: serde_json::Value = + serde_json::from_str(MANUAL_ADJUSTMENT_POSTED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored \ + billing_ledger_manual_adjustment_posted.v1.schema.json", + )?; + let fx_revaluation_completed_schema: serde_json::Value = + serde_json::from_str(FX_REVALUATION_COMPLETED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored \ + billing_ledger_fx_revaluation_completed.v1.schema.json", + )?; + let fx_revaluation_reversed_schema: serde_json::Value = + serde_json::from_str(FX_REVALUATION_REVERSED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored \ + billing_ledger_fx_revaluation_reversed.v1.schema.json", + )?; + let period_closed_schema: serde_json::Value = serde_json::from_str(PERIOD_CLOSED_SCHEMA_JSON) + .context( + "bss-ledger: failed to parse vendored billing_ledger_period_closed.v1.schema.json", + )?; + let reconciliation_completed_schema: serde_json::Value = + serde_json::from_str(RECONCILIATION_COMPLETED_SCHEMA_JSON).context( + "bss-ledger: failed to parse vendored \ + billing_ledger_reconciliation_completed.v1.schema.json", + )?; + + let results = registry + .register(vec![ + entry_posted_schema, + entry_reversed_schema, + invariant_alarm_schema, + dispute_recorded_schema, + settlement_returned_schema, + revenue_recognized_schema, + revenue_recognition_reversed_schema, + schedule_changed_schema, + credit_note_posted_schema, + debit_note_posted_schema, + refund_recorded_schema, + manual_adjustment_posted_schema, + fx_revaluation_completed_schema, + fx_revaluation_reversed_schema, + period_closed_schema, + reconciliation_completed_schema, + ]) + .await + .context( + "bss-ledger: TypesRegistryClient::register(...) failed for the \ + ledger event-type schemas", + )?; + for result in results { + if let RegisterResult::Err { gts_id, error } = result { + return Err(anyhow::anyhow!( + "bss-ledger: failed to register event-type schema {} \ + in types-registry: {error}", + gts_id.as_deref().unwrap_or("") + )); + } + } + tracing::info!( + "bss-ledger: registered event-type GTS schemas {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, \ + {}, {}, {}, {} and {}", + ENTRY_POSTED_TYPE_ID, + ENTRY_REVERSED_TYPE_ID, + INVARIANT_ALARM_TYPE_ID, + DISPUTE_RECORDED_TYPE_ID, + SETTLEMENT_RETURNED_TYPE_ID, + REVENUE_RECOGNIZED_TYPE_ID, + REVENUE_RECOGNITION_REVERSED_TYPE_ID, + SCHEDULE_CHANGED_TYPE_ID, + CREDIT_NOTE_POSTED_TYPE_ID, + DEBIT_NOTE_POSTED_TYPE_ID, + REFUND_RECORDED_TYPE_ID, + MANUAL_ADJUSTMENT_POSTED_TYPE_ID, + FX_REVALUATION_COMPLETED_TYPE_ID, + FX_REVALUATION_REVERSED_TYPE_ID, + PERIOD_CLOSED_TYPE_ID, + RECONCILIATION_COMPLETED_TYPE_ID + ); + Ok(()) +} + +// Tests parked with the event broker: the JSON-Schema lockstep / `TypedEvent` +// checks return with `event-broker-sdk` (see `crate::infra::events::publisher`). diff --git a/gears/bss/ledger/ledger/src/infra/exception.rs b/gears/bss/ledger/ledger/src/infra/exception.rs new file mode 100644 index 000000000..106216355 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/exception.rs @@ -0,0 +1,179 @@ +//! `ExceptionRouter` — the additive seam that routes a per-slice stub condition to +//! a durable, close-blocking `exception_queue` row (Slice 7 Phase 2, design §4.6). +//! +//! **Additive (decision 5).** Each stub site already emits its alarm / log (and +//! sometimes rejects with a 422); routing is a SECOND effect beside it — an OPEN +//! row the close gate reads — never a replacement. So routing is **fire-and-forget**: +//! a failure to open the row is logged, never propagated, and never fails the money +//! path the stub sits on (the rejection / alarm already happened). The only new +//! behaviour is "this condition now blocks close until resolved / approved". +//! +//! The row is **period-bound to the tenant's current OPEN period** so the close +//! gate's `list_open_in_txn(period)` sees it. A stub rejects the operation (no post +//! commits), so binding to the period that is open *now* — the one a close would +//! certify next — is the correct close-blocking grain. + +use std::sync::Arc; + +use serde_json::Value as JsonValue; +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::exception::ExceptionType; +use crate::infra::storage::repo::{ExceptionQueueRepo, RecognitionRepo}; + +/// Routes a stub condition into the durable exception queue, fire-and-forget. Cheap +/// to clone (`DBProvider` is a handle); shared as an `Arc` by the stub-bearing +/// services. +#[derive(Clone)] +pub struct ExceptionRouter { + db: DBProvider, + /// Reused only for `current_open_period` (a `fiscal_period` read). + periods: RecognitionRepo, +} + +impl ExceptionRouter { + /// Build the router over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + let periods = RecognitionRepo::new(db.clone()); + Self { db, periods } + } + + /// Wrap the router as a shared handle (the form the services hold). + #[must_use] + pub fn shared(db: DBProvider) -> Arc { + Arc::new(Self::new(db)) + } + + /// Open one OPEN `exception_queue` row for `(tenant, ty, business_ref)`, bound to + /// the tenant's CURRENT OPEN period (so it blocks the next close). For a stub-site + /// condition that rejected a present-time operation, the period open *now* is the + /// correct close-blocking grain. A recon-origin caller that knows the reconciled + /// period MUST use [`Self::route_for_period`] instead — binding to the current open + /// period would leave a non-current reconciled period's close gate blind. + /// Fire-and-forget: any failure is logged, never propagated. + pub async fn route( + &self, + tenant: Uuid, + ty: ExceptionType, + business_ref: &str, + detail: Option, + ) { + self.route_inner(tenant, ty, business_ref, None, detail) + .await; + } + + /// Open one OPEN `exception_queue` row bound to an EXPLICIT `period` — the + /// reconciliation framework's grain, so the close gate for *that* period sees it. + /// A recon may run for a period that is not the tenant's current open one; binding + /// to `current_open_period` would leave the reconciled period's close gate blind. + pub async fn route_for_period( + &self, + tenant: Uuid, + ty: ExceptionType, + business_ref: &str, + period: &str, + detail: Option, + ) { + self.route_inner(tenant, ty, business_ref, Some(period.to_owned()), detail) + .await; + } + + /// Shared body: resolve the close-blocking period (an explicit `period_override` + /// for recon origin, else the tenant's current OPEN period), dedup on + /// `(tenant, type, business_ref)`, and insert one OPEN row. Fire-and-forget — any + /// failure is logged and swallowed (the stub's alarm / rejection is the primary + /// effect; the row must never fail the caller's path). + async fn route_inner( + &self, + tenant: Uuid, + ty: ExceptionType, + business_ref: &str, + period_override: Option, + detail: Option, + ) { + let scope = AccessScope::for_tenant(tenant); + + // Period-bind: an explicit recon period when given, else the current OPEN period + // (the stub-rejection grain). A transient resolve failure must not silently + // downgrade a close-blocking row to a period-less (dashboard-only) one, so retry + // a few times; only a genuine no-open-period (`Ok(None)`) or exhausted retries + // leaves the row period-less rather than dropping it. + let period = if let Some(p) = period_override { + Some(p) + } else { + let mut resolved = None; + for attempt in 1..=3u8 { + match self.periods.current_open_period(&scope, tenant).await { + Ok(p) => { + resolved = p; + break; + } + Err(e) => tracing::warn!( + target: "bss-ledger", + error = %e, + %tenant, + attempt, + exception_type = ty.as_str(), + "exception-route: current-open-period resolve failed (retrying)" + ), + } + } + resolved + }; + + let exception_id = Uuid::now_v7(); + let type_token = ty.as_str(); + let business_ref = business_ref.to_owned(); + let result = self + .db + .transaction(move |txn| { + let scope = scope.clone(); + let period = period.clone(); + let business_ref = business_ref.clone(); + let detail = detail.clone(); + Box::pin(async move { + // Dedup: skip if an OPEN row already exists for this business key + // (a periodic stub re-detects each scan; an inline re-try repeats + // the key). The check + open share this txn's snapshot. + if ExceptionQueueRepo::exists_open_for_ref( + txn, + &scope, + tenant, + type_token, + &business_ref, + ) + .await + .map_err(|e| DbError::Other(anyhow::anyhow!("dedup exception_queue: {e}")))? + { + return Ok(()); + } + ExceptionQueueRepo::open( + txn, + &scope, + tenant, + exception_id, + type_token, + &business_ref, + period.as_deref(), + detail, + ) + .await + .map_err(|e| DbError::Other(anyhow::anyhow!("open exception_queue: {e}"))) + }) + }) + .await; + + if let Err(e) = result { + tracing::warn!( + target: "bss-ledger", + error = %e, + %tenant, + exception_type = type_token, + "exception-route: failed to open exception_queue row (alarm already emitted)" + ); + } + } +} diff --git a/gears/bss/ledger/ledger/src/infra/fx.rs b/gears/bss/ledger/ledger/src/infra/fx.rs new file mode 100644 index 000000000..754913ebb --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/fx.rs @@ -0,0 +1,20 @@ +//! FX & multi-currency rate-service layer (Slice 5): resolve the locked rate for +//! a cross-currency entry over the local `ledger_fx_rate` store, freeze it in a +//! `ledger_fx_rate_snapshot`, and stamp the functional translation onto the +//! posting lines. +//! +//! - [`rate_source`] — resolution logic ([`RateSource`](rate_source::RateSource)): +//! provider-precedence ordering + staleness screening over the candidate rows. +//! - [`rate_locker`] — translate + snapshot + stamp +//! ([`RateLocker`](rate_locker::RateLocker)): the orchestration that resolves a +//! rate, persists the per-lock snapshot, and writes the functional columns. +//! +//! These are standalone components in this slice — NOT yet hooked into the live +//! S1/S2 posting paths (`invoice_post` / `settle`). The functional-currency +//! source (the AMS legal-entity feed, design S5-F3) is not wired in this slice, +//! so the caller cannot yet drive them; they exist and are unit-tested ahead of +//! that wiring. + +pub mod rate_locker; +pub mod rate_source; +pub mod revaluation_run; diff --git a/gears/bss/ledger/ledger/src/infra/fx/rate_locker.rs b/gears/bss/ledger/ledger/src/infra/fx/rate_locker.rs new file mode 100644 index 000000000..a2ea53b1b --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/fx/rate_locker.rs @@ -0,0 +1,152 @@ +//! `RateLocker` — translate + snapshot + stamp (design §4.2 / §4.5): for a +//! cross-currency entry, resolve the lock-ready rate +//! ([`RateSource`](super::rate_source::RateSource)), freeze it in a +//! `ledger_fx_rate_snapshot` ([`FxRepo::insert_snapshot`]), translate every line +//! to the functional currency at that single locked rate +//! ([`translate_entry`](crate::domain::fx::translate::translate_entry)), and +//! stamp the functional columns (`functional_amount_minor` / +//! `functional_currency` / `rate_snapshot_ref`) onto the lines in place. +//! +//! A single-currency entry (`transaction_ccy == functional_ccy`) needs no FX: the +//! functional columns stay NULL and no snapshot is written. +//! +//! **Wired into the live S1 (invoice/post) and S2 (settle) posting paths.** Each +//! caller drives this gated on `functional_ccy.is_some() && fc != entry_currency`, +//! so a single-currency tenant (no functional currency, or one equal to the entry +//! currency) is unaffected — no snapshot, functional columns stay NULL. The +//! cross-currency path is exercised by the controller's testcontainer test (the +//! snapshot insert needs a database); the single-currency short-circuit is +//! unit-tested here. + +use bss_ledger_sdk::AccountClass; +use chrono::{DateTime, Utc}; +use toolkit_db::secure::AccessScope; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::fx::translate::{FxLine, FxTranslateError, translate_entry}; +use crate::domain::model::NewLine; +use crate::infra::fx::rate_source::RateSource; +use crate::infra::storage::repo::{FxRepo, NewRateSnapshot}; + +/// Resolves + locks the FX rate for an entry and stamps the functional columns. +#[derive(Clone)] +pub struct RateLocker { + source: RateSource, + repo: FxRepo, +} + +impl RateLocker { + #[must_use] + pub fn new(source: RateSource, repo: FxRepo) -> Self { + Self { source, repo } + } + + /// Lock an FX rate for the entry and stamp the functional translation onto + /// `lines`, or do nothing for a single-currency entry. + /// + /// - `transaction_ccy == functional_ccy` (single-currency): returns + /// `Ok(None)` and leaves every line's functional columns NULL — there is no + /// translation to do and no snapshot to write. + /// - else (cross-currency): resolves the rate for `transaction_ccy → + /// functional_ccy`, inserts a `ledger_fx_rate_snapshot` from it (`rate_id`), + /// translates every line at the locked `rate_micro` with the per-entry + /// rounding residual closed onto the AR anchor (the first `AccountClass::Ar` + /// line, else line 0), and sets each line's `functional_amount_minor` / + /// `functional_currency` / `rate_snapshot_ref`. Returns `Ok(Some(rate_id))`. + /// + /// # Errors + /// - [`DomainError::FxRateUnavailable`] / [`DomainError::FxRateStaleNotAllowed`] + /// propagated from [`RateSource::resolve`]. + /// - [`DomainError::Internal`] on a snapshot-insert failure, or when the pure + /// translation rejects the input (a residual/anchor/overflow misuse — see + /// [`map_translate_err`]). + pub async fn lock_and_stamp( + &self, + scope: &AccessScope, + tenant: Uuid, + lines: &mut [NewLine], + transaction_ccy: &str, + functional_ccy: &str, + now: DateTime, + ) -> Result, DomainError> { + // Single-currency: nothing to translate, functional columns stay NULL. + if transaction_ccy == functional_ccy { + return Ok(None); + } + + // Resolve the lock-ready rate (transaction → functional) and freeze it. + let resolved = self + .source + .resolve(scope, tenant, transaction_ccy, functional_ccy, now) + .await?; + let rate_id = self + .repo + .insert_snapshot( + scope, + &NewRateSnapshot { + tenant_id: tenant, + base_currency: transaction_ccy.to_owned(), + quote_currency: functional_ccy.to_owned(), + rate_micro: resolved.rate_micro, + as_of: resolved.as_of, + provider: resolved.provider.clone(), + stale: resolved.stale, + fallback_order: resolved.fallback_order, + triangulated_via: resolved.triangulated_via.clone(), + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("fx snapshot insert: {e}")))?; + + // Translate every line at the single locked rate, closing the per-entry + // functional rounding residual onto the AR anchor (the leg whose + // functional dwarfs a ≤ lines−1 minor-unit residual). No AR line → anchor + // line 0 (the residual plug still balances the functional column; the + // anchor must merely be a substantial real line). + let fxlines: Vec = lines + .iter() + .map(|l| FxLine { + amount_minor: l.amount_minor, + side: l.side, + }) + .collect(); + let anchor = lines + .iter() + .position(|l| l.account_class == AccountClass::Ar) + .unwrap_or(0); + let func = translate_entry(&fxlines, resolved.rate_micro, anchor) + .map_err(|e| map_translate_err(&e))?; + + // Stamp the functional columns in place (one functional amount per line, + // input order). NOTE: the per-line `rate_snapshot_ref` FK (journal_line → + // fx_rate_snapshot) is NOT carried on `NewLine` — one rate per entry (§4.3), + // so it rides the entry header (`NewEntry.rate_snapshot_ref`): the live + // S1/S2 hook sets it from the `rate_id` returned here, and the journal repo + // stamps it onto every line on insert. The snapshot id is returned to the + // caller for that purpose. + for (line, func_amount) in lines.iter_mut().zip(func) { + line.functional_amount_minor = Some(func_amount); + line.functional_currency = Some(functional_ccy.to_owned()); + } + + Ok(Some(rate_id)) + } +} + +/// Map a pure [`FxTranslateError`] to a [`DomainError`]. Every variant is a +/// translate **misuse** the caller should not reach with a balanced entry and a +/// positive locked rate (a non-positive rate, an out-of-bounds anchor, a residual +/// that would drive the anchor non-positive, or an out-of-range product) — the +/// rate itself was already accepted by `RateSource`, so these are internal +/// invariant breaches, not a "no acceptable rate" condition. They therefore map +/// to [`DomainError::Internal`] (a 500 whose diagnostic stays server-side), NOT +/// `FxRateUnavailable` (which means the store had no usable quote). +#[must_use] +fn map_translate_err(e: &FxTranslateError) -> DomainError { + DomainError::Internal(format!("fx translation rejected the entry: {e}")) +} + +#[cfg(test)] +#[path = "rate_locker_tests.rs"] +mod rate_locker_tests; diff --git a/gears/bss/ledger/ledger/src/infra/fx/rate_locker_tests.rs b/gears/bss/ledger/ledger/src/infra/fx/rate_locker_tests.rs new file mode 100644 index 000000000..54985580c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/fx/rate_locker_tests.rs @@ -0,0 +1,123 @@ +//! Tests for `RateLocker`. +//! +//! The single-currency short-circuit (`transaction_ccy == functional_ccy` → +//! `Ok(None)`, functional columns left NULL) returns BEFORE any DB access, so it +//! runs against a bare in-memory SQLite `Db` with no migrations. The +//! cross-currency translate-path (resolve → snapshot insert → stamp) needs a +//! database for the snapshot insert, so it is a Docker-gated `#[ignore]` stub the +//! controller fills in alongside the live posting wiring. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::doc_markdown +)] + +use bss_ledger_sdk::{AccountClass, MappingStatus, Side}; +use chrono::Utc; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +use super::*; +use crate::config::FxConfig; +use crate::domain::model::NewLine; +use crate::infra::storage::repo::FxRepo; + +/// A minimal AR `NewLine` in `currency`, functional columns unset — the input +/// shape `lock_and_stamp` reads (`account_class`, `side`, `amount_minor`) and +/// writes (`functional_*`, `rate_snapshot_ref`). +fn ar_line(amount_minor: i64, side: Side, currency: &str) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + seller_tenant_id: None, + resource_tenant_id: None, + account_id: Uuid::now_v7(), + account_class: AccountClass::Ar, + gl_code: None, + side, + amount_minor, + currency: currency.to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Build a `RateLocker` over a bare in-memory SQLite provider (no migrations) — +/// enough for the single-currency short-circuit, which never touches the DB. +async fn locker_no_db() -> RateLocker { + // A shared-cache in-memory SQLite DB; the single-currency path returns before + // any query, so the (empty) schema is irrelevant. + let db = connect_db( + "sqlite:file:fx_rate_locker_unit?mode=memory&cache=shared", + ConnectOpts::default(), + ) + .await + .unwrap(); + let provider = DBProvider::::new(db); + let repo = FxRepo::new(provider); + let source = RateSource::new(repo.clone(), FxConfig::default()); + RateLocker::new(source, repo) +} + +#[tokio::test] +async fn single_currency_returns_none_and_leaves_functional_null() { + let locker = locker_no_db().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + // A balanced single-currency entry (USD == USD): no FX, no stamping. + let mut lines = vec![ + ar_line(1000, Side::Debit, "USD"), + ar_line(1000, Side::Credit, "USD"), + ]; + + let result = locker + .lock_and_stamp(&scope, tenant, &mut lines, "USD", "USD", Utc::now()) + .await + .expect("single-currency lock must succeed"); + + // No snapshot minted. + assert_eq!(result, None, "single-currency must return Ok(None)"); + // Functional columns untouched on every line. + for line in &lines { + assert_eq!(line.functional_amount_minor, None); + assert_eq!(line.functional_currency, None); + } +} + +/// Cross-currency translate-path (resolve → snapshot insert → stamp): needs a +/// migrated database for the `ledger_fx_rate` candidate + the +/// `ledger_fx_rate_snapshot` insert. Left as a Docker-gated stub the controller +/// completes when it wires the functional-currency source (design S5-F3) and the +/// live posting paths; the expected post-conditions are spelled out below. +#[tokio::test] +#[ignore = "requires Docker (testcontainers) — controller completes the cross-currency path"] +async fn cross_currency_stamps_functional_and_returns_snapshot_id() { + // Expected, once a migrated Postgres + a seeded `ledger_fx_rate` row exist: + // 1. `lock_and_stamp(scope, tenant, &mut lines, "EUR", "USD", now)` resolves + // the EUR->USD rate (the provider-ordered, non-stale candidate), + // 2. inserts ONE `ledger_fx_rate_snapshot` row (returned `Some(rate_id)`), + // 3. sets every line's `functional_amount_minor` (the translated USD amount, + // residual closed onto the AR anchor), `functional_currency == "USD"`, + // and `rate_snapshot_ref == rate_id`, + // 4. the functional column nets to zero (DR == CR) by construction. + // The pure translation itself (residual plug, banker's rounding) is already + // covered by `domain::fx::translate::translate_tests`. +} diff --git a/gears/bss/ledger/ledger/src/infra/fx/rate_source.rs b/gears/bss/ledger/ledger/src/infra/fx/rate_source.rs new file mode 100644 index 000000000..aef5b62d9 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/fx/rate_source.rs @@ -0,0 +1,213 @@ +//! `RateSource` — FX rate **resolution** (design §4.5 / F3): over the local +//! `ledger_fx_rate` candidate rows for a `(base, quote)` pair, pick the rate to +//! lock by the configured provider precedence, screening for staleness. +//! +//! The resolution is two pure decisions ([`order_index`] + [`is_stale`]) wrapped +//! around one repo read, so the money-relevant logic (ordering, staleness) is +//! unit-tested without a database. The selected provider's rank in +//! `provider_order` becomes the result's `fallback_order` (0 = the primary +//! provider). +//! +//! **v1 scope.** +//! - Direct pairs only — triangulation (design §4.6, e.g. via EUR) is deferred. +//! - The stale fallback is FORBIDDEN: if every candidate is stale the resolve +//! fails ([`DomainError::FxRateStaleNotAllowed`]) rather than locking a stale +//! rate. The design's per-tenant "tenant policy explicitly allows last-good" +//! is a follow-up (see the TODO at the all-stale branch). + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use toolkit_db::secure::AccessScope; +use uuid::Uuid; + +use crate::config::FxConfig; +use crate::domain::error::DomainError; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::storage::repo::FxRepo; + +/// G10 currencies — the major, deeply-liquid pairs held to the tighter +/// `stale_g10_hours` freshness window (design F3). A pair is "G10" if EITHER side +/// is in this set; everything else falls back to the (day-scale) +/// `stale_default_max_days` window. +const G10: &[&str] = &[ + "USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "SEK", "NOK", +]; + +/// A resolved, lock-ready FX rate — the rate +/// [`RateLocker`](crate::infra::fx::rate_locker::RateLocker) freezes into a +/// snapshot and translates the entry at. `fallback_order` is the chosen +/// provider's rank in the configured precedence (0 = primary). `stale` is always +/// `false` in v1 (a stale-only candidate set is rejected, not returned); +/// `triangulated_via` is always `None` in v1 (direct pairs only). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedRate { + pub rate_micro: i64, + pub provider: String, + pub as_of: DateTime, + pub stale: bool, + pub fallback_order: i32, + pub triangulated_via: Option, +} + +/// Rank of `provider` in the configured precedence: its index in +/// `provider_order` when listed, else `provider_order.len()` (so EVERY unlisted +/// provider sorts AFTER every listed one, and ties among unlisted providers are +/// broken by the caller's stable secondary key). Pure — unit-tested without a DB. +#[must_use] +fn order_index(provider: &str, provider_order: &[String]) -> usize { + provider_order + .iter() + .position(|p| p == provider) + .unwrap_or(provider_order.len()) +} + +/// Whether a rate is stale: `age` (now − `as_of`) exceeds the freshness window +/// for the pair. A G10 pair (either side in [`G10`]) uses `stale_g10_hours`; any +/// other pair uses `stale_default_max_days`. A negative `age` (a future `as_of`, +/// i.e. clock skew) is never stale (`age <= window` holds). Pure — unit-tested +/// without a DB. +#[must_use] +fn is_stale(base: &str, quote: &str, age: chrono::Duration, cfg: &FxConfig) -> bool { + let window = if is_g10_pair(base, quote) { + chrono::Duration::hours(i64::try_from(cfg.stale_g10_hours).unwrap_or(i64::MAX)) + } else { + chrono::Duration::days(i64::try_from(cfg.stale_default_max_days).unwrap_or(i64::MAX)) + }; + age > window +} + +/// True when either leg of the pair is a G10 currency. +#[must_use] +fn is_g10_pair(base: &str, quote: &str) -> bool { + G10.contains(&base) || G10.contains("e) +} + +/// Resolves the lock-ready rate for a currency pair over the local rate store. +#[derive(Clone)] +pub struct RateSource { + repo: FxRepo, + cfg: FxConfig, + /// Optional metrics sink for the provider-fallback counter + /// (`ledger_fx_provider_fallback_total{provider}`). `None` on the no-metrics + /// constructions (unit tests); wired via [`Self::with_metrics`] on the live + /// lock paths (S1 invoice-post, S2 settle) and the revaluation runner. + metrics: Option>, +} + +impl RateSource { + #[must_use] + pub fn new(repo: FxRepo, cfg: FxConfig) -> Self { + Self { + repo, + cfg, + metrics: None, + } + } + + /// Attach a metrics sink so a fallback resolve (a non-primary provider chosen) + /// increments `ledger_fx_provider_fallback_total{provider}`. Builder-style to + /// match the gear's `with_metrics` convention; a `RateSource` without it simply + /// records no fallback metric (the `fallback_order` is still stamped on the + /// snapshot either way). + #[must_use] + pub fn with_metrics(mut self, metrics: Arc) -> Self { + self.metrics = Some(metrics); + self + } + + /// Resolve the rate to lock for `(base → quote)` as of `now`. + /// + /// Reads every provider's `ledger_fx_rate` row for the pair, orders them by + /// the configured provider precedence (a provider's index in + /// `provider_order`; an unlisted provider sorts AFTER every listed one, ties + /// broken stably by `fallback_order` then `provider`), and returns the FIRST + /// non-stale candidate — its precedence rank stamped as `fallback_order` + /// (0 = primary). + /// + /// **Triangulation via EUR (design §4.6) is deferred — only direct pairs in + /// v1.** A missing direct pair is unavailable, not synthesized from two legs. + /// + /// # Errors + /// - [`DomainError::FxRateUnavailable`] when NO candidate row exists for the + /// pair (no provider has quoted it). + /// - [`DomainError::FxRateStaleNotAllowed`] when candidates exist but EVERY + /// one is stale (v1 forbids the stale fallback). + /// - [`DomainError::Internal`] on a repo / storage failure. + pub async fn resolve( + &self, + // Intentionally unused: FX rates are tenant-axis reference data, not + // object-scoped, so there is no BOLA surface to gate here — the repo read + // (`FxRepo::latest_rates`) re-derives `AccessScope::for_tenant(tenant)` from + // the server-trusted `tenant` and enforces it SQL-side. Kept on the + // signature for call-site uniformity with the other secured resolves. + _scope: &AccessScope, + tenant: Uuid, + base: &str, + quote: &str, + now: DateTime, + ) -> Result { + // Direct pairs only — see the §4.6 triangulation deferral above. + let mut candidates = self + .repo + .latest_rates(tenant, base, quote) + .await + .map_err(|e| DomainError::Internal(format!("fx latest_rates: {e}")))?; + + if candidates.is_empty() { + return Err(DomainError::FxRateUnavailable(format!( + "no FX rate in the local store for {base}->{quote} (tenant {tenant})" + ))); + } + + // Order by configured provider precedence, then stably by the row's own + // `fallback_order`, then `provider` (a deterministic total order so the + // pick is reproducible even when two providers share a precedence rank — + // e.g. both unlisted). + candidates.sort_by(|a, b| { + order_index(&a.provider, &self.cfg.provider_order) + .cmp(&order_index(&b.provider, &self.cfg.provider_order)) + .then(a.fallback_order.cmp(&b.fallback_order)) + .then_with(|| a.provider.cmp(&b.provider)) + }); + + // First non-stale candidate wins; its precedence rank is the result's + // `fallback_order` (0 = primary). + for row in &candidates { + let age = now - row.as_of; + if !is_stale(base, quote, age, &self.cfg) { + let rank = order_index(&row.provider, &self.cfg.provider_order); + // A non-primary pick (rank > 0) is a provider fallback: the primary + // provider had no fresh quote for this pair. Count it by the resolved + // provider so an FX-feed degradation is observable + // (`ledger_fx_provider_fallback_total{provider}`, §9). + if rank > 0 + && let Some(metrics) = &self.metrics + { + metrics.fx_provider_fallback(&row.provider); + } + return Ok(ResolvedRate { + rate_micro: row.rate_micro, + provider: row.provider.clone(), + as_of: row.as_of, + stale: false, + fallback_order: i32::try_from(rank).unwrap_or(i32::MAX), + triangulated_via: None, + }); + } + } + + // Candidates exist but all are stale. v1 forbids the stale fallback. + // TODO(VHP-1853): per-tenant stale-allowed policy → on the configured + // tenants, return the freshest stale candidate as + // ResolvedRate { stale: true, .. } instead of erroring here. + Err(DomainError::FxRateStaleNotAllowed(format!( + "every FX rate for {base}->{quote} is stale and the stale fallback is forbidden \ + (tenant {tenant})" + ))) + } +} + +#[cfg(test)] +#[path = "rate_source_tests.rs"] +mod rate_source_tests; diff --git a/gears/bss/ledger/ledger/src/infra/fx/rate_source_tests.rs b/gears/bss/ledger/ledger/src/infra/fx/rate_source_tests.rs new file mode 100644 index 000000000..65e7d1ba6 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/fx/rate_source_tests.rs @@ -0,0 +1,111 @@ +//! Unit tests for the pure resolution helpers: provider-precedence ordering and +//! the per-pair staleness window. These exercise the money-relevant decisions +//! ([`order_index`] / [`is_stale`] / [`is_g10_pair`]) without a database; the +//! DB-bound `resolve` end-to-end path is left to a testcontainer test the +//! controller adds. + +use super::*; + +fn cfg(g10_hours: u64, default_days: u64, order: &[&str]) -> FxConfig { + FxConfig { + revaluation_enabled: false, + stale_g10_hours: g10_hours, + stale_default_max_days: default_days, + rate_sync_tick_secs: 3_600, + revaluation_run_tick_secs: 86_400, + provider_order: order.iter().map(|s| (*s).to_owned()).collect(), + } +} + +// --- staleness window --- + +#[test] +fn g10_pair_fresh_just_under_24h() { + // A G10 pair (USD/EUR) at 23h age is within the 24h window → fresh. + let cfg = cfg(24, 7, &[]); + assert!(!is_stale("USD", "EUR", chrono::Duration::hours(23), &cfg)); +} + +#[test] +fn g10_pair_stale_past_24h() { + // The same pair at 25h age has crossed the 24h window → stale. + let cfg = cfg(24, 7, &[]); + assert!(is_stale("USD", "EUR", chrono::Duration::hours(25), &cfg)); +} + +#[test] +fn g10_window_boundary_is_exclusive_at_exactly_24h() { + // Exactly at the window (age == 24h) is NOT stale (`age > window` is strict); + // one second past it is. + let cfg = cfg(24, 7, &[]); + assert!(!is_stale("USD", "EUR", chrono::Duration::hours(24), &cfg)); + assert!(is_stale( + "USD", + "EUR", + chrono::Duration::hours(24) + chrono::Duration::seconds(1), + &cfg + )); +} + +#[test] +fn g10_classification_keys_on_either_leg() { + // Either side being G10 makes the pair G10 (here the quote leg, USD). + assert!(is_g10_pair("BRL", "USD")); + assert!(is_g10_pair("USD", "BRL")); + // Neither leg G10 → not a G10 pair. + assert!(!is_g10_pair("BRL", "INR")); +} + +#[test] +fn non_g10_pair_uses_max_days_window() { + // A non-G10 pair (BRL/INR) tolerates up to the configured max-days window: + // fresh at 6 days, stale past 7. + let cfg = cfg(24, 7, &[]); + assert!(!is_stale("BRL", "INR", chrono::Duration::days(6), &cfg)); + assert!(is_stale("BRL", "INR", chrono::Duration::days(8), &cfg)); +} + +#[test] +fn non_g10_uses_days_even_when_g10_hour_window_would_pass() { + // Guard the branch: a non-G10 pair at 30h is NOT stale (it is on the day-scale + // window), even though 30h exceeds the 24h G10 window. Catches a base/quote + // mix-up that mis-routes a non-G10 pair through the tighter window. + let cfg = cfg(24, 7, &[]); + assert!(!is_stale("BRL", "INR", chrono::Duration::hours(30), &cfg)); +} + +#[test] +fn future_as_of_is_never_stale() { + // A negative age (future as_of: clock skew) is within any window. + let cfg = cfg(24, 7, &[]); + assert!(!is_stale("USD", "EUR", chrono::Duration::hours(-5), &cfg)); + assert!(!is_stale("BRL", "INR", chrono::Duration::hours(-5), &cfg)); +} + +// --- provider precedence --- + +#[test] +fn order_index_ranks_by_configured_position() { + let order = ["ecb".to_owned(), "boe".to_owned(), "fed".to_owned()]; + assert_eq!(order_index("ecb", &order), 0); + assert_eq!(order_index("boe", &order), 1); + assert_eq!(order_index("fed", &order), 2); +} + +#[test] +fn unlisted_provider_sorts_after_every_listed_one() { + let order = ["ecb".to_owned(), "boe".to_owned()]; + // An unlisted provider gets rank == len, strictly greater than every listed + // rank — so it always sorts last. + assert_eq!(order_index("acme", &order), 2); + assert!(order_index("acme", &order) > order_index("boe", &order)); +} + +#[test] +fn empty_order_makes_every_provider_equal_rank() { + // With no configured order, all providers share rank 0; the resolver's stable + // secondary key (fallback_order, then provider) then decides. + let order: [String; 0] = []; + assert_eq!(order_index("ecb", &order), 0); + assert_eq!(order_index("acme", &order), 0); +} diff --git a/gears/bss/ledger/ledger/src/infra/fx/revaluation_run.rs b/gears/bss/ledger/ledger/src/infra/fx/revaluation_run.rs new file mode 100644 index 000000000..75a4ac0ef --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/fx/revaluation_run.rs @@ -0,0 +1,921 @@ +//! `UnrealizedRevaluationRun` (design §3.6 / §4.5) — Phase 3 Group H2/H3. A +//! Mode-B ledger (= ledger of record, decision 4) remeasures, at period end, +//! every open foreign-currency **monetary** grain `{AR, UNALLOCATED, +//! REUSABLE_CREDIT}` at the period-end rate against its carried functional value +//! and posts the difference as a dedicated **functional-only** entry +//! (`amount_minor = 0` on every line): one adjusting line per moved grain (it +//! moves the grain's functional carrying value) plus one net `FX_UNREALIZED` +//! contra line so the functional column balances. `CONTRACT_LIABILITY` is +//! excluded (non-monetary, ASC 830 / IAS 21). +//! +//! - **One entry per `(tenant, period, scope, payer)`.** A journal entry may span +//! only ONE payer tenant (`validate_balanced_entry`'s `MixedPayer` invariant), +//! so a scope's grains are grouped by payer and each payer gets its own entry. +//! The idempotency family is `(tenant, FX_REVALUATION, period_id:scope:payer)` — +//! `PostingService::post` claims it from the entry's +//! `source_doc_type`/`source_business_id`, so a re-run is a clean replay. +//! - **Reversal** (H3, decision 7): the run reads its own posted revaluation +//! entries for `period:scope:` (every payer) and posts the **negation** as fresh +//! `FX_REVAL_REVERSAL` JEs in the next OPEN period — not a line-negation of the +//! original by id, and posting cleanly after the original period closes (it +//! targets a different, open period). Only realized FX is permanent. +//! - **Mode gate** (H4): `FxConfig.revaluation_enabled` — fail-safe OFF so a +//! Mode-A tenant never double-counts vs its ERP. A disabled run is a no-op. +//! +//! Money-critical (the remeasure math is the pure +//! [`crate::domain::fx::revaluation`]); lives in `infra` because it needs repo + +//! posting + rate-source access. The grain enumeration is a cache scan (never a +//! `journal_line` rescan) — `PaymentRepo::list_*_to_revalue`. + +use std::collections::BTreeMap; +use std::str::FromStr; +use std::sync::Arc; + +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, NaiveDate, Utc}; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::config::FxConfig; +use crate::domain::error::DomainError; +use crate::domain::fx::revaluation::{ + RevaluationLine, RevaluationPosition, RevaluationScope, remeasure, +}; +use crate::domain::fx::translate::translate_amount; +use crate::domain::model::{LineRecord, NewEntry, NewLine}; +use crate::domain::period::{period_end_utc, period_start_utc}; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::payloads::{LedgerFxRevaluationCompleted, LedgerFxRevaluationReversed}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::fx::rate_source::RateSource; +use crate::infra::posting::chart::{ChartIndex, load_chart}; +use crate::infra::posting::service::{PostSidecar, PostedFacts, PostingService}; +use crate::infra::storage::repo::{ + FxRepo, JournalRepo, PaymentRepo, RecognitionRepo, ReferenceRepo, RevaluationGrain, +}; + +/// Origin literal stamped on revaluation posts. +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// The outcome of one `(tenant, period, scope)` revaluation or reversal attempt +/// (aggregated across the scope's per-payer entries). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ScopeStatus { + /// Mode-B gate is off (`revaluation_enabled = false`) — no-op. + Disabled, + /// No cross-currency grain in scope, or every grain already at the period-end + /// rate (net zero) — nothing posted. + NothingToPost, + /// Revaluation entries posted (`entries` fresh; `grains` moved across them). + /// A re-run that fully replays reports `entries = 0`. + Posted { entries: usize, grains: usize }, + /// (Reversal) no original `FX_REVALUATION` entry exists for the period:scope — + /// nothing to reverse. + NothingToReverse, + /// (Reversal) no later OPEN period is available to post the reversal into — the + /// run retries on its next tick (the `PeriodOpenJob` must open the next period). + ReversalDeferred, + /// (Reversal) `FX_REVAL_REVERSAL` entries posted in the next OPEN period + /// (`entries` fresh; a full replay reports `0`). + Reversed { entries: usize }, +} + +/// One scope's outcome within a period run. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScopeOutcome { + pub scope: RevaluationScope, + pub status: ScopeStatus, +} + +/// The result of a full period run across the three monetary scopes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RevaluationReport { + pub period_id: String, + pub scopes: Vec, +} + +/// Map a [`RevaluationScope`] to its grain `account_class` (the line the +/// adjusting leg posts on, which routes the functional delta to the grain). +const fn scope_account_class(scope: RevaluationScope) -> AccountClass { + match scope { + RevaluationScope::Ar => AccountClass::Ar, + RevaluationScope::Unallocated => AccountClass::Unallocated, + RevaluationScope::ReusableCredit => AccountClass::ReusableCredit, + } +} + +/// Mode-B period-end unrealized-revaluation runner. +pub struct UnrealizedRevaluationRun { + posting: PostingService, + repo: PaymentRepo, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + rate_source: RateSource, + /// Read-back of the original revaluation entries for the reversal (H3). + journal: JournalRepo, + /// Resolve the next OPEN period for the reversal (`current_open_period`). + recognition: RecognitionRepo, + /// Event publisher for the in-txn `fx.revaluation_completed` / `_reversed` + /// outbox events (threaded into the per-post sidecars). `Arc`-shared with the + /// posting engine (which owns the `entry.posted` producer). + publisher: Arc, +} + +impl UnrealizedRevaluationRun { + /// Build the runner over one database provider, the event publisher (threaded + /// into the posting engine), and the FX config (rate source + Mode-B gate). + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + fx: FxConfig, + ) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + let repo = PaymentRepo::new(db.clone()); + let journal = JournalRepo::new(db.clone()); + let recognition = RecognitionRepo::new(db.clone()); + let rate_source = RateSource::new(FxRepo::new(db), fx); + Self { + posting, + repo, + reference, + resolver, + rate_source, + journal, + recognition, + publisher, + } + } + + /// Attach a metrics sink so the runner's period-end rate resolves emit the + /// provider-fallback counter (`ledger_fx_provider_fallback_total{provider}`) + /// when a non-primary provider is chosen at period end. Builder-style, matching + /// the gear's `with_metrics` convention; without it the runner records no + /// fallback metric (the `fallback_order` is still stamped on the snapshot). + #[must_use] + pub fn with_metrics(mut self, metrics: Arc) -> Self { + self.rate_source = self.rate_source.with_metrics(metrics); + self + } + + /// Run the unrealized revaluation for `period_id` across the three monetary + /// scopes `{AR, UNALLOCATED, REUSABLE_CREDIT}`, posting one entry per + /// `(scope, payer)` that has a net movement. A disabled run (Mode-A) is a + /// no-op across all scopes. + /// + /// # Errors + /// Propagates the first scope's [`DomainError`] (e.g. a `FxRateUnavailable` + /// from the period-end rate resolve, or a `PeriodClosed` if `period_id` is + /// not open) — the caller (job / REST) isolates per tenant. + pub async fn run_period( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + enabled: bool, + ) -> Result { + let mut scopes = Vec::with_capacity(RevaluationScope::all().len()); + for rev_scope in RevaluationScope::all() { + let status = self + .run_scope(ctx, scope, tenant, period_id, rev_scope, enabled) + .await?; + scopes.push(ScopeOutcome { + scope: rev_scope, + status, + }); + } + Ok(RevaluationReport { + period_id: period_id.to_owned(), + scopes, + }) + } + + /// Run the revaluation for one `(tenant, period, scope)`: enumerate the open + /// cross-currency grains, group them by payer (one entry per payer — the + /// `MixedPayer` invariant), remeasure each at the period-end rate, and post a + /// functional-only entry per moved payer. Idempotent via the engine's dedup on + /// `(tenant, FX_REVALUATION, period_id:scope:payer)`. + /// + /// # Errors + /// [`DomainError`] on a rate resolve, a malformed period id, a missing + /// `FX_UNREALIZED` account, or a post failure. + async fn run_scope( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + rev_scope: RevaluationScope, + enabled: bool, + ) -> Result { + // H4 — Mode gate: a Mode-A (or unset) tenant must not revalue. `enabled` is + // the caller's resolved per-tenant Mode-B decision (VHP-1986). + if !enabled { + return Ok(ScopeStatus::Disabled); + } + + // 1. Enumerate the open cross-currency grains in scope (cache scan, never a + // journal_line rescan), grouped by payer (one entry per payer). + let grains = self.list_grains(scope, tenant, rev_scope).await?; + if grains.is_empty() { + return Ok(ScopeStatus::NothingToPost); + } + let by_payer = group_by_payer(grains); + + // 2. Period-end instant: the rate in effect at period close (design §4.5). + let as_of = period_end_utc(period_id).ok_or_else(|| { + DomainError::Internal(format!("malformed period_id for revaluation: {period_id}")) + })?; + let chart = load_chart(&self.reference, scope, tenant).await?; + let mut rate_cache: BTreeMap = BTreeMap::new(); + + let mut entries = 0usize; + let mut moved = 0usize; + for (payer, payer_grains) in by_payer { + if let Some(grains_moved) = self + .run_payer( + ctx, + scope, + tenant, + period_id, + rev_scope, + payer, + &payer_grains, + as_of, + &chart, + &mut rate_cache, + ) + .await? + { + entries += 1; + moved += grains_moved; + } + } + if entries == 0 { + Ok(ScopeStatus::NothingToPost) + } else { + Ok(ScopeStatus::Posted { + entries, + grains: moved, + }) + } + } + + /// Remeasure + post one payer's grains within a scope. Returns the count of + /// grains moved (the entry was posted), or `None` when the payer nets to zero + /// (nothing posted). + #[allow(clippy::too_many_arguments)] + async fn run_payer( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + rev_scope: RevaluationScope, + payer: Uuid, + grains: &[RevaluationGrain], + as_of: DateTime, + chart: &ChartIndex, + rate_cache: &mut BTreeMap, + ) -> Result, DomainError> { + // One functional currency per tenant/legal-entity (F5). A grain set that + // mixes functional currencies is a multi-LE invariant breach (deferred, + // decision 5) — fail loud rather than post a mixed-functional entry. + // The caller (`run_scope`) only routes non-empty payer groups here, but + // propagate defensively rather than index — an empty set is no movement. + let functional_ccy = grains + .first() + .ok_or_else(|| { + DomainError::Internal(format!( + "revaluation called with no grains for tenant {tenant} payer {payer} scope {}", + rev_scope.as_token() + )) + })? + .functional_currency + .clone(); + if grains + .iter() + .any(|g| g.functional_currency != functional_ccy) + { + return Err(DomainError::Internal(format!( + "revaluation grains mix functional currencies for tenant {tenant} payer {payer} \ + scope {} (multi-LE not supported, decision 5)", + rev_scope.as_token() + ))); + } + + // Remeasure each grain at the period-end rate (one rate per txn currency). + let mut positions: Vec = Vec::with_capacity(grains.len()); + for g in grains { + let remeasured = if g.currency == functional_ccy { + g.balance_minor + } else { + let rate_micro = if let Some(r) = rate_cache.get(&g.currency) { + *r + } else { + let resolved = self + .rate_source + .resolve(scope, tenant, &g.currency, &functional_ccy, as_of) + .await?; + rate_cache.insert(g.currency.clone(), resolved.rate_micro); + resolved.rate_micro + }; + translate_amount(g.balance_minor, rate_micro) + .map_err(|e| DomainError::Internal(format!("revaluation translate: {e}")))? + }; + positions.push(RevaluationPosition { + normal_side: rev_scope.normal_side(), + carried_functional_minor: g.functional_balance_minor, + remeasured_functional_minor: remeasured, + }); + } + + let reval = remeasure(&positions) + .map_err(|e| DomainError::Internal(format!("revaluation remeasure: {e}")))?; + let Some(fx_unrealized) = reval.fx_unrealized else { + return Ok(None); // every grain at the period-end rate: nothing to post + }; + + // Build the functional-only entry: one adjusting line per moved grain + the + // net FX_UNREALIZED contra (all carrying THIS payer's tenant id). + let mut lines: Vec = Vec::with_capacity(grains.len() + 1); + let mut moved = 0usize; + for (g, leg) in grains.iter().zip(&reval.grain_lines) { + let Some(leg) = leg else { continue }; + let scale = self.scale_for(scope, tenant, &g.currency).await?; + lines.push(Self::grain_line(g, rev_scope, leg, scale)); + moved += 1; + } + let fx_account = chart + .resolve(AccountClass::FxUnrealized, &functional_ccy, None) + .ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned FX_UNREALIZED account for functional currency {functional_ccy}" + )) + })?; + let fx_scale = self.scale_for(scope, tenant, &functional_ccy).await?; + lines.push(Self::fx_unrealized_line( + payer, + fx_account, + &functional_ccy, + &fx_unrealized, + fx_scale, + )); + + let entry = Self::build_entry( + ctx, + tenant, + period_id, + period_end_naive(period_id), + &functional_ccy, + SourceDocType::FxRevaluation, + &business_id(period_id, rev_scope, payer), + None, + None, + ); + // Publish `billing.ledger.fx.revaluation_completed` IN the post txn (the + // transactional outbox) so the event commits atomically with the entry, or + // rolls back with it. Reached only on the fresh-claim path (a replay returns + // before the sidecar), so it fires once per posted revaluation entry. + let sidecar: Arc = Arc::new(RevaluationCompletedSidecar { + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + tenant_id: tenant, + period_id: period_id.to_owned(), + scope: rev_scope.as_token().to_owned(), + payer_id: payer, + functional_currency: functional_ccy.clone(), + fx_unrealized_minor: fx_unrealized_signed( + fx_unrealized.side, + fx_unrealized.functional_minor, + ), + grains_moved: i32::try_from(moved).unwrap_or(i32::MAX), + posted_at_utc: entry.posted_at_utc, + }); + self.posting + .post(ctx, scope, entry, lines, Some(sidecar)) + .await?; + Ok(Some(moved)) + } + + /// Reverse the unrealized revaluation for `reval_period_id` across the three + /// monetary scopes — fresh `FX_REVAL_REVERSAL` JEs in the next OPEN period that + /// negate the original revaluation entries (decision 7). A disabled run is a + /// no-op. Idempotent via the engine's dedup on + /// `(tenant, FX_REVAL_REVERSAL, reval_period_id:scope:payer)`. + /// + /// # Errors + /// Propagates the first scope's [`DomainError`] — the caller isolates per + /// tenant. + pub async fn reverse_period( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + reval_period_id: &str, + enabled: bool, + ) -> Result { + let mut scopes = Vec::with_capacity(RevaluationScope::all().len()); + for rev_scope in RevaluationScope::all() { + let status = self + .reverse_scope(ctx, scope, tenant, reval_period_id, rev_scope, enabled) + .await?; + scopes.push(ScopeOutcome { + scope: rev_scope, + status, + }); + } + Ok(RevaluationReport { + period_id: reval_period_id.to_owned(), + scopes, + }) + } + + /// Reverse one `(tenant, reval_period, scope)`: read every original per-payer + /// `FX_REVALUATION` entry for `reval_period:scope:`, resolve the next OPEN + /// period, and post each one's negation as a fresh `FX_REVAL_REVERSAL` JE + /// there. Deferred when no later OPEN period exists (the reval period must have + /// closed and a successor opened). + /// + /// # Errors + /// [`DomainError`] on a read fault, a malformed line, or a post failure. + async fn reverse_scope( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + reval_period_id: &str, + rev_scope: RevaluationScope, + enabled: bool, + ) -> Result { + if !enabled { + return Ok(ScopeStatus::Disabled); + } + + // 1. Find every original per-payer revaluation entry to negate. + let prefix = business_id_prefix(reval_period_id, rev_scope); + let originals = self + .journal + .list_entries_with_lines_by_doc_prefix( + scope, + tenant, + SourceDocType::FxRevaluation.as_str(), + &prefix, + ) + .await + .map_err(|e| DomainError::Internal(format!("list revaluation entries: {e}")))?; + if originals.is_empty() { + return Ok(ScopeStatus::NothingToReverse); + } + + // 2. Resolve the next OPEN period (the lowest open period_id). The reversal + // posts there only once the reval period has CLOSED — i.e. the current + // open period is strictly later than the reval period. Otherwise defer. + let Some(open_period) = self + .recognition + .current_open_period(scope, tenant) + .await + .map_err(|e| DomainError::Internal(format!("current open period: {e}")))? + else { + return Ok(ScopeStatus::ReversalDeferred); + }; + if open_period.as_str() <= reval_period_id { + return Ok(ScopeStatus::ReversalDeferred); + } + let effective_at = period_start_utc(&open_period).map_or_else( + || DateTime::::default().date_naive(), + |d| d.date_naive(), + ); + + // 3. Post the negation of each original as a fresh FX_REVAL_REVERSAL JE in + // the next open period (idempotent on the original's business_id). + let mut entries = 0usize; + for original in &originals { + let mut lines: Vec = Vec::with_capacity(original.lines.len()); + for r in &original.lines { + lines.push(reverse_line(r)?); + } + // One payer per entry (the `MixedPayer` invariant) — read it off any + // line; the reversal's net FX_UNREALIZED is the negation of the + // original's (the reversal flips each leg's side). + let payer_id = original.lines.first().map_or(tenant, |r| r.payer_tenant_id); + let fx_signed = reversal_fx_unrealized_signed(&lines); + let entry = Self::build_entry( + ctx, + tenant, + &open_period, + effective_at, + &original.entry_currency, + SourceDocType::FxRevalReversal, + &original.source_business_id, + Some(original.entry_id), + Some(reval_period_id.to_owned()), + ); + // Publish `billing.ledger.fx.revaluation_reversed` IN the post txn (the + // transactional outbox), atomically with the reversal entry. Reached only + // on the fresh-claim path, so it fires once per posted reversal entry. + let sidecar: Arc = Arc::new(RevaluationReversedSidecar { + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + tenant_id: tenant, + reverses_entry_id: original.entry_id, + reval_period_id: reval_period_id.to_owned(), + reversal_period_id: open_period.clone(), + scope: rev_scope.as_token().to_owned(), + payer_id, + functional_currency: original.entry_currency.clone(), + fx_unrealized_minor: fx_signed, + posted_at_utc: entry.posted_at_utc, + }); + self.posting + .post(ctx, scope, entry, lines, Some(sidecar)) + .await?; + entries += 1; + } + Ok(ScopeStatus::Reversed { entries }) + } + + /// Enumerate the open cross-currency grains for one scope. + async fn list_grains( + &self, + scope: &AccessScope, + tenant: Uuid, + rev_scope: RevaluationScope, + ) -> Result, DomainError> { + let r = match rev_scope { + RevaluationScope::Ar => self.repo.list_ar_invoices_to_revalue(scope, tenant).await, + RevaluationScope::Unallocated => { + self.repo.list_unallocated_to_revalue(scope, tenant).await + } + RevaluationScope::ReusableCredit => { + self.repo + .list_reusable_credit_to_revalue(scope, tenant) + .await + } + }; + r.map_err(|e| DomainError::Internal(format!("list revaluation grains: {e}"))) + } + + /// Resolve a currency's minor-unit scale. + async fn scale_for( + &self, + scope: &AccessScope, + tenant: Uuid, + currency: &str, + ) -> Result { + self.resolver + .resolve(scope, tenant, currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}"))) + } + + /// Build a per-grain functional-only adjusting line. `currency` is the grain's + /// **transaction** currency (the projector's grain key), `amount_minor = 0`, + /// and the functional movement rides `functional_amount_minor` on `leg.side` + /// so the projector moves the grain's `functional_balance_minor` by the signed + /// delta (debit-normal grain rises on DR / falls on CR). + fn grain_line( + g: &RevaluationGrain, + rev_scope: RevaluationScope, + leg: &RevaluationLine, + scale: u8, + ) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: g.payer_tenant_id, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: g.account_id, + account_class: scope_account_class(rev_scope), + gl_code: None, + side: leg.side, + amount_minor: 0, + currency: g.currency.clone(), + currency_scale: scale, + invoice_id: g.invoice_id.clone(), + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: Some(leg.functional_minor), + functional_currency: Some(g.functional_currency.clone()), + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: g.credit_grant_event_type.clone(), + ar_status: None, + } + } + + /// Build the net `FX_UNREALIZED` functional-only contra line (`amount_minor = + /// 0`, `currency = functional_ccy`). It carries the SAME `payer` as the grain + /// lines (the `MixedPayer` invariant) and projects onto the `FX_UNREALIZED` + /// `account_balance` grain only; the reversal next period undoes it. + fn fx_unrealized_line( + payer: Uuid, + account_id: Uuid, + functional_ccy: &str, + fx: &RevaluationLine, + scale: u8, + ) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: payer, + seller_tenant_id: None, + resource_tenant_id: None, + account_id, + account_class: AccountClass::FxUnrealized, + gl_code: None, + side: fx.side, + amount_minor: 0, + currency: functional_ccy.to_owned(), + currency_scale: scale, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: Some(fx.functional_minor), + functional_currency: Some(functional_ccy.to_owned()), + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } + } + + /// Build the revaluation/reversal entry header. `period_id` is the period the + /// entry posts INTO (the reval period for a run; the next OPEN period for a + /// reversal); `effective_at` is the reporting date (period-end for a run, + /// first-of-period for a reversal). `posted_at_utc` is now. + #[allow(clippy::too_many_arguments)] + fn build_entry( + ctx: &SecurityContext, + tenant: Uuid, + period_id: &str, + effective_at: NaiveDate, + functional_ccy: &str, + source_doc_type: SourceDocType, + source_business_id: &str, + reverses_entry_id: Option, + reverses_period_id: Option, + ) -> NewEntry { + NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + // v1: one legal entity per tenant (F5) — derived server-side. + legal_entity_id: tenant, + period_id: period_id.to_owned(), + entry_currency: functional_ccy.to_owned(), + source_doc_type, + source_business_id: source_business_id.to_owned(), + reverses_entry_id, + reverses_period_id, + posted_at_utc: Utc::now(), + effective_at, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: ctx.subject_id(), + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + } + } +} + +/// Group the scope's grains by payer tenant (one journal entry per payer — the +/// `MixedPayer` invariant). `BTreeMap` for a deterministic per-payer order. +fn group_by_payer(grains: Vec) -> BTreeMap> { + let mut by_payer: BTreeMap> = BTreeMap::new(); + for g in grains { + by_payer.entry(g.payer_tenant_id).or_default().push(g); + } + by_payer +} + +/// The idempotency `business_id` for a per-payer revaluation / reversal: +/// `period_id:scope:payer` (design §4.5 — the run and the reversal share the key +/// family, distinguished by `source_doc_type`; payer-scoped because an entry spans +/// only one payer). +fn business_id(period_id: &str, scope: RevaluationScope, payer: Uuid) -> String { + format!("{period_id}:{}:{payer}", scope.as_token()) +} + +/// The `business_id` prefix for ALL payers of one `(period, scope)` — the reversal +/// lookup (`period_id:scope:`). +fn business_id_prefix(period_id: &str, scope: RevaluationScope) -> String { + format!("{period_id}:{}:", scope.as_token()) +} + +/// The last calendar day of `period_id` (`YYYYMM`) as a `NaiveDate` — the +/// revaluation's effective date. Falls back to the epoch for a malformed id (the +/// run already validated `period_end_utc`, so this never hits the fallback on the +/// post path). +fn period_end_naive(period_id: &str) -> NaiveDate { + period_end_utc(period_id) + .and_then(|end| end.date_naive().pred_opt()) + .unwrap_or_else(|| DateTime::::default().date_naive()) +} + +/// Negate one posted revaluation line into a fresh reversal line: flip the side +/// (so the projector unwinds the grain's functional carrying value), keep the +/// account / grain keys / functional value / currency, and mint a new `line_id`. +/// `amount_minor` is `0` on a revaluation line, so the reversal stays +/// functional-only too. +fn reverse_line(record: &LineRecord) -> Result { + let orig_side = Side::from_str(&record.side) + .map_err(|_| DomainError::Internal(format!("reversal: bad side {}", record.side)))?; + let flipped = match orig_side { + Side::Debit => Side::Credit, + Side::Credit => Side::Debit, + }; + let account_class = AccountClass::from_str(&record.account_class).map_err(|_| { + DomainError::Internal(format!( + "reversal: bad account_class {}", + record.account_class + )) + })?; + let mapping_status = MappingStatus::from_str(&record.mapping_status).map_err(|_| { + DomainError::Internal(format!( + "reversal: bad mapping_status {}", + record.mapping_status + )) + })?; + let currency_scale = u8::try_from(record.currency_scale).map_err(|_| { + DomainError::Internal(format!( + "reversal: bad currency_scale {}", + record.currency_scale + )) + })?; + Ok(NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: record.payer_tenant_id, + seller_tenant_id: record.seller_tenant_id, + resource_tenant_id: record.resource_tenant_id, + account_id: record.account_id, + account_class, + gl_code: record.gl_code.clone(), + side: flipped, + amount_minor: record.amount_minor, + currency: record.currency.clone(), + currency_scale, + invoice_id: record.invoice_id.clone(), + due_date: record.due_date, + revenue_stream: record.revenue_stream.clone(), + mapping_status, + functional_amount_minor: record.functional_amount_minor, + functional_currency: record.functional_currency.clone(), + tax_jurisdiction: record.tax_jurisdiction.clone(), + tax_filing_period: record.tax_filing_period.clone(), + tax_rate_ref: record.tax_rate_ref.clone(), + legal_entity_id: record.legal_entity_id, + invoice_item_ref: record.invoice_item_ref.clone(), + sku_or_plan_ref: record.sku_or_plan_ref.clone(), + price_id: record.price_id.clone(), + pricing_snapshot_ref: record.pricing_snapshot_ref.clone(), + po_allocation_group: record.po_allocation_group.clone(), + credit_grant_event_type: record.credit_grant_event_type.clone(), + ar_status: record.ar_status.clone(), + }) +} + +/// Signed functional value of a net `FX_UNREALIZED` contra leg for the event +/// payload: a CREDIT contra is a net unrealized **gain** (`+`), a DEBIT a net +/// **loss** (`−`). The `functional_minor` magnitude is always non-negative (domain +/// invariant), so the sign is carried entirely by the posting side. +const fn fx_unrealized_signed(side: Side, functional_minor: i64) -> i64 { + match side { + Side::Credit => functional_minor, + Side::Debit => -functional_minor, + } +} + +/// The signed `FX_UNREALIZED` functional value carried by a reversal's lines — the +/// negation of the original revaluation's (the reversal flips each leg's side) — +/// for the `revaluation_reversed` event payload. `0` if no `FX_UNREALIZED` line is +/// present (a degenerate entry; never on the real reversal path, which always +/// negates the original's contra). +fn reversal_fx_unrealized_signed(lines: &[NewLine]) -> i64 { + lines + .iter() + .find(|l| l.account_class == AccountClass::FxUnrealized) + .and_then(|l| { + l.functional_amount_minor + .map(|f| fx_unrealized_signed(l.side, f)) + }) + .unwrap_or(0) +} + +/// In-txn [`PostSidecar`] that publishes `billing.ledger.fx.revaluation_completed` +/// (the transactional outbox) atomically with the `FX_UNREALIZED` revaluation +/// entry. Carries the run-time payload facts known before the post; `entry_id` +/// comes from the finalized [`PostedFacts`]. The publish-only mirror of +/// [`crate::infra::recognition::sidecar`]'s release event step (no counter rows). +struct RevaluationCompletedSidecar { + publisher: Arc, + ctx: SecurityContext, + tenant_id: Uuid, + period_id: String, + scope: String, + payer_id: Uuid, + functional_currency: String, + fx_unrealized_minor: i64, + grains_moved: i32, + posted_at_utc: DateTime, +} + +#[async_trait::async_trait] +impl PostSidecar for RevaluationCompletedSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + _scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + self.publisher + .publish_fx_revaluation_completed( + &self.ctx, + txn, + LedgerFxRevaluationCompleted { + tenant_id: self.tenant_id, + entry_id: posted.entry_id, + period_id: self.period_id.clone(), + scope: self.scope.clone(), + payer_id: self.payer_id, + functional_currency: self.functional_currency.clone(), + fx_unrealized_minor: self.fx_unrealized_minor, + grains_moved: self.grains_moved, + posted_at_utc: self.posted_at_utc, + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("publish fx_revaluation_completed: {e}"))) + } +} + +/// In-txn [`PostSidecar`] that publishes `billing.ledger.fx.revaluation_reversed` +/// (the transactional outbox) atomically with the `FX_REVAL_REVERSAL` entry. The +/// mirror of [`RevaluationCompletedSidecar`] for the reversal post. +struct RevaluationReversedSidecar { + publisher: Arc, + ctx: SecurityContext, + tenant_id: Uuid, + reverses_entry_id: Uuid, + reval_period_id: String, + reversal_period_id: String, + scope: String, + payer_id: Uuid, + functional_currency: String, + fx_unrealized_minor: i64, + posted_at_utc: DateTime, +} + +#[async_trait::async_trait] +impl PostSidecar for RevaluationReversedSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + _scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + self.publisher + .publish_fx_revaluation_reversed( + &self.ctx, + txn, + LedgerFxRevaluationReversed { + tenant_id: self.tenant_id, + entry_id: posted.entry_id, + reverses_entry_id: self.reverses_entry_id, + reval_period_id: self.reval_period_id.clone(), + reversal_period_id: self.reversal_period_id.clone(), + scope: self.scope.clone(), + payer_id: self.payer_id, + functional_currency: self.functional_currency.clone(), + fx_unrealized_minor: self.fx_unrealized_minor, + posted_at_utc: self.posted_at_utc, + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("publish fx_revaluation_reversed: {e}"))) + } +} + +#[cfg(test)] +#[path = "revaluation_run_tests.rs"] +mod revaluation_run_tests; diff --git a/gears/bss/ledger/ledger/src/infra/fx/revaluation_run_tests.rs b/gears/bss/ledger/ledger/src/infra/fx/revaluation_run_tests.rs new file mode 100644 index 000000000..7ebd61a02 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/fx/revaluation_run_tests.rs @@ -0,0 +1,61 @@ +//! Unit tests for the `UnrealizedRevaluationRun` pure helpers (idempotency +//! business-id shape, period-end effective date, scope→account-class mapping). +//! The end-to-end run (cache scan → remeasure → post → dual-column trigger) is a +//! testcontainer test (Group J). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +#[test] +fn business_id_is_period_colon_scope_colon_payer() { + let payer = uuid::uuid!("11111111-1111-1111-1111-111111111111"); + assert_eq!( + business_id("202606", RevaluationScope::Ar, payer), + "202606:AR:11111111-1111-1111-1111-111111111111" + ); + assert_eq!( + business_id("202612", RevaluationScope::Unallocated, payer), + "202612:UNALLOCATED:11111111-1111-1111-1111-111111111111" + ); + // The reversal lookup prefix is `period:scope:` (every payer). + assert_eq!( + business_id_prefix("202601", RevaluationScope::ReusableCredit), + "202601:REUSABLE_CREDIT:" + ); + assert!( + business_id("202601", RevaluationScope::ReusableCredit, payer).starts_with( + &business_id_prefix("202601", RevaluationScope::ReusableCredit) + ) + ); +} + +#[test] +fn period_end_naive_is_last_day_of_month() { + assert_eq!( + period_end_naive("202606"), + NaiveDate::from_ymd_opt(2026, 6, 30).unwrap() + ); + // December rolls over: end of 2026-12 is 2026-12-31. + assert_eq!( + period_end_naive("202612"), + NaiveDate::from_ymd_opt(2026, 12, 31).unwrap() + ); + // February (non-leap) ends on the 28th. + assert_eq!( + period_end_naive("202602"), + NaiveDate::from_ymd_opt(2026, 2, 28).unwrap() + ); +} + +#[test] +fn scope_account_class_maps_each_scope() { + assert_eq!(scope_account_class(RevaluationScope::Ar), AccountClass::Ar); + assert_eq!( + scope_account_class(RevaluationScope::Unallocated), + AccountClass::Unallocated + ); + assert_eq!( + scope_account_class(RevaluationScope::ReusableCredit), + AccountClass::ReusableCredit + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/inquiry.rs b/gears/bss/ledger/ledger/src/infra/inquiry.rs new file mode 100644 index 000000000..4ef06cfcc --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/inquiry.rs @@ -0,0 +1,694 @@ +//! `InquiryService` + `AuditPackExporter` — the filtered audit-inquiry / drill +//! reads and the audit-pack CSV export (Slice 6 Phase 4 Group 4A, plan +//! `docs/superpowers/plans/2026-06-23-vhp-1858-phase4-inquiry-metrics.md`). +//! +//! All reads go through the `SecureORM` layer (`.secure().scope_with(scope)`), +//! so the scope the caller passes IS the SQL-level BOLA filter — a row outside +//! it is simply never returned. The scope may be a TARGET-tenant scope handed +//! back by [`crate::infra::authz::cross_tenant::CrossTenantGateway`] (an audit +//! pack can be cross-tenant-gated), or the caller's own HOME scope on the +//! routine path. +//! +//! ## Filter axes (verified against the entities) +//! - `legal_entity_id` and `period_id` live on `journal_entry` (the header) and +//! are filtered there directly. +//! - `payer_tenant_id` and `account_class` live on `journal_line` only, so they +//! are resolved by reading the scoped lines and matching their parent entries +//! (the gear keeps no SQL JOIN helper, so this is a two-step scoped read, not +//! a single joined query). +//! - NOTE: the schema's `period_id` is a `String` (`YYYYMM`), NOT a `Uuid`. The +//! Group-4A plan sketched `period_id: Option`; the real column is text, +//! so [`InquiryFilter::period_id`] is an `Option` to match storage. +//! +//! ## Scale / NFR (§10, ratified targets) +//! The MVP export is SYNCHRONOUS: it walks the scoped rows in one request and +//! builds the CSV in memory. The architecture's async-export path (a job that +//! materializes a pack within ≤ 15 min for very large scopes) is a future +//! extension; for the request-time surface the ratified §10 NFR targets are +//! audit retrieval p95 ≤ 2 s, inquiry p95 ≤ 5 s, and audit-pack export async +//! ≤ 15 min — which the bounded scoped reads here meet. + +use chrono::{DateTime, Utc}; +use sea_orm::ActiveValue::Set; +use sea_orm::{ColumnTrait, Condition, EntityTrait, Order}; +use std::collections::HashMap; +use std::fmt::Write as _; +use std::sync::Arc; +use std::time::Instant; +use toolkit_db::secure::{AccessScope, DBRunner, DbTx, SecureEntityExt, SecureInsertExt}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::model::RepoError; +use crate::domain::ports::metrics::{LedgerMetricsPort, NoopLedgerMetrics}; +use crate::infra::storage::entity::{audit_pack_export, journal_entry, journal_line}; + +/// The inquiry filter axes. Every field is optional; an absent field is "any". +/// `legal_entity_id` + `period_id` filter the entry header; `payer_tenant_id` + +/// `account_class` filter the lines (and so narrow which entries match). +/// +/// Not a `#[domain_model]`: like the sibling read projections in +/// [`crate::infra::audit::retrieval`] (`AuditEntryRecord`, `FreezeRecord`) this +/// is a plain infra read shape, not a domain value. +#[derive(Clone, Debug, Default)] +pub struct InquiryFilter { + /// Payer tenant on a line (line-axis predicate). + pub payer_tenant_id: Option, + /// Fiscal period on the entry header (`YYYYMM`; a `String`, not a `Uuid`). + pub period_id: Option, + /// GL account class on a line (line-axis predicate). + pub account_class: Option, + /// Legal entity on the entry header. + pub legal_entity_id: Option, +} + +impl InquiryFilter { + /// `true` when at least one line-axis predicate (`payer_tenant_id` / + /// `account_class`) is set — the reader must then narrow by line, not header + /// alone. + fn has_line_predicate(&self) -> bool { + self.payer_tenant_id.is_some() || self.account_class.is_some() + } +} + +/// One entry-header row in a filtered inquiry result. A pure read projection of +/// `journal_entry`; carries no lines (the drill read fetches lines). +#[derive(Clone, Debug)] +pub struct EntryRow { + pub entry_id: Uuid, + pub tenant_id: Uuid, + pub legal_entity_id: Uuid, + pub period_id: String, + pub entry_currency: String, + pub source_doc_type: String, + pub source_business_id: String, + pub reverses_entry_id: Option, + pub posted_at_utc: DateTime, + pub posted_by_actor_id: Uuid, + pub origin: String, + pub correlation_id: Uuid, + pub created_seq: i64, +} + +impl From for EntryRow { + fn from(m: journal_entry::Model) -> Self { + Self { + entry_id: m.entry_id, + tenant_id: m.tenant_id, + legal_entity_id: m.legal_entity_id, + period_id: m.period_id, + entry_currency: m.entry_currency, + source_doc_type: m.source_doc_type, + source_business_id: m.source_business_id, + reverses_entry_id: m.reverses_entry_id, + posted_at_utc: m.posted_at_utc, + posted_by_actor_id: m.posted_by_actor_id, + origin: m.origin, + correlation_id: m.correlation_id, + created_seq: m.created_seq, + } + } +} + +/// One line row in a drill / export. A read projection of `journal_line` +/// carrying the linkage columns an audit pack needs. +#[derive(Clone, Debug)] +pub struct LineRow { + pub line_id: Uuid, + pub entry_id: Uuid, + pub payer_tenant_id: Uuid, + pub account_id: Uuid, + pub account_class: String, + pub gl_code: Option, + pub side: String, + pub amount_minor: i64, + pub currency: String, + pub invoice_id: Option, + pub revenue_stream: Option, + pub legal_entity_id: Option, +} + +impl From for LineRow { + fn from(m: journal_line::Model) -> Self { + Self { + line_id: m.line_id, + entry_id: m.entry_id, + payer_tenant_id: m.payer_tenant_id, + account_id: m.account_id, + account_class: m.account_class, + gl_code: m.gl_code, + side: m.side, + amount_minor: m.amount_minor, + currency: m.currency, + invoice_id: m.invoice_id, + revenue_stream: m.revenue_stream, + legal_entity_id: m.legal_entity_id, + } + } +} + +/// A drilled entry: the header row, its lines, and the entries linked to it (a +/// reversal / mapping-correction that `reverses_entry_id`-links to this entry, +/// or the entry this one reverses). Mirrors the document-history linkage shape +/// of [`crate::infra::audit::retrieval::AuditRetrievalReader::document_history`]. +#[derive(Clone, Debug)] +pub struct EntryDrill { + pub entry: EntryRow, + pub lines: Vec, + /// Entries linked to `entry` (reversals / mapping-corrections that target + /// it, plus the entry it reverses when set), ordered by `created_seq`. + pub linked: Vec, +} + +/// Scoped inquiry reader over one [`DBProvider`]. Stateless. +#[derive(Clone)] +pub struct InquiryService { + db: DBProvider, +} + +impl InquiryService { + /// Build the service over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// The underlying provider. + #[must_use] + pub fn db(&self) -> &DBProvider { + &self.db + } + + /// Filter posted entries under `scope` on the service's own connection (the + /// routine path). Header-axis predicates (`legal_entity_id` / `period_id`) + /// filter `journal_entry` directly; the line-axis predicates + /// (`payer_tenant_id` / `account_class`) read the scoped lines and keep only + /// the entries those lines belong to. Ordered by `created_seq`. SQL-level + /// BOLA via the scoped selects. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn filter_entries( + &self, + scope: &AccessScope, + filter: &InquiryFilter, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + filter_entries_on(&conn, scope, filter).await + } + + /// Drill into one entry by `(tenant_id, entry_id)` under `scope` on the + /// service's own connection (the routine path). Returns the entry header, + /// its lines, and the entries linked to it (a reversal / mapping-correction + /// that targets it, plus the entry it reverses) — mirroring the + /// document-history linkage shape. `None` when the entry is absent or + /// outside `scope` (no existence leak). + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn drill( + &self, + scope: &AccessScope, + tenant_id: Uuid, + entry_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + drill_on(&conn, scope, tenant_id, entry_id).await + } +} + +/// Filter posted entries under `scope` against any connection (the service's +/// own connection on the routine path, or a `DbTx` on the cross-tenant path so +/// the read shares the forensic record's transaction). See +/// [`InquiryService::filter_entries`] for the predicate semantics. +/// +/// # Errors +/// [`RepoError::Db`] on a storage / scope failure. +async fn filter_entries_on( + conn: &C, + scope: &AccessScope, + filter: &InquiryFilter, +) -> Result, RepoError> { + // Header-axis predicates on journal_entry. + let mut header_cond = Condition::all(); + if let Some(le) = filter.legal_entity_id { + header_cond = header_cond.add(journal_entry::Column::LegalEntityId.eq(le)); + } + if let Some(period) = filter.period_id.clone() { + header_cond = header_cond.add(journal_entry::Column::PeriodId.eq(period)); + } + + let headers = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter(header_cond) + .order_by(journal_entry::Column::CreatedSeq, Order::Asc) + .all(conn) + .await + .map_err(|e| RepoError::Db(format!("filter journal_entry: {e}")))?; + + if !filter.has_line_predicate() { + return Ok(headers.into_iter().map(EntryRow::from).collect()); + } + + // Line-axis predicates: read the scoped lines that match, collect their + // parent entry ids, then keep only the headers in that set. + let mut line_cond = Condition::all(); + if let Some(payer) = filter.payer_tenant_id { + line_cond = line_cond.add(journal_line::Column::PayerTenantId.eq(payer)); + } + if let Some(class) = filter.account_class.clone() { + line_cond = line_cond.add(journal_line::Column::AccountClass.eq(class)); + } + let lines = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter(line_cond) + .all(conn) + .await + .map_err(|e| RepoError::Db(format!("filter journal_line: {e}")))?; + let matching_entry_ids: std::collections::HashSet = + lines.into_iter().map(|l| l.entry_id).collect(); + + Ok(headers + .into_iter() + .filter(|h| matching_entry_ids.contains(&h.entry_id)) + .map(EntryRow::from) + .collect()) +} + +/// Drill into one entry by `(tenant_id, entry_id)` under `scope` against any +/// connection: the entry header, its lines, and the entries linked to it +/// (reversal / mapping-correction that link to it, plus the entry it reverses). +/// Reuses the document-history linkage shape of +/// [`crate::infra::audit::retrieval::AuditRetrievalReader::document_history`] +/// for the linked set. +/// +/// Returns `None` when the entry is absent or outside `scope` (no existence +/// leak). +/// +/// # Errors +/// [`RepoError::Db`] on a storage / scope failure. +async fn drill_on( + conn: &C, + scope: &AccessScope, + tenant_id: Uuid, + entry_id: Uuid, +) -> Result, RepoError> { + let header = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::EntryId.eq(entry_id)) + .add(journal_entry::Column::TenantId.eq(tenant_id)), + ) + .one(conn) + .await + .map_err(|e| RepoError::Db(format!("drill journal_entry: {e}")))?; + + let Some(header) = header else { + return Ok(None); + }; + let entry = EntryRow::from(header); + + let line_rows = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_line::Column::EntryId.eq(entry_id)) + .add(journal_line::Column::TenantId.eq(tenant_id)), + ) + .order_by(journal_line::Column::LineId, Order::Asc) + .all(conn) + .await + .map_err(|e| RepoError::Db(format!("drill journal_line: {e}")))?; + let lines = line_rows.into_iter().map(LineRow::from).collect(); + + // Linked: entries that reverse THIS entry (reversal / mapping-correction + // link back via `reverses_entry_id`), plus the entry THIS one reverses. + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + seen.insert(entry.entry_id); + let mut linked: Vec = Vec::new(); + + let reversing = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::ReversesEntryId.eq(entry_id)), + ) + .order_by(journal_entry::Column::CreatedSeq, Order::Asc) + .all(conn) + .await + .map_err(|e| RepoError::Db(format!("drill reversing entries: {e}")))?; + for m in reversing { + if seen.insert(m.entry_id) { + linked.push(EntryRow::from(m)); + } + } + + if let Some(reverses) = entry.reverses_entry_id { + let prior = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::EntryId.eq(reverses)), + ) + .one(conn) + .await + .map_err(|e| RepoError::Db(format!("drill reversed entry: {e}")))?; + if let Some(m) = prior + && seen.insert(m.entry_id) + { + linked.push(EntryRow::from(m)); + } + } + + linked.sort_by_key(|e| e.created_seq); + Ok(Some(EntryDrill { + entry, + lines, + linked, + })) +} + +/// CSV audit-pack exporter over one [`DBProvider`]. Stateless; reuses +/// [`InquiryService`] for the scoped reads. +/// +/// The CSV is built BY HAND (no `csv` crate dependency): a header row plus one +/// row per `(entry, line)`, each field RFC-4180-quoted when it contains a +/// comma, double-quote, or newline (wrap in `"`, double any internal `"`). +/// +/// MVP is synchronous (in-memory build over the scoped rows). A very large +/// scope would be served by the architecture's async export job (materialize +/// within ≤ 15 min); that path is a future extension. Request-time NFR targets: +/// audit retrieval p95 ≤ 2 s, inquiry p95 ≤ 5 s. +#[derive(Clone)] +pub struct AuditPackExporter { + inquiry: InquiryService, + metrics: Arc, +} + +impl AuditPackExporter { + /// Build the exporter over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { + inquiry: InquiryService::new(db), + metrics: Arc::new(NoopLedgerMetrics), + } + } + + /// Bind the §9 metrics sink (`ledger_audit_pack_export_duration_seconds` is + /// recorded per export). Defaults to no-op until wired. + #[must_use] + pub fn with_metrics(mut self, metrics: Arc) -> Self { + self.metrics = metrics; + self + } + + /// Export a filtered audit pack as CSV under `scope` on the exporter's own + /// connection (the routine, own-tenant path). Returns the full CSV document + /// (header + one row per `(entry, line)`) and the data-row count (excludes + /// the header). SQL-level BOLA via the scoped reads. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn export_csv( + &self, + scope: &AccessScope, + filter: &InquiryFilter, + ) -> Result<(String, usize), RepoError> { + let conn = self + .inquiry + .db() + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + // §9: record the export latency (`ledger_audit_pack_export_duration_seconds`). + let started = Instant::now(); + let out = export_csv_on(&conn, scope, filter).await; + self.metrics + .audit_pack_export_duration(started.elapsed().as_secs_f64()); + out + } + + /// Export a filtered audit pack as CSV under `scope` inside `txn` (the + /// cross-tenant path): the export reads share the transaction in which + /// [`crate::infra::authz::cross_tenant::CrossTenantGateway::resolve_read_scope`] + /// wrote the `cross-tenant-access` forensic record, so the record and the + /// foreign read commit (or roll back) together. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn export_csv_in_txn( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + filter: &InquiryFilter, + ) -> Result<(String, usize), RepoError> { + // §9: record the export latency (`ledger_audit_pack_export_duration_seconds`). + let started = Instant::now(); + let out = export_csv_on(txn, scope, filter).await; + self.metrics + .audit_pack_export_duration(started.elapsed().as_secs_f64()); + out + } + + /// Persist a materialized audit-pack export row inside `txn` (the same + /// transaction that resolved the read scope, wrote the cross-tenant-access + /// forensic record, and built the CSV — so the export row and the foreign + /// read commit or roll back together). `scope` MUST be the home-tenant scope + /// the row is owned by; `scope_with_model` validates the model's `tenant_id` + /// against it. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn insert_export_in_txn( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + model: &audit_pack_export::Model, + ) -> Result<(), RepoError> { + let am = audit_pack_export::ActiveModel { + export_id: Set(model.export_id), + tenant_id: Set(model.tenant_id), + target_tenant_id: Set(model.target_tenant_id), + status: Set(model.status.clone()), + reason_code: Set(model.reason_code.clone()), + actor_ref: Set(model.actor_ref.clone()), + csv: Set(model.csv.clone()), + row_count: Set(model.row_count), + error_detail: Set(model.error_detail.clone()), + created_at_utc: Set(model.created_at_utc), + completed_at_utc: Set(model.completed_at_utc), + }; + audit_pack_export::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("insert audit_pack_export scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert audit_pack_export: {e}")))?; + Ok(()) + } + + /// Read one audit-pack export row for `tenant` (the requester's home tenant) + /// under `scope`, or `None` when absent / scoped-out (no existence leak). + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn find_export( + &self, + scope: &AccessScope, + tenant: Uuid, + export_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .inquiry + .db() + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + audit_pack_export::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(audit_pack_export::Column::TenantId.eq(tenant)) + .add(audit_pack_export::Column::ExportId.eq(export_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("find audit_pack_export: {e}"))) + } +} + +/// Build the CSV audit pack under `scope` against any connection (own +/// connection on the routine path, a `DbTx` on the cross-tenant path). The +/// line-axis filter (if any) narrows WHICH entries match; the pack then carries +/// ALL the lines of each matching entry (the full entry is the audit unit). +/// +/// # Errors +/// [`RepoError::Db`] on a storage / scope failure. +async fn export_csv_on( + conn: &C, + scope: &AccessScope, + filter: &InquiryFilter, +) -> Result<(String, usize), RepoError> { + let entries = filter_entries_on(conn, scope, filter).await?; + + let mut lines_by_entry: HashMap> = HashMap::new(); + let entry_ids: Vec = entries.iter().map(|e| e.entry_id).collect(); + if !entry_ids.is_empty() { + let line_rows = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(journal_line::Column::EntryId.is_in(entry_ids))) + .order_by(journal_line::Column::EntryId, Order::Asc) + .order_by(journal_line::Column::LineId, Order::Asc) + .all(conn) + .await + .map_err(|e| RepoError::Db(format!("export journal_line: {e}")))?; + for m in line_rows { + lines_by_entry + .entry(m.entry_id) + .or_default() + .push(LineRow::from(m)); + } + } + + let mut csv = String::new(); + csv.push_str(CSV_HEADER); + csv.push('\n'); + let mut row_count = 0usize; + for entry in &entries { + match lines_by_entry.get(&entry.entry_id) { + Some(lines) if !lines.is_empty() => { + for line in lines { + push_row(&mut csv, entry, Some(line)); + row_count += 1; + } + } + // An entry with no lines still emits one row (the header dims), so + // the pack never silently drops an entry. + _ => { + push_row(&mut csv, entry, None); + row_count += 1; + } + } + } + Ok((csv, row_count)) +} + +/// The audit-pack CSV header (column order = the row order in [`push_row`]). +const CSV_HEADER: &str = "entry_id,tenant_id,period_id,legal_entity_id,posted_at_utc,\ +source_doc_type,source_business_id,origin,posted_by_actor_id,correlation_id,reverses_entry_id,\ +created_seq,line_id,payer_tenant_id,account_id,account_class,gl_code,side,amount_minor,currency,\ +invoice_id,revenue_stream"; + +/// Append one CSV data row (an `(entry, line)` pair; `line = None` emits the +/// entry dims with the line columns blank) to `out`, RFC-4180-quoting each +/// field that needs it and terminating with `\n`. +fn push_row(out: &mut String, entry: &EntryRow, line: Option<&LineRow>) { + let mut fields: Vec = vec![ + entry.entry_id.to_string(), + entry.tenant_id.to_string(), + entry.period_id.clone(), + entry.legal_entity_id.to_string(), + entry.posted_at_utc.to_rfc3339(), + entry.source_doc_type.clone(), + entry.source_business_id.clone(), + entry.origin.clone(), + entry.posted_by_actor_id.to_string(), + entry.correlation_id.to_string(), + entry + .reverses_entry_id + .map(|u| u.to_string()) + .unwrap_or_default(), + entry.created_seq.to_string(), + ]; + if let Some(line) = line { + fields.extend([ + line.line_id.to_string(), + line.payer_tenant_id.to_string(), + line.account_id.to_string(), + line.account_class.clone(), + line.gl_code.clone().unwrap_or_default(), + line.side.clone(), + line.amount_minor.to_string(), + line.currency.clone(), + line.invoice_id.clone().unwrap_or_default(), + line.revenue_stream.clone().unwrap_or_default(), + ]); + } else { + // Ten blank line columns (line_id .. revenue_stream). + for _ in 0..10 { + fields.push(String::new()); + } + } + + let mut first = true; + for f in &fields { + if !first { + out.push(','); + } + first = false; + let _ = write!(out, "{}", csv_escape(f)); + } + out.push('\n'); +} + +/// RFC-4180 field quoting **plus a CSV formula-injection guard**. +/// +/// Two independent transforms, applied in order: +/// 1. **Formula guard:** a field whose first character is one a spreadsheet +/// treats as a formula lead-in (`=`, `+`, `-`, `@`, or a leading TAB/CR) is +/// prefixed with a single quote (`'`) so Excel / Google Sheets render the +/// cell as literal text instead of evaluating it (e.g. `=cmd|…`, +/// `=HYPERLINK(…)`, `=WEBSERVICE(…)` data exfil). The audit-pack carries +/// caller-supplied free text (`source_business_id`, `gl_code`, `invoice_id`, +/// `revenue_stream`, …); without this guard such a value is executed when an +/// investigator opens the pack in a spreadsheet. +/// 2. **RFC-4180 quoting:** the (possibly prefixed) value is wrapped in +/// double-quotes with any internal `"` doubled when it contains a comma, +/// double-quote, or newline. +/// +/// Returned as a `Cow` to avoid allocating for the common (untouched) field. +fn csv_escape(field: &str) -> std::borrow::Cow<'_, str> { + let needs_formula_guard = field + .chars() + .next() + .is_some_and(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r')); + let needs_quoting = field.contains([',', '"', '\n', '\r']); + + if !needs_formula_guard && !needs_quoting { + return std::borrow::Cow::Borrowed(field); + } + + // Prefix the formula lead-in INSIDE any RFC-4180 quoting, so the cell's + // literal text becomes `'=…`. + let guarded = if needs_formula_guard { + std::borrow::Cow::Owned(format!("'{field}")) + } else { + std::borrow::Cow::Borrowed(field) + }; + if needs_quoting { + std::borrow::Cow::Owned(format!("\"{}\"", guarded.replace('"', "\"\""))) + } else { + guarded + } +} + +#[cfg(test)] +#[path = "inquiry_tests.rs"] +mod inquiry_tests; diff --git a/gears/bss/ledger/ledger/src/infra/inquiry_tests.rs b/gears/bss/ledger/ledger/src/infra/inquiry_tests.rs new file mode 100644 index 000000000..6a0d3f370 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/inquiry_tests.rs @@ -0,0 +1,64 @@ +//! Pure-fn unit tests for the audit-pack CSV encoder (DE1101: tests live in the +//! sibling `_tests.rs` hooked via `#[path]`). The scoped DB reads are covered by +//! the Postgres integration test `tests/postgres_inquiry.rs`. + +use super::{CSV_HEADER, csv_escape}; + +/// `csv_escape` returns a `Cow`; compare its `&str` view for clarity. +fn esc(field: &str) -> String { + csv_escape(field).into_owned() +} + +#[test] +fn plain_field_is_returned_verbatim() { + assert_eq!(esc("REVENUE"), "REVENUE"); + assert_eq!(esc(""), ""); + assert_eq!(esc("a-b_c.123"), "a-b_c.123"); +} + +#[test] +fn field_with_comma_is_quoted() { + assert_eq!(esc("Acme, Inc."), "\"Acme, Inc.\""); +} + +#[test] +fn field_with_quote_doubles_the_quote_and_wraps() { + // `say "hi"` → `"say ""hi"""` + assert_eq!(esc("say \"hi\""), "\"say \"\"hi\"\"\""); +} + +#[test] +fn field_with_newline_is_quoted() { + assert_eq!(esc("line1\nline2"), "\"line1\nline2\""); + assert_eq!(esc("line1\r\nline2"), "\"line1\r\nline2\""); +} + +#[test] +fn formula_lead_in_is_neutralized_with_a_single_quote() { + // A field starting with a spreadsheet formula trigger is prefixed with `'` + // so Excel / Sheets render it as literal text, not a formula. + assert_eq!(esc("=cmd|'/c calc'!A1"), "'=cmd|'/c calc'!A1"); + assert_eq!(esc("+1+1"), "'+1+1"); + assert_eq!(esc("-2+3"), "'-2+3"); + assert_eq!(esc("@SUM(A1)"), "'@SUM(A1)"); + assert_eq!(esc("\tlead-tab"), "'\tlead-tab"); +} + +#[test] +fn formula_lead_in_that_also_needs_quoting_is_prefixed_inside_the_quotes() { + // `=A1,B1` triggers BOTH the formula guard and comma-quoting → `"'=A1,B1"`. + assert_eq!(esc("=A1,B1"), "\"'=A1,B1\""); +} + +#[test] +fn formula_trigger_only_applies_to_the_first_character() { + // A `-`/`@`/`+` that is not the first character is harmless and untouched. + assert_eq!(esc("a-b_c.123"), "a-b_c.123"); + assert_eq!(esc("user@host"), "user@host"); +} + +#[test] +fn header_column_count_matches_row_field_count() { + // 22 columns: 12 entry fields + 10 line fields (see `push_row`). + assert_eq!(CSV_HEADER.split(',').count(), 22); +} diff --git a/gears/bss/ledger/ledger/src/infra/invoice_post.rs b/gears/bss/ledger/ledger/src/infra/invoice_post.rs new file mode 100644 index 000000000..2a6f304c2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/invoice_post.rs @@ -0,0 +1,751 @@ +//! `InvoicePostService` — the orchestrator that drives the pure invoice-post +//! domain (`crate::domain::invoice`) through the foundation engine. +//! +//! It ties the pieces together for one business post: +//! 1. **payer gate** — reject a post for a closed payer (`PAYER_CLOSED`); a +//! reversal of an already-posted invoice bypasses this (a closed payer must +//! still be able to have a wrong charge backed out). +//! 2. **map** each item to its GL target (`domain::invoice::mapping::resolve`). +//! 3. **build** the balanced direct-split entry +//! (`domain::invoice::builder::build_invoice_entry`). +//! 4. **bind** the real chart `account_id` for each line from the provisioned +//! chart of accounts (the pure builder emits a nil placeholder). +//! 5. **resolve scale** per line and **post** via [`PostingService`]. +//! 6. **emit metrics** — `invoice_post` (outcome) + duration on every attempt; +//! the suspense gauges when the post parks PENDING lines. +//! +//! Lives in `infra` (not `domain`) because it needs repo + posting access; the +//! domain modules it calls stay pure (dylint DE0301). It wraps the `pub` +//! [`PostingService`] + [`ReferenceRepo`] directly (rather than the SDK +//! `LedgerClientV1`, whose in-process impl `LedgerLocalClient::new` is +//! `pub(crate)`), so it is constructible from out-of-crate integration tests. + +use std::sync::Arc; +use std::time::Instant; + +use bss_ledger_sdk::{MappingStatus, PostEntry, PostLine}; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::config::{FxConfig, RecognitionConfig}; +use crate::domain::error::DomainError; +use crate::domain::invoice::builder::{InvoiceItem, PostedInvoice, build_invoice_entry}; +use crate::domain::invoice::mapping::{MappedLine, resolve}; +use crate::domain::invoice::policy::MissingMappingMode; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::ports::metrics::{LedgerMetricsPort, PostFlow, PostResult}; +use crate::domain::recognition::builder::{ScheduleBuilder, ScheduleOutcome, is_immaterial}; +use crate::domain::recognition::input::RecognitionInput; +use crate::domain::recognition::ports::{ + DefaultDeferralPolicyResolver, DefaultSspResolver, DefaultVcResolver, RecognitionContext, +}; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::payloads::LedgerEntryReversed; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::fx::rate_locker::RateLocker; +use crate::infra::fx::rate_source::RateSource; +use crate::infra::posting::chart::{ChartIndex, load_chart}; +use crate::infra::posting::idempotency::IdempotencyGate; +use crate::infra::posting::service::{PostSidecar, PostedFacts, PostingService}; +use crate::infra::recognition::sidecar::{PlannedScheduleMaterialization, ScheduleBuilderSidecar}; +use crate::infra::storage::repo::{FxRepo, PostingPolicyRepo, ReferenceRepo}; + +/// Origin literal stamped on posts made through this service. +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// In-transaction sidecar that emits `billing.ledger.entry.reversed` (architecture +/// §6, VHP-1837) on the explicit reversal path. It rides the post's own +/// transaction (the transactional outbox), so the event row commits atomically +/// with the reversing entry or rolls back with it. It carries the operator +/// `reason` (which is NOT persisted on the entry header) and the original entry +/// id; the reversing entry's id comes from [`PostedFacts`]. A `MAPPING_CORRECTION` +/// does not attach this sidecar — it is a correction, not a §6 reversal. +struct ReversalEventSidecar { + publisher: Arc, + ctx: SecurityContext, + tenant_id: Uuid, + reverses_entry_id: Uuid, + reason: String, +} + +#[async_trait::async_trait] +impl PostSidecar for ReversalEventSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + _scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + self.publisher + .publish_entry_reversed( + &self.ctx, + txn, + LedgerEntryReversed { + entry_id: posted.entry_id, + reverses_entry_id: self.reverses_entry_id, + tenant_id: self.tenant_id, + reason: self.reason.clone(), + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("publish entry_reversed: {e}"))) + } +} + +/// Write port the journal-entry REST handlers post through. Abstracts the two +/// foundation-engine writes the surface needs — a fresh invoice post (payer +/// gate + map + build + bind) and a pre-built reversal/correction post — so the +/// router tests can stub the post path without a database. The production +/// implementation is [`InvoicePostService`]. +#[async_trait::async_trait] +pub trait InvoicePoster: Send + Sync { + /// Post a fully-recognized invoice (Variant A). `payer_open = false` rejects + /// with [`DomainError::PayerClosed`] before any ledger effect. + /// + /// # Errors + /// [`DomainError`] on a payer gate / foundation rejection or an infra fault. + async fn post_invoice( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + inv: &PostedInvoice, + payer_open: bool, + ) -> Result; + + /// Post a pre-bound reversal entry (the caller built it from a read-back + /// original whose lines already carry real `account_id`s; the payer gate is + /// intentionally not applied — a reversal must post even for a closed payer). + /// `reason` is `Some(audit reason)` for an explicit reversal — it is announced + /// on the `billing.ledger.entry.reversed` event (VHP-1837), not persisted on + /// the row — or `None` for a mapping-correction's internal reversal leg, which + /// announces nothing (a correction is not a §6 reversal). + /// + /// # Errors + /// [`DomainError`] on a foundation rejection or an infra fault. + async fn post_reversal( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + reversal: PostEntry, + reason: Option, + ) -> Result; + + /// Post a corrected re-post (`MAPPING_CORRECTION`) whose lines were freshly + /// built and carry placeholder nil `account_id`s — so unlike [`post_reversal`] + /// this binds each line's chart `account_id` from the provisioned chart + /// before posting. The `source_doc_type` + `reverses_*` header is preserved. + /// + /// # Errors + /// [`DomainError`] on an unmapped account / foundation rejection / infra fault. + async fn post_correction( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + correction: PostEntry, + ) -> Result; +} + +/// Orchestrates the invoice-post domain over the foundation engine. +pub struct InvoicePostService { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + metrics: Arc, + /// ASC 606 recognition tunables (Slice 4): the per-schedule segment ceiling + /// the pure `ScheduleBuilder` enforces during derivation. + recognition_config: RecognitionConfig, + /// The S1 FX lock (Slice 5): resolves + snapshots the locked rate and stamps + /// the functional translation on a cross-currency invoice entry. Inert for a + /// single-currency tenant (no functional currency configured). + rate_locker: RateLocker, + /// Event publisher, retained so the reversal path can attach a + /// [`ReversalEventSidecar`] that emits `billing.ledger.entry.reversed` in the + /// post txn (VHP-1837). The same handle is threaded into the posting engine. + publisher: Arc, + /// Tenant posting policy (VHP-1853): the missing-mapping mode (the hard-block + /// gate below) + the AR-aging buckets. Read effective in the orchestrator + /// before a post; absent a row the gear default (`SUSPENSE`) applies. + posting_policy_repo: PostingPolicyRepo, +} + +impl InvoicePostService { + /// Build the service over one database provider, the event publisher + /// (threaded into the posting engine), the metrics sink, and the recognition + /// config (the segment ceiling the derivation enforces, Slice 4). + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + recognition_config: RecognitionConfig, + fx_config: FxConfig, + ) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + // S1 FX lock: resolve over the local rate store (provider order + + // staleness from `fx_config`) and freeze a snapshot per cross-currency post. + let rate_locker = RateLocker::new( + RateSource::new(FxRepo::new(db.clone()), fx_config).with_metrics(Arc::clone(&metrics)), + FxRepo::new(db.clone()), + ); + let posting_policy_repo = PostingPolicyRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db)); + Self { + posting, + reference, + resolver, + metrics, + recognition_config, + rate_locker, + publisher, + posting_policy_repo, + } + } + + /// Post a fully-recognized invoice (Variant A). `payer_open` is the payer's + /// lifecycle decision (resolved by the caller): `false` ⇒ the post is + /// rejected with [`DomainError::PayerClosed`] before any ledger effect. + /// + /// On success emits `invoice_post(Posted | Replayed)` + the duration, and — + /// when the post parked any PENDING line — the suspense gauges. Every + /// rejection emits `invoice_post(Rejected)` + the duration. + /// + /// # Errors + /// [`DomainError::PayerClosed`] when `!payer_open`; any foundation rejection + /// (unbalanced/empty/period-closed/account-closed/negative-balance/…) or + /// [`DomainError::Internal`] on an infrastructure fault. + pub async fn post_invoice( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + inv: &PostedInvoice, + payer_open: bool, + ) -> Result { + let started = Instant::now(); + let result = self.post_invoice_inner(ctx, scope, inv, payer_open).await; + self.record(&result, started, PostFlow::InvoicePost); + // On a fresh post that parked PENDING lines, surface the suspense backlog + // as a gauge (age 0 at post time; the tie-out job ages it durably). + if let Ok(ref posted) = result + && !posted.replayed + { + let pending = pending_line_count(inv); + if pending > 0 { + self.metrics + .suspense_pending(inv.seller_tenant_id, pending, 0.0); + } + } + result + } + + /// Build + post the entry (no metrics — the public wrapper records them). + /// + /// Slice 4: each item carrying a recognition spec is run through the pure + /// [`ScheduleBuilder`] derivation FIRST (in [`Self::derive_recognition`]), + /// which fills the item's `deferred_minor` and yields the schedules to + /// materialize. The builder then splits each stream's credit into + /// `CR REVENUE (recognized now)` + `CR CONTRACT_LIABILITY (deferred)`; the + /// schedules ride a [`ScheduleBuilderSidecar`] threaded into the post so they + /// materialize in the same serializable transaction (or roll back with the + /// entry). When NO item defers, the derivation yields no schedules, the + /// builder emits no Contract-liability line, and the post is threaded a + /// `None` sidecar — byte-identical to the pre-Slice-4 path. + async fn post_invoice_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + inv: &PostedInvoice, + payer_open: bool, + ) -> Result { + if !payer_open { + return Err(DomainError::PayerClosed(format!( + "payer {} is closed", + inv.payer_tenant_id + ))); + } + + // Recognition derivation (Slice 4): fill each item's deferred amount and + // collect the schedules to materialize. A derivation Err (SSP / policy / + // segment-ceiling / PO-gate breach) propagates → the post fails, so no + // orphan deferral is ever posted. Returns the items with `deferred_minor` + // set + the per-item-stream schedule plans (empty ⇒ no recognition). + let (items, schedules) = self.derive_recognition(inv)?; + + // Build over the recognition-augmented items (the deferred split is + // driven by each item's `deferred_minor`). + let inv_with_deferral = PostedInvoice { + items, + ..inv.clone() + }; + let mapped: Vec = inv_with_deferral.items.iter().map(resolve).collect(); + // VHP-1853 missing-mapping policy: when the tenant's effective mode is + // HARD_BLOCK, an item that resolved to SUSPENSE/PENDING fails the whole + // post (`ACCOUNT_MAPPING_MISSING`) rather than parking. The default + // (SUSPENSE) keeps the park-and-reclassify path byte-unchanged. + if self + .posting_policy_repo + .read_effective_policy(scope, inv.seller_tenant_id, chrono::Utc::now()) + .await + .map_err(|e| DomainError::Internal(format!("read posting policy: {e}")))? + .missing_mapping_mode + == MissingMappingMode::HardBlock + && mapped + .iter() + .any(|m| m.mapping_status == MappingStatus::Pending) + { + return Err(DomainError::AccountMappingMissing(format!( + "invoice {} has an unmapped item and the tenant posting policy is HARD_BLOCK", + inv.invoice_id + ))); + } + let entry = build_invoice_entry(&inv_with_deferral, &mapped); + + // Thread the schedule-materialization sidecar ONLY when ≥1 schedule must + // materialize; else `None` (byte-identical to the pre-Slice-4 post). + let sidecar: Option> = if schedules.is_empty() { + None + } else { + Some(Arc::new(ScheduleBuilderSidecar { + tenant_id: inv.seller_tenant_id, + payer_tenant_id: inv.payer_tenant_id, + source_invoice_id: inv.invoice_id.clone(), + schedules, + idempotency: IdempotencyGate::new(), + // The invoice-post mints the FIRST schedule for its keys. + build_discriminator: None, + })) + }; + // S1: the seller tenant's functional currency (S5-F3). `None` (unconfigured) + // or equal to the entry currency → the RateLocker short-circuits inside + // `post_prebound` (single-currency, functional NULL — byte-green). + let functional_ccy = self + .reference + .functional_currency(scope, inv.seller_tenant_id) + .await + .map_err(|e| DomainError::Internal(format!("functional currency lookup: {e}")))?; + self.bind_and_post(ctx, scope, entry, sidecar, functional_ccy.as_deref()) + .await + } + + /// Run the pure recognition derivation for every item that carries a spec, + /// returning (a) the items with their derived `deferred_minor` filled and + /// (b) the schedules to materialize (one per deferred item-stream, paired + /// with the item's `invoice_item_ref`). + /// + /// Per item with a [`RecognitionInput`]: build a [`RecognitionContext`] (the + /// item's ex-tax amount, the invoice period, the invoice gross total, the + /// currency, the revenue stream) and call [`ScheduleBuilder::derive`]. A + /// [`ScheduleOutcome::NoDeferral`] leaves `deferred_minor = 0` (no schedule); + /// a [`ScheduleOutcome::Schedule`] sets `deferred_minor` and is collected. + /// + /// Two Slice-4 gates fire here (the derivation owns the trigger conditions, + /// NOT Slice 1's endpoint): + /// - **invoice-item-link** (§4.7): a deferred item MUST carry an + /// `invoice_item_ref` (the schedule's NOT-NULL `source_invoice_item_ref`, + /// the Contract-liability line it draws down). A deferred item without one + /// is blocked with [`DomainError::RecognitionWithoutInvoiceLink`] before the + /// post (no orphan deferral). [The SSP gate is enforced inside `derive` by + /// the `SspResolver`; the policy/segment gates likewise.] + /// - **PO-allocation-group** (C4, §4.4): a deferred / multi-PO / VC item whose + /// PO allocation group cannot be resolved AND cannot be defaulted (Catalog + /// default) AND is not R4-exempt ⇒ [`DomainError::MissingPoAllocationGroup`]. + /// An ordinary point-in-time line auto-defaults / never blocks. + /// + /// # Errors + /// Any block the derivation raises ([`DomainError::SspSnapshotRequired`], + /// [`DomainError::RecognitionPolicyConflict`], [`DomainError::ScheduleTooLong`]), + /// the §4.7 invoice-item-link [`DomainError::RecognitionWithoutInvoiceLink`] + /// gate, or the C4 [`DomainError::MissingPoAllocationGroup`] gate. + fn derive_recognition( + &self, + inv: &PostedInvoice, + ) -> Result<(Vec, Vec), DomainError> { + let policy = DefaultDeferralPolicyResolver; + let ssp = DefaultSspResolver; + let vc = DefaultVcResolver; + let builder = ScheduleBuilder::new(&policy, &ssp, &vc, &self.recognition_config); + + // The R4 exemption / invoice-share denominator is the invoice gross. + let invoice_total_minor = inv.gross_minor(); + + let mut items = Vec::with_capacity(inv.items.len()); + let mut schedules = Vec::new(); + for item in &inv.items { + let mut out_item = item.clone(); + if let Some(input) = &item.recognition { + // C4 PO-gate: a deferring / multi-PO / VC line needs a resolvable + // (or defaultable) PO allocation group unless R4-exempt. + check_po_allocation_group( + input, + item.amount_minor_ex_tax, + invoice_total_minor, + &self.recognition_config, + )?; + + let ctx = RecognitionContext { + input, + invoice_period_id: &inv.period_id, + item_amount_minor_ex_tax: item.amount_minor_ex_tax, + invoice_total_minor, + currency: &item.currency, + revenue_stream: &item.revenue_stream, + }; + match builder.derive(&ctx)? { + ScheduleOutcome::NoDeferral => {} + ScheduleOutcome::Schedule(schedule) => { + // §4.7 invoice-item-link: a deferred item MUST resolve to + // its Contract-liability line via a non-empty + // `invoice_item_ref`. Block before the post (no orphan) with + // the SPECIFIC `RecognitionWithoutInvoiceLink` (wire + // `RECOGNITION_WITHOUT_INVOICE_LINK`, 400) — the §4.7 + // invariant's own code, not the generic `AmountOutOfRange`. + let item_ref = item + .invoice_item_ref + .as_deref() + .filter(|r| !r.is_empty()) + .ok_or_else(|| { + DomainError::RecognitionWithoutInvoiceLink(format!( + "deferred recognition line (stream `{}`) must carry an \ + invoice_item_ref to anchor its contract-liability schedule", + item.revenue_stream + )) + })?; + out_item.deferred_minor = schedule.deferred_minor; + schedules.push(PlannedScheduleMaterialization { + schedule, + source_invoice_item_ref: item_ref.to_owned(), + }); + } + } + } + items.push(out_item); + } + Ok((items, schedules)) + } + + /// Post a reversal built from an original entry's [`PostEntry`] projection + /// (the caller builds it via `domain::invoice::reversal::build_reversal`). + /// The reversal's lines already carry real `account_id`s (copied from the + /// original read-back), so only scale resolution + the engine post remain. + /// The payer gate is intentionally NOT applied — a reversal must post even + /// for a closed payer. + /// + /// # Errors + /// Any foundation rejection or [`DomainError::Internal`] on an infrastructure + /// fault. + pub async fn post_reversal( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + reversal: PostEntry, + reason: Option, + ) -> Result { + let started = Instant::now(); + // Announce `billing.ledger.entry.reversed` in the post txn (VHP-1837) only + // for an explicit reversal (`reason` present): the event commits atomically + // with the reversing entry. A mapping-correction's reversal leg passes + // `None` (it is a correction, not a §6 reversal) and announces nothing. A + // reversal carries no recognition sidecar (recognition is materialized on + // the forward post, never re-derived on a reversal). + let sidecar: Option> = match (reversal.reverses_entry_id, reason) { + (Some(orig), Some(reason)) => Some(Arc::new(ReversalEventSidecar { + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + tenant_id: reversal.tenant_id, + reverses_entry_id: orig, + reason, + }) as Arc), + _ => None, + }; + let result = self + .post_prebound(ctx, scope, reversal, sidecar, None) + .await; + self.record(&result, started, PostFlow::Reversal); + result + } + + /// Post a corrected re-post (`MAPPING_CORRECTION`) whose freshly-built lines + /// carry nil placeholder `account_id`s: binds each from the provisioned chart + /// (like the invoice-post path) then posts, preserving the correction's + /// `source_doc_type` + `reverses_*` header. Records `invoice_post` + duration. + /// + /// # Errors + /// [`DomainError`] on an unmapped account / foundation rejection / infra fault. + pub async fn post_correction( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + correction: PostEntry, + ) -> Result { + let started = Instant::now(); + // A mapping-correction re-post carries no recognition sidecar (it + // re-books an already-recognized split; recognition schedules are + // materialized on the original post). + let result = self.bind_and_post(ctx, scope, correction, None, None).await; + self.record(&result, started, PostFlow::MappingCorrection); + result + } + + /// Resolve each line's chart `account_id` from the provisioned chart, then + /// resolve scale + post. `sidecar` (Slice 4) materializes recognition + /// schedules in the same post transaction when present; `None` ⇒ no + /// in-transaction side effect (the byte-identical non-deferred path + the + /// reversal/correction paths). + async fn bind_and_post( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + mut entry: PostEntry, + sidecar: Option>, + functional_ccy: Option<&str>, + ) -> Result { + let chart = load_chart(&self.reference, scope, entry.tenant_id).await?; + for line in &mut entry.lines { + line.account_id = resolve_line(&chart, line).ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + line.account_class.as_str(), + line.revenue_stream, + line.currency + )) + })?; + } + self.post_prebound(ctx, scope, entry, sidecar, functional_ccy) + .await + } + + /// Map an already-account-bound [`PostEntry`] to the engine's + /// `NewEntry`/`NewLine`, resolving each line's scale, and post (threading + /// `sidecar` into the foundation engine). + async fn post_prebound( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: PostEntry, + sidecar: Option>, + functional_ccy: Option<&str>, + ) -> Result { + let mut new_entry = NewEntry { + entry_id: entry.entry_id, + tenant_id: entry.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: entry.tenant_id, + period_id: entry.period_id.clone(), + entry_currency: entry.entry_currency.clone(), + source_doc_type: entry.source_doc_type, + source_business_id: entry.source_business_id.clone(), + reverses_entry_id: entry.reverses_entry_id, + reverses_period_id: entry.reverses_period_id.clone(), + posted_at_utc: chrono::Utc::now(), + effective_at: entry.effective_at, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: entry.posted_by_actor_id, + correlation_id: entry.correlation_id, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let mut new_lines: Vec = Vec::with_capacity(entry.lines.len()); + for line in entry.lines { + let scale = self + .resolver + .resolve(scope, entry.tenant_id, &line.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + new_lines.push(new_line(line, scale)); + } + // S1 FX lock (forward post only — reversal/correction pass `None` and never + // re-lock; a reversal carries the original rate, spec §4.2 F-8c). When the + // tenant has a functional currency that DIFFERS from the entry's + // transaction currency, resolve + snapshot the locked rate and stamp the + // functional translation on every line; the snapshot id rides the entry + // (one rate per entry, §4.3) and the journal repo stamps each line from it. + // Same/None → single-currency: no snapshot, functional stays NULL. + if let Some(fc) = functional_ccy + && fc != new_entry.entry_currency + { + new_entry.rate_snapshot_ref = self + .rate_locker + .lock_and_stamp( + scope, + new_entry.tenant_id, + &mut new_lines, + &new_entry.entry_currency, + fc, + chrono::Utc::now(), + ) + .await?; + } + self.posting + .post(ctx, scope, new_entry, new_lines, sidecar) + .await + } + + /// Emit `invoice_post(outcome, flow)` + the flow-labelled duration for one + /// attempt. `flow` keeps reversals/corrections off the invoice-post rate. + fn record( + &self, + result: &Result, + started: Instant, + flow: PostFlow, + ) { + let outcome = match result { + Ok(r) if r.replayed => PostResult::Replayed, + Ok(_) => PostResult::Posted, + Err(_) => PostResult::Rejected, + }; + self.metrics.invoice_post(outcome, flow); + self.metrics + .invoice_post_duration(started.elapsed().as_secs_f64(), flow); + } +} + +/// The production [`InvoicePoster`]: delegates to the inherent methods (which +/// the in-crate integration tests also call on the concrete type). Lets the REST +/// surface hold `Arc` and the router tests stub the writes. +#[async_trait::async_trait] +impl InvoicePoster for InvoicePostService { + async fn post_invoice( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + inv: &PostedInvoice, + payer_open: bool, + ) -> Result { + InvoicePostService::post_invoice(self, ctx, scope, inv, payer_open).await + } + + async fn post_reversal( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + reversal: PostEntry, + reason: Option, + ) -> Result { + InvoicePostService::post_reversal(self, ctx, scope, reversal, reason).await + } + + async fn post_correction( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + correction: PostEntry, + ) -> Result { + InvoicePostService::post_correction(self, ctx, scope, correction).await + } +} + +/// The C4 PO-allocation-group gate (design §4.4, Rev2 N-revrec-3) — owned by +/// Slice 4's recognition orchestration, NOT a mutation of Slice 1's invoice-post +/// endpoint. It fires ONLY for a genuinely ambiguous obligation: a **deferring** +/// (straight-line) / **multi-PO** / **VC** line whose PO allocation group cannot +/// be resolved (absent / blank on the input) AND cannot be defaulted by the +/// Catalog AND is not R4-exempt ⇒ [`DomainError::MissingPoAllocationGroup`]. +/// +/// An ordinary point-in-time line (the routine-billing common case) is never +/// blocked: it is auto-defaulted (the Catalog default group auto-tags it) and is +/// not a multi-PO / VC obligation, so the gate does not apply. v1 has no Catalog +/// reader, so "cannot be defaulted" reduces to "the input carries no +/// `po_allocation_group`" for an obligation that needs one; a future Catalog- +/// default resolver drops in here without changing the trigger condition. +/// +/// The R4 immaterial-one-shot exemption short-circuits the gate (an exempt +/// one-shot recognizes point-in-time and needs no PO group); the materiality +/// threshold itself is re-checked inside the derivation, so here the +/// SKU-eligibility flag is the conservative exemption signal. +/// +/// # Errors +/// [`DomainError::MissingPoAllocationGroup`] when the obligation needs a PO +/// allocation group and none is resolvable / defaultable. +fn check_po_allocation_group( + input: &RecognitionInput, + item_amount_minor_ex_tax: i64, + invoice_total_minor: i64, + config: &RecognitionConfig, +) -> Result<(), DomainError> { + let needs_group = + input.timing.is_deferred() || input.multi_po || input.vc_estimate_ref.is_some(); + if !needs_group { + return Ok(()); + } + // R4-exempt one-shots recognize point-in-time and need no PO group — but only + // when ACTUALLY immaterial (the SKU flag AND under the materiality threshold), + // matching the derivation's R4 check exactly: an over-threshold flagged line + // still defers, so it must not skip the gate. + if input.immaterial_one_shot_sku + && is_immaterial(item_amount_minor_ex_tax, invoice_total_minor, config) + { + return Ok(()); + } + let resolvable = input + .po_allocation_group + .as_deref() + .is_some_and(|g| !g.is_empty()); + if resolvable { + return Ok(()); + } + Err(DomainError::MissingPoAllocationGroup(format!( + "deferring/multi-PO/VC recognition line (policy `{}`) has no resolvable or \ + defaultable po_allocation_group", + input.policy_ref + ))) +} + +/// Thin `PostLine` adapter over [`ChartIndex::resolve`]: projects a built +/// line's `(account_class, currency, revenue_stream)` onto the key-based +/// resolver. Per-stream classes key on the line's stream; the rest resolve +/// stream-less. +fn resolve_line(chart: &ChartIndex, line: &PostLine) -> Option { + chart.resolve( + line.account_class, + &line.currency, + line.revenue_stream.as_deref(), + ) +} + +/// Count the built lines that would park on SUSPENSE/PENDING — the suspense +/// backlog this invoice contributes. Pure over the input (mirrors the mapping +/// resolver) so the gauge needs no read-back. +fn pending_line_count(inv: &PostedInvoice) -> i64 { + let n = inv + .items + .iter() + .filter(|i| resolve(i).mapping_status == MappingStatus::Pending) + .count(); + i64::try_from(n).unwrap_or(i64::MAX) +} + +/// Map one SDK [`PostLine`] + its resolved scale to the engine's [`NewLine`]. +fn new_line(line: PostLine, scale: u8) -> NewLine { + NewLine { + line_id: line.line_id, + payer_tenant_id: line.payer_tenant_id, + seller_tenant_id: line.seller_tenant_id, + resource_tenant_id: line.resource_tenant_id, + account_id: line.account_id, + account_class: line.account_class, + gl_code: line.gl_code, + side: line.side, + amount_minor: line.amount_minor, + currency: line.currency, + currency_scale: scale, + invoice_id: line.invoice_id, + due_date: line.due_date, + revenue_stream: line.revenue_stream, + mapping_status: line.mapping_status, + functional_amount_minor: line.functional_amount_minor, + functional_currency: line.functional_currency, + tax_jurisdiction: line.tax_jurisdiction, + tax_filing_period: line.tax_filing_period, + tax_rate_ref: line.tax_rate_ref, + legal_entity_id: None, + invoice_item_ref: line.invoice_item_ref, + sku_or_plan_ref: line.sku_or_plan_ref, + price_id: line.price_id, + pricing_snapshot_ref: line.pricing_snapshot_ref, + po_allocation_group: line.po_allocation_group, + credit_grant_event_type: line.credit_grant_event_type, + ar_status: line.ar_status, + } +} diff --git a/gears/bss/ledger/ledger/src/infra/jobs.rs b/gears/bss/ledger/ledger/src/infra/jobs.rs new file mode 100644 index 000000000..67a91a86a --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs.rs @@ -0,0 +1,38 @@ +//! Background jobs run by the gear's `RunnableCapability` `serve` loop. +//! +//! These are **system-context, cross-tenant** jobs: they iterate all tenants +//! via the secure layer under the sanctioned all-tenants system scope +//! ([`toolkit_db::secure::AccessScope::allow_all`], the AM reaper/lease +//! pattern) and aggregate in memory (the gear has no raw-SQL / DB-side-aggregate +//! access), then narrow to one tenant via `AccessScope::for_tenant`. +//! +//! - [`tieout`] — daily tie-out (self-reconciliation) of `account_balance` +//! vs the journal lines, plus the entry-balance backstop, no-negative +//! re-check, and PENDING-mapping check. Raises invariant alarms. +//! - [`period_open`] — fiscal-period-open automation (ensures the current +//! and next `fiscal_period` exist for every legal entity with a calendar). +//! - [`queue_applier`] — periodic sweep draining due queued allocations +//! (allocate-before-settlement, §4.7) across all tenants; the backstop to the +//! drain-on-settle hook. +//! - [`aged_alarms`] — periodic `Warn` alarms for queued work / parked +//! unallocated cash that has aged past a threshold (§6). +//! - [`recognition_run`] — periodic ASC 606 S6 release: triggers a recognition +//! run for every `(tenant, period)` with due `PENDING` segments (Slice 4 §4.3), +//! the automatic backstop to the on-demand `POST /recognition-runs` endpoint. +//! - [`verifier`] — daily chain Verifier: re-walks every tenant's +//! tamper-evidence hash chain and freezes + alarms a tenant whose chain no +//! longer verifies (tamper alarm). +//! - [`attribution_sweep`] — the `payer-attribution-drift` detective seam +//! (§4.7): a documented no-op until the AuthZ/Tenant resolver is wired into +//! the gear; the resolver + per-entry comparison + alarm emission are the +//! drop-in future. + +pub mod aged_alarms; +pub mod attribution_sweep; +pub mod period_open; +pub mod queue_applier; +pub mod rate_sync; +pub mod recognition_run; +pub mod revaluation_run; +pub mod tieout; +pub mod verifier; diff --git a/gears/bss/ledger/ledger/src/infra/jobs/aged_alarms.rs b/gears/bss/ledger/ledger/src/infra/jobs/aged_alarms.rs new file mode 100644 index 000000000..ea76b3932 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/aged_alarms.rs @@ -0,0 +1,1145 @@ +//! `AgedAlarmJob` — periodic `Warn`-severity alarms for work that has aged past +//! a threshold without clearing (architecture §6). Three families: +//! +//! - **`AGED_ALLOCATION_QUEUE`** — a `PAYMENT_ALLOCATE` queue row still `QUEUED` +//! whose `queued_at` is older than [`AGED_THRESHOLD_SECS`] (its settlement never +//! landed, or the drain keeps failing). +//! - **`DISPUTE_PHASE_QUEUED`** — the same for a `CHARGEBACK` queue row (an +//! out-of-order `won`/`lost` awaiting its `opened`). +//! - **`AGED_UNALLOCATED`** — an `unallocated_balance` grain still holding cash +//! (`balance_minor > 0`) whose OLDEST contributing `UNALLOCATED` journal line +//! posted longer ago than the threshold (unapplied receipts nobody allocated). +//! +//! Unlike the hard `TieOutJob` invariants (which are `Critical`), aged alarms are +//! `Warn`: they flag *latency*, not a books defect — the queue/cache is correct, +//! just stale. They are re-emitted every tick until the aged item clears, exactly +//! like a re-detected tie-out variance. +//! +//! ## Age proxy for `AGED_UNALLOCATED` (resolves G-P5a — DECIDED) +//! `unallocated_balance` carries no age timestamp; `last_entry_seq` points at the +//! LATEST contributing entry (wrong direction). The age proxy is therefore the +//! **oldest** contributing `UNALLOCATED` line's post time: the gear has no +//! `posted_at_utc` on `journal_line` (it lives on `journal_entry`), so the job +//! reads both, builds an `entry_id -> posted_at_utc` map, groups the tenant's +//! `UNALLOCATED` lines by the unallocated grain `(payer, account, currency)` +//! (mirroring `BalanceProjector::derive_grains`), and takes the MIN post time per +//! grain. A grain is flagged iff that min age exceeds the threshold AND the cache +//! `balance_minor > 0` (cash still parked). No migration. +//! +//! ## System-context / cross-tenant (mirrors `TieOutJob` + `QueueApplierJob`) +//! The aged-queue scan reads the UNSCOPED cross-tenant candidate feed +//! ([`PendingQueueRepo::list_all_due`]); the unallocated scan enumerates tenants +//! from `unallocated_balance` under [`AccessScope::allow_all`] and re-reads each +//! tenant's lines + cache under [`AccessScope::for_tenant`]. All aggregation is in +//! memory (the gear has no DB-side aggregate access — see the `TieOutJob` docs). +//! A per-tenant read failure is isolated (logged, the pass continues) so one +//! flaky tenant doesn't abort the tick. Age is measured against +//! [`chrono::Utc::now`] — this is a live periodic tick, not a deterministic +//! replay, so wall-clock `now` is the correct reference here. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; + +use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc}; +use sea_orm::{ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{AccessScope, SecureEntityExt}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::infra::events::payloads::{ + AffectedItem, AlarmCategory, AlarmSeverity, LedgerInvariantAlarm, +}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::exception::ExceptionRouter; +use crate::infra::storage::entity::{ + account_balance, journal_entry, journal_line, refund, tax_subbalance, unallocated_balance, +}; +use crate::infra::storage::repo::PendingQueueRepo; + +/// The allocation deferred-apply queue flow this job ages — the +/// `PAYMENT_ALLOCATE` literal (kept in lockstep with `SourceDocType::PaymentAllocate` +/// and the same literal `QueueApplierJob` sweeps). +const FLOW_PAYMENT_ALLOCATE: &str = "PAYMENT_ALLOCATE"; + +/// The chargeback deferred-apply queue flow this job ages — the `CHARGEBACK` +/// literal (kept in lockstep with `SourceDocType::Chargeback`). +const FLOW_CHARGEBACK: &str = "CHARGEBACK"; + +/// `UNALLOCATED` account-class code (matches `bss_ledger_sdk::AccountClass::Unallocated`). +const CLASS_UNALLOCATED: &str = "UNALLOCATED"; + +/// `REFUND_CLEARING` account-class code (matches +/// `bss_ledger_sdk::AccountClass::RefundClearing`) — the two-stage refund's stage-1 +/// clearing liability this job ages (Slice 3 §4.4 / Group F). +const CLASS_REFUND_CLEARING: &str = "REFUND_CLEARING"; + +/// The `refund.phase` literal for a stage-1 initiation (matches +/// `RefundPhase::Initiated::as_str`) — the orphan scan looks for these with no +/// matching terminal phase. +const PHASE_INITIATED: &str = "initiated"; + +/// Refund-clearing aging WARN threshold (7 days, design §4.4 / §13 — "7 d Warn"). +/// A `REFUND_CLEARING` balance open longer than this raises the `Warn` +/// `REFUND_CLEARING_AGED` alarm; a stage-1 refund unmatched this long also pages +/// Revenue Assurance (`STAGE1_REFUND_ORPHAN`). A documented const (mirrors +/// [`AGED_THRESHOLD_SECS`]; wire to `jobs.refund_clearing_warn_secs` for +/// per-deployment tuning when needed — deferred). +const REFUND_CLEARING_WARN_SECS: i64 = 7 * 24 * 60 * 60; + +/// Refund-clearing aging PAGE threshold (14 days, design §4.4 / §13 — "14 d +/// Page"). A `REFUND_CLEARING` balance open longer than this escalates the 7-day +/// Warn to the `Critical` `STUCK_REFUND_CLEARING` close-blocking exception (+ the +/// `// exception stub (full exception_queue is Slice 7)` marker). +const REFUND_CLEARING_PAGE_SECS: i64 = 14 * 24 * 60 * 60; + +/// Age threshold (seconds) past which a `QUEUED` row or a parked unallocated grain +/// trips its aged alarm. A documented const for now (the sibling `TieOutJob` reads +/// no config either; the cadences in `JobsConfig` are tick intervals, not domain +/// thresholds). 24h — generous relative to the few-minutes queue-drain cadence, so +/// only genuinely stuck work alarms. Wire to `jobs.aged_*_secs` config when the +/// thresholds need per-deployment tuning (deferred — §6). +const AGED_THRESHOLD_SECS: i64 = 86_400; + +/// Upper bound on the cross-tenant aged-queue candidate read per tick — a ceiling +/// on how many `QUEUED` rows one pass loads into memory (mirrors the sweep job's +/// discovery limit). The aged subset is filtered from this candidate set by +/// `queued_at`. +const AGED_DISCOVERY_LIMIT: u64 = 10_000; + +/// Cap on the per-alarm `affected` list — bounds the event size on a wide aged +/// backlog while still naming enough items for an operator to act (mirrors +/// `TieOutJob::MAX_AFFECTED`). +const MAX_AFFECTED: usize = 50; + +/// One aged `QUEUED` queue row that tripped a queue-age alarm (ids only — no PII). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgedQueueItem { + /// Owning tenant. + pub tenant_id: Uuid, + /// The queue business id (the dedup/correlation key). + pub business_id: String, + /// Age of the row in whole seconds at scan time. + pub age_secs: i64, +} + +/// One unallocated grain still holding cash whose oldest contributing line is +/// older than the threshold (ids only — no PII). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgedUnallocatedGrain { + /// Owning tenant. + pub tenant_id: Uuid, + /// Paying tenant (the unallocated-pool owner dimension). + pub payer_tenant_id: Uuid, + /// Unallocated account whose pool is parked. + pub account_id: Uuid, + /// Grain currency. + pub currency: String, + /// Cached parked balance (`> 0`). + pub balance_minor: i64, + /// Age of the oldest contributing `UNALLOCATED` line in whole seconds. + pub age_secs: i64, +} + +/// One open `REFUND_CLEARING` balance grain whose oldest contributing line is +/// older than the refund-clearing aging threshold (ids only — no PII). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgedRefundClearingGrain { + /// Owning tenant. + pub tenant_id: Uuid, + /// The clearing account whose balance is stuck open. + pub account_id: Uuid, + /// Grain currency. + pub currency: String, + /// Cached open clearing balance (`> 0`). + pub balance_minor: i64, + /// Age of the oldest contributing `REFUND_CLEARING` line in whole seconds. + pub age_secs: i64, + /// `true` once the grain has aged past the 14-day PAGE threshold (the + /// close-blocking `STUCK_REFUND_CLEARING` exception); `false` for a 7-day Warn. + pub paged: bool, +} + +/// One stage-1 refund (`initiated`) with no matching stage-2 / reversal beyond the +/// aging threshold — paged to Revenue Assurance (ids only — no PII). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stage1OrphanRefund { + /// Owning tenant. + pub tenant_id: Uuid, + /// The PSP refund id whose stage-1 never advanced. + pub psp_refund_id: String, + /// Grain currency. + pub currency: String, + /// The stuck stage-1 amount in minor units. + pub amount_minor: i64, + /// Age of the stage-1 `refund` row in whole seconds. + pub age_secs: i64, +} + +/// One tax sub-balance that went negative in a CLOSED (prior) filing period — +/// negative BEYOND its filing window (design §4.5 / AC #17). An in-window +/// negative is a legitimate reversal and is NOT flagged; only a strictly-earlier +/// filing period that is negative pages Revenue Assurance (ids + amount only — +/// no PII). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NegativeTaxGrain { + /// Owning tenant. + pub tenant_id: Uuid, + /// The account whose per-jurisdiction tax cache went negative. + pub account_id: Uuid, + /// The tax jurisdiction dimension of the grain. + pub tax_jurisdiction: String, + /// The (closed, prior) filing period that is negative (`YYYYMM`). + pub tax_filing_period: String, + /// The negative cached tax balance in minor units (`< 0`). + pub balance_minor: i64, + /// Grain currency. The `tax_subbalance` cache carries no currency column, so + /// this is always empty (the defect has no single currency — mirrors the + /// other multi-/no-currency alarms). + pub currency: String, +} + +/// Whether a `tax_subbalance`'s `filing_period` is BEYOND its filing window — +/// i.e. strictly earlier than `current_period` (a CLOSED, prior period). Both +/// are `YYYYMM` strings, so a lexicographic `<` is a chronological comparison. +/// An in-window (current-period) negative is a legitimate reversal and returns +/// `false`; a future period (clock skew) also returns `false`. Factored out so +/// the window filter is unit-testable without a database. +#[must_use] +fn is_beyond_filing_window(filing_period: &str, current_period: &str) -> bool { + filing_period < current_period +} + +/// Periodic aged-alarm job over every tenant with queued work or parked cash. +pub struct AgedAlarmJob { + db: DBProvider, + publisher: Arc, + /// Metrics sink (Group F): the refund-clearing balance/age gauges + /// (`ledger_refund_clearing_balance_minor` / `_aged_seconds`) and the + /// stage-1-orphan counter (`ledger_stage1_refund_orphan_total`, design §9). + /// Defaults to the no-op so the queue/unallocated families need no metrics. + metrics: Arc, + // Slice 7 Phase 2: routes the 14-day-page `STUCK_REFUND_CLEARING` stub to a + // durable close-blocking exception row (ADDITIVE beside the alarm). `None` until + // `with_exceptions` wires it (so existing constructions are unchanged). + exceptions: Option>, +} + +impl AgedAlarmJob { + /// Build the job over one database provider and the event publisher (used + /// out-of-band to emit the aged alarms on a separate connection). The metrics + /// sink defaults to the no-op (the queue / unallocated aging families emit no + /// metrics); override via [`Self::with_metrics`] to feed the §9 refund-clearing + /// gauges + stage-1-orphan counter. + #[must_use] + pub fn new(db: DBProvider, publisher: Arc) -> Self { + Self { + db, + publisher, + metrics: Arc::new(crate::domain::ports::metrics::NoopLedgerMetrics), + exceptions: None, + } + } + + /// Attach the exception router (Slice 7 Phase 2) so the 14-day-page + /// `STUCK_REFUND_CLEARING` alarm also opens a durable close-blocking exception + /// row. Additive — the existing alarm is unchanged. + #[must_use] + pub fn with_exceptions(mut self, exceptions: Arc) -> Self { + self.exceptions = Some(exceptions); + self + } + + /// Bind the metrics sink (Group F): the refund-clearing balance/age gauges + + /// the stage-1-orphan counter (design §9). Builder form (defaults to the no-op + /// at `new`) so the existing `(db, publisher)` call sites stay source-compatible. + #[must_use] + pub fn with_metrics( + mut self, + metrics: Arc, + ) -> Self { + self.metrics = metrics; + self + } + + /// Run one aged-alarm pass: scan both queue flows + the unallocated pool + /// across all tenants, emitting one `Warn` alarm per non-empty aged class. + /// + /// # Errors + /// Returns `Err` only on an infrastructure failure reading the cross-tenant + /// candidate feeds (the pass cannot start); per-tenant unallocated read faults + /// are isolated (logged) within the pass. + pub async fn run(&self) -> anyhow::Result<()> { + let now = Utc::now(); + let threshold = ChronoDuration::seconds(AGED_THRESHOLD_SECS); + + // Z10-1: each alarm FAMILY runs independently. An infra fault enumerating one + // family's candidate feed is logged and the pass continues to the others (a + // single blip must not transiently blind the later scans for the whole tick — + // they self-heal next tick). Per-tenant faults are already isolated INSIDE each + // scan; this isolates the cross-family enumeration too. + + // --- Aged queue rows (both flows), grouped by tenant for one alarm each. + match self + .aged_queue_rows(FLOW_PAYMENT_ALLOCATE, now, threshold) + .await + { + Ok(rows) => { + self.emit_queue_alarms(AlarmCategory::AgedAllocationQueue, &rows) + .await; + } + Err(e) => tracing::error!(error = %e, + "bss-ledger: aged allocation-queue scan failed (infra); continuing"), + } + match self.aged_queue_rows(FLOW_CHARGEBACK, now, threshold).await { + Ok(rows) => { + self.emit_queue_alarms(AlarmCategory::DisputePhaseQueued, &rows) + .await; + } + Err(e) => tracing::error!(error = %e, + "bss-ledger: aged chargeback-queue scan failed (infra); continuing"), + } + + // --- Aged unallocated grains, per tenant (isolated failures). + match self.aged_unallocated_grains(now, threshold).await { + Ok(rows) => self.emit_unallocated_alarms(&rows).await, + Err(e) => tracing::error!(error = %e, + "bss-ledger: aged unallocated scan failed (infra); continuing"), + } + + // --- Slice-3 Phase-2 (Group F): refund-clearing aging + stage-1 orphans. + // Distinct thresholds (7d Warn / 14d Page, §13) from the 24h queue/ + // unallocated aging, so this scans on its own cutoffs. + match self.aged_refund_clearing_grains(now).await { + Ok(rows) => self.emit_refund_clearing_alarms(&rows).await, + Err(e) => tracing::error!(error = %e, + "bss-ledger: aged refund-clearing scan failed (infra); continuing"), + } + match self.stage1_orphan_refunds(now).await { + Ok(rows) => self.emit_stage1_orphan_alarms(&rows).await, + Err(e) => tracing::error!(error = %e, + "bss-ledger: stage-1 orphan-refund scan failed (infra); continuing"), + } + + // --- Slice-3 Phase-3 (Group 2): tax sub-balances negative beyond their filing + // window (design §4.5 / AC #17). In-window negatives are a legitimate reversal; + // only a CLOSED (prior) filing period that is negative pages Revenue Assurance. + match self.negative_tax_subbalances(now).await { + Ok(rows) => self.emit_negative_tax_alarms(&rows).await, + Err(e) => tracing::error!(error = %e, + "bss-ledger: negative-tax-subbalance scan failed (infra); continuing"), + } + + Ok(()) + } + + /// Read the UNSCOPED cross-tenant `QUEUED` candidate feed for `flow` and keep + /// only the rows whose `queued_at` is older than `threshold`. Mirrors the + /// sweep job's `list_all_due` candidate-feed read (system-context). + /// + /// # Errors + /// Returns `Err` on an infrastructure failure reading the candidate feed. + async fn aged_queue_rows( + &self, + flow: &str, + now: DateTime, + threshold: ChronoDuration, + ) -> anyhow::Result> { + let repo = PendingQueueRepo::new(self.db.clone()); + // `list_all_due` returns due `QUEUED` rows oldest-`queued_at` first; an + // aged row is necessarily due (its `apply_after`, if any, is long past), so + // the due feed is a superset of the aged set — filter it by `queued_at`. + let rows = repo + .list_all_due(flow, now, AGED_DISCOVERY_LIMIT) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: list_all_due({flow}): {e}"))?; + let cutoff = now - threshold; + Ok(rows + .into_iter() + .filter(|r| r.queued_at < cutoff) + .map(|r| AgedQueueItem { + tenant_id: r.tenant_id, + business_id: r.business_id, + age_secs: (now - r.queued_at).num_seconds(), + }) + .collect()) + } + + /// Scan every tenant's `UNALLOCATED` journal lines + `unallocated_balance` + /// cache and flag grains whose oldest contributing line is older than + /// `threshold` AND whose cached `balance_minor > 0`. Per-tenant failures are + /// isolated (logged, the pass continues). + /// + /// # Errors + /// Returns `Err` only if the up-front tenant enumeration fails. + async fn aged_unallocated_grains( + &self, + now: DateTime, + threshold: ChronoDuration, + ) -> anyhow::Result> { + // Enumerate tenants holding a parked unallocated grain (UNSCOPED system + // scope). Scoped to a block so the connection is released before the + // per-tenant loop opens its own. + let tenant_ids: BTreeSet = { + let conn = self.db.conn()?; + let cache = unallocated_balance::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .filter(Condition::all().add(unallocated_balance::Column::BalanceMinor.gt(0))) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: enumerate unallocated tenants: {e}"))?; + cache.iter().map(|c| c.tenant_id).collect() + }; + + let cutoff = now - threshold; + let mut aged = Vec::new(); + for tenant_id in tenant_ids { + match self + .aged_unallocated_for_tenant(tenant_id, now, cutoff) + .await + { + Ok(mut grains) => aged.append(&mut grains), + Err(e) => { + // Isolate per-tenant infra faults: log and continue. + tracing::error!( + tenant_id = %tenant_id, + error = %e, + "bss-ledger: aged-unallocated scan failed for tenant; continuing" + ); + } + } + } + Ok(aged) + } + + /// Per-tenant unallocated age scan: read the tenant's journal entries (for the + /// `entry_id -> posted_at_utc` map), its `UNALLOCATED` journal lines, and its + /// `unallocated_balance` cache (all scoped), then flag grains older than + /// `cutoff` with a positive cached balance. + async fn aged_unallocated_for_tenant( + &self, + tenant_id: Uuid, + now: DateTime, + cutoff: DateTime, + ) -> anyhow::Result> { + let conn = self.db.conn()?; + let scope = AccessScope::for_tenant(tenant_id); + + let entries = journal_entry::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(journal_entry::Column::TenantId.eq(tenant_id))) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: read journal_entry: {e}"))?; + let lines = journal_line::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(journal_line::Column::TenantId.eq(tenant_id)) + .add(journal_line::Column::AccountClass.eq(CLASS_UNALLOCATED)), + ) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: read journal_line: {e}"))?; + let cache = unallocated_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(unallocated_balance::Column::TenantId.eq(tenant_id))) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: read unallocated_balance: {e}"))?; + + Ok(aged_grains(&entries, &lines, &cache, now, cutoff)) + } + + /// Scan every tenant's open `REFUND_CLEARING` balances (Group F, design §4.4): + /// flag a grain whose oldest contributing `REFUND_CLEARING` line is older than + /// the 7-day WARN threshold AND whose cached `balance_minor > 0` (the clearing + /// is still open). Grains older than the 14-day PAGE threshold are marked + /// `paged` (the `STUCK_REFUND_CLEARING` close-blocking escalation). Enumerates + /// tenants from `account_balance` (UNSCOPED `allow_all`), re-reads each scoped; + /// per-tenant faults are isolated. + /// + /// # Errors + /// Returns `Err` only if the up-front tenant enumeration fails. + async fn aged_refund_clearing_grains( + &self, + now: DateTime, + ) -> anyhow::Result> { + // Enumerate tenants holding an open REFUND_CLEARING balance (system scope). + let tenant_ids: BTreeSet = { + let conn = self.db.conn()?; + let rows = account_balance::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .filter( + Condition::all() + .add(account_balance::Column::AccountClass.eq(CLASS_REFUND_CLEARING)) + .add(account_balance::Column::BalanceMinor.gt(0)), + ) + .all(&conn) + .await + .map_err(|e| { + anyhow::anyhow!("aged-alarms: enumerate refund-clearing tenants: {e}") + })?; + rows.iter().map(|c| c.tenant_id).collect() + }; + + let warn_cutoff = now - ChronoDuration::seconds(REFUND_CLEARING_WARN_SECS); + let page_cutoff = now - ChronoDuration::seconds(REFUND_CLEARING_PAGE_SECS); + let mut aged = Vec::new(); + for tenant_id in tenant_ids { + match self + .aged_refund_clearing_for_tenant(tenant_id, now, warn_cutoff, page_cutoff) + .await + { + Ok(mut grains) => aged.append(&mut grains), + Err(e) => tracing::error!( + tenant_id = %tenant_id, error = %e, + "bss-ledger: aged-refund-clearing scan failed for tenant; continuing" + ), + } + } + Ok(aged) + } + + /// Per-tenant refund-clearing age scan: read the tenant's journal entries (for + /// the `entry_id -> posted_at_utc` map), its `REFUND_CLEARING` journal lines, + /// and its `account_balance` `REFUND_CLEARING` grains (all scoped), then flag + /// grains whose oldest line is older than the WARN cutoff with a positive + /// cached balance — marking `paged` when older than the PAGE cutoff. Also feeds + /// the §9 balance/age gauges per tenant. + async fn aged_refund_clearing_for_tenant( + &self, + tenant_id: Uuid, + now: DateTime, + warn_cutoff: DateTime, + page_cutoff: DateTime, + ) -> anyhow::Result> { + let conn = self.db.conn()?; + let scope = AccessScope::for_tenant(tenant_id); + + let entries = journal_entry::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(journal_entry::Column::TenantId.eq(tenant_id))) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: read journal_entry (clearing): {e}"))?; + let lines = journal_line::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(journal_line::Column::TenantId.eq(tenant_id)) + .add(journal_line::Column::AccountClass.eq(CLASS_REFUND_CLEARING)), + ) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: read journal_line (clearing): {e}"))?; + let cache = account_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(account_balance::Column::TenantId.eq(tenant_id)) + .add(account_balance::Column::AccountClass.eq(CLASS_REFUND_CLEARING)), + ) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: read account_balance (clearing): {e}"))?; + + Ok(aged_refund_clearing_grains( + tenant_id, + &entries, + &lines, + &cache, + now, + warn_cutoff, + page_cutoff, + &*self.metrics, + )) + } + + /// Scan every tenant's stage-1 (`initiated`) refunds with no matching terminal + /// phase (`confirmed` / `rejected` / `voided` / `unknown_final`) for the same + /// `psp_refund_id`, aged past the WARN threshold (design §4.4 — "a stage-1 + /// entry without a matching stage-2 or stage-1 reversal beyond the threshold + /// pages Revenue Assurance"). Enumerates tenants from the `refund` table + /// (UNSCOPED `allow_all`), re-reads each scoped; per-tenant faults are isolated. + /// + /// # Errors + /// Returns `Err` only if the up-front tenant enumeration fails. + async fn stage1_orphan_refunds( + &self, + now: DateTime, + ) -> anyhow::Result> { + // Enumerate tenants with at least one stage-1 refund row (system scope). + let tenant_ids: BTreeSet = { + let conn = self.db.conn()?; + let rows = refund::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .filter(Condition::all().add(refund::Column::Phase.eq(PHASE_INITIATED))) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: enumerate refund tenants: {e}"))?; + rows.iter().map(|r| r.tenant_id).collect() + }; + + let cutoff = now - ChronoDuration::seconds(REFUND_CLEARING_WARN_SECS); + let mut orphans = Vec::new(); + for tenant_id in tenant_ids { + match self.stage1_orphans_for_tenant(tenant_id, now, cutoff).await { + Ok(mut rows) => orphans.append(&mut rows), + Err(e) => tracing::error!( + tenant_id = %tenant_id, error = %e, + "bss-ledger: stage1-orphan scan failed for tenant; continuing" + ), + } + } + Ok(orphans) + } + + /// Per-tenant stage-1-orphan scan: read all of the tenant's `refund` rows + /// (scoped), group by `psp_refund_id`, and flag a `psp_refund_id` whose ONLY + /// row is the stage-1 `initiated` (no terminal phase landed) AND whose stage-1 + /// `created_at_utc` is older than `cutoff`. + async fn stage1_orphans_for_tenant( + &self, + tenant_id: Uuid, + now: DateTime, + cutoff: DateTime, + ) -> anyhow::Result> { + let conn = self.db.conn()?; + let scope = AccessScope::for_tenant(tenant_id); + let rows = refund::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(refund::Column::TenantId.eq(tenant_id))) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: read refund rows: {e}"))?; + Ok(stage1_orphans(tenant_id, &rows, now, cutoff)) + } + + /// Emit the refund-clearing aging alarms (Group F): one `Warn` + /// `REFUND_CLEARING_AGED` per tenant with at least one 7-day-aged grain, and — + /// for grains past the 14-day PAGE threshold — one `Critical` + /// `STUCK_REFUND_CLEARING` per tenant (the close-blocking exception stub). + async fn emit_refund_clearing_alarms(&self, aged: &[AgedRefundClearingGrain]) { + let mut warn_by_tenant: HashMap> = HashMap::new(); + let mut page_by_tenant: HashMap> = HashMap::new(); + for g in aged { + warn_by_tenant.entry(g.tenant_id).or_default().push(g); + if g.paged { + page_by_tenant.entry(g.tenant_id).or_default().push(g); + } + } + + // 7-day Warn: aged clearing latency (re-detected each tick until it drains). + for (tenant_id, grains) in warn_by_tenant { + let detail = format!( + "tenant={tenant_id} category=REFUND_CLEARING_AGED aged_grains={}", + grains.len() + ); + tracing::warn!( + tenant_id = %tenant_id, aged_grains = grains.len(), + "bss-ledger: aged refund-clearing balances detected" + ); + let affected = grains + .iter() + .map(|g| AffectedItem { + id: format!("account={}/age_secs={}", g.account_id, g.age_secs), + currency: g.currency.clone(), + expected_minor: 0, + actual_minor: g.balance_minor, + }) + .take(MAX_AFFECTED) + .collect(); + self.emit_with_severity( + tenant_id, + AlarmCategory::RefundClearingAged, + AlarmSeverity::Warn, + &detail, + affected, + ) + .await; + } + + // 14-day Page: the close-blocking STUCK_REFUND_CLEARING exception (Critical). + for (tenant_id, grains) in page_by_tenant { + // exception stub (full exception_queue is Slice 7) + let detail = format!( + "tenant={tenant_id} category=STUCK_REFUND_CLEARING paged_grains={} \ + (close-blocking; full exception_queue is Slice 7)", + grains.len() + ); + tracing::error!( + tenant_id = %tenant_id, paged_grains = grains.len(), + "bss-ledger: refund-clearing aged past the 14-day PAGE threshold — \ + STUCK_REFUND_CLEARING (close-blocking; full exception_queue is Slice 7)" + ); + let affected = grains + .iter() + .map(|g| AffectedItem { + id: format!("account={}/age_secs={}", g.account_id, g.age_secs), + currency: g.currency.clone(), + expected_minor: 0, + actual_minor: g.balance_minor, + }) + .take(MAX_AFFECTED) + .collect(); + self.emit_with_severity( + tenant_id, + AlarmCategory::StuckRefundClearing, + AlarmSeverity::Critical, + &detail, + affected, + ) + .await; + + // Slice 7 Phase 2: ADDITIVELY open a durable close-blocking exception row + // beside the alarm above. One deduped OPEN row per tenant (fixed + // business_ref) — the periodic scan re-detects the aged grains each tick, + // and the router's `(tenant, type, business_ref)` OPEN dedup collapses + // them to a single close-blocking row. + if let Some(ex) = &self.exceptions { + ex.route( + tenant_id, + crate::domain::exception::ExceptionType::StuckRefundClearing, + "refund-clearing-aged", + Some(serde_json::json!({ "paged_grains": grains.len() })), + ) + .await; + } + } + } + + /// Emit one `STAGE1_REFUND_ORPHAN` `Warn` alarm per tenant with at least one + /// orphaned stage-1 refund (paged to Revenue Assurance), carrying the orphan + /// psp-refund ids (capped) + their amounts/ages. Bumps + /// `ledger_stage1_refund_orphan_total` per orphan (design §9). + async fn emit_stage1_orphan_alarms(&self, orphans: &[Stage1OrphanRefund]) { + let mut by_tenant: HashMap> = HashMap::new(); + for o in orphans { + by_tenant.entry(o.tenant_id).or_default().push(o); + } + for (tenant_id, items) in by_tenant { + for _ in &items { + self.metrics.stage1_refund_orphan(); + } + let detail = format!( + "tenant={tenant_id} category=STAGE1_REFUND_ORPHAN orphans={} \ + (pages Revenue Assurance)", + items.len() + ); + tracing::warn!( + tenant_id = %tenant_id, orphans = items.len(), + "bss-ledger: stage-1 refund orphans detected — paging Revenue Assurance" + ); + let affected = items + .iter() + .map(|o| AffectedItem { + id: format!("psp_refund:{}/age_secs={}", o.psp_refund_id, o.age_secs), + currency: o.currency.clone(), + expected_minor: 0, + actual_minor: o.amount_minor, + }) + .take(MAX_AFFECTED) + .collect(); + self.emit_with_severity( + tenant_id, + AlarmCategory::Stage1RefundOrphan, + AlarmSeverity::Warn, + &detail, + affected, + ) + .await; + } + } + + /// Scan every tenant's `tax_subbalance` cache (Group 2, design §4.5 / AC #17) + /// for grains that went negative BEYOND their filing window: read all rows + /// with `balance_minor < 0` (UNSCOPED `allow_all`, system context — mirrors the + /// other cross-tenant enumerations), then filter IN RUST to those whose + /// `tax_filing_period` is strictly earlier than the current `YYYYMM` filing + /// period (an in-window negative is a legitimate reversal and is NOT flagged). + /// The grain is self-contained (`balance_minor` + jurisdiction + filing-period), + /// so no journal age computation is needed — unlike the refund-clearing / + /// unallocated scans, "beyond window" is a pure string comparison. + /// + /// # Errors + /// Returns `Err` on an infrastructure failure reading `tax_subbalance`. + async fn negative_tax_subbalances( + &self, + now: DateTime, + ) -> anyhow::Result> { + let current_period = format!("{:04}{:02}", now.year(), now.month()); + let conn = self.db.conn()?; + let rows = tax_subbalance::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .filter(Condition::all().add(tax_subbalance::Column::BalanceMinor.lt(0))) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("aged-alarms: read tax_subbalance: {e}"))?; + Ok(rows + .into_iter() + .filter(|r| is_beyond_filing_window(&r.tax_filing_period, ¤t_period)) + .map(|r| NegativeTaxGrain { + tenant_id: r.tenant_id, + account_id: r.account_id, + tax_jurisdiction: r.tax_jurisdiction, + tax_filing_period: r.tax_filing_period, + balance_minor: r.balance_minor, + // `tax_subbalance` has no currency column (the defect has no single + // currency) — empty, like the other multi-/no-currency alarms. + currency: String::new(), + }) + .collect()) + } + + /// Emit one `Critical` `NEGATIVE_TAX_SUBBALANCE` alarm per negative-beyond-window + /// tax grain (Group 2). Unlike the per-tenant-grouped aged families this emits + /// per grain — each `(jurisdiction, filing-period)` discrepancy is a distinct + /// reconciliation item Revenue Assurance triages. Re-detected each tick while the + /// negative persists. + async fn emit_negative_tax_alarms(&self, grains: &[NegativeTaxGrain]) { + for g in grains { + let detail = format!( + "tax_subbalance negative beyond filing window: jurisdiction={} filing_period={} balance_minor={}", + g.tax_jurisdiction, g.tax_filing_period, g.balance_minor + ); + tracing::error!( + tenant_id = %g.tenant_id, + account_id = %g.account_id, + jurisdiction = %g.tax_jurisdiction, + filing_period = %g.tax_filing_period, + balance_minor = g.balance_minor, + "bss-ledger: tax sub-balance negative beyond its filing window — \ + NEGATIVE_TAX_SUBBALANCE (Revenue Assurance must reconcile)" + ); + let affected = vec![AffectedItem { + id: format!( + "account:{}/jurisdiction:{}/filing:{}", + g.account_id, g.tax_jurisdiction, g.tax_filing_period + ), + currency: g.currency.clone(), + expected_minor: 0, + actual_minor: g.balance_minor, + }]; + self.emit_with_severity( + g.tenant_id, + AlarmCategory::NegativeTaxSubbalance, + AlarmSeverity::Critical, + &detail, + affected, + ) + .await; + } + } + + /// Emit one fire-and-forget invariant alarm for `category` at `severity` + /// against `tenant` — the severity-parameterized twin of [`Self::emit`] (which + /// is hard-wired to `Warn`). Used by the refund-clearing path, where the 14-day + /// `STUCK_REFUND_CLEARING` page is `Critical` (design §13). + async fn emit_with_severity( + &self, + tenant_id: Uuid, + category: AlarmCategory, + severity: AlarmSeverity, + detail: &str, + affected: Vec, + ) { + let code = category.as_str().to_owned(); + let alarm = LedgerInvariantAlarm { + category, + severity, + tenant_id, + scope: format!("tenant:{tenant_id}"), + code, + detail: detail.to_owned(), + affected, + }; + self.publisher + .emit_invariant_alarm(&SecurityContext::anonymous(), alarm) + .await; + } + + /// Emit one `Warn` alarm per tenant that has at least one aged queue row for + /// `category`, carrying the aged business ids (capped) as `affected`. + async fn emit_queue_alarms(&self, category: AlarmCategory, aged: &[AgedQueueItem]) { + // Group the aged rows by tenant — one alarm per (tenant, category). + let mut by_tenant: HashMap> = HashMap::new(); + for item in aged { + by_tenant.entry(item.tenant_id).or_default().push(item); + } + for (tenant_id, items) in by_tenant { + let detail = format!( + "tenant={} category={} aged_rows={}", + tenant_id, + category.as_str(), + items.len(), + ); + tracing::warn!( + tenant_id = %tenant_id, + category = category.as_str(), + aged_rows = items.len(), + "bss-ledger: aged queue rows detected" + ); + let affected = items + .iter() + .map(|i| AffectedItem { + id: i.business_id.clone(), + currency: String::new(), + // Age proxy: expected=0 (no threshold leg), actual=age in + // seconds (the divergence an operator reads). + expected_minor: 0, + actual_minor: i.age_secs, + }) + .take(MAX_AFFECTED) + .collect(); + self.emit(tenant_id, category, &detail, affected).await; + } + } + + /// Emit one `AGED_UNALLOCATED` `Warn` alarm per tenant with at least one aged + /// parked grain, carrying the grain keys (capped) + their balances/ages. + async fn emit_unallocated_alarms(&self, aged: &[AgedUnallocatedGrain]) { + let mut by_tenant: HashMap> = HashMap::new(); + for g in aged { + by_tenant.entry(g.tenant_id).or_default().push(g); + } + for (tenant_id, grains) in by_tenant { + let detail = format!( + "tenant={tenant_id} category=AGED_UNALLOCATED aged_grains={}", + grains.len(), + ); + tracing::warn!( + tenant_id = %tenant_id, + aged_grains = grains.len(), + "bss-ledger: aged unallocated grains detected" + ); + let affected = grains + .iter() + .map(|g| AffectedItem { + id: format!( + "payer={}/account={}/age_secs={}", + g.payer_tenant_id, g.account_id, g.age_secs + ), + currency: g.currency.clone(), + // expected=0 (no target), actual=the parked balance still sat + // in the pool (what an operator must get allocated/returned). + expected_minor: 0, + actual_minor: g.balance_minor, + }) + .take(MAX_AFFECTED) + .collect(); + self.emit(tenant_id, AlarmCategory::AgedUnallocated, &detail, affected) + .await; + } + } + + /// Emit one fire-and-forget `Warn` invariant alarm for `category` against + /// `tenant`. Mirrors [`crate::infra::jobs::tieout::TieOutJob::emit`] but at + /// `Warn` (aged alarms flag latency, not a books defect). + async fn emit( + &self, + tenant_id: Uuid, + category: AlarmCategory, + detail: &str, + affected: Vec, + ) { + let code = category.as_str().to_owned(); + let alarm = LedgerInvariantAlarm { + category, + severity: AlarmSeverity::Warn, + tenant_id, + scope: format!("tenant:{tenant_id}"), + code, + detail: detail.to_owned(), + affected, + }; + self.publisher + .emit_invariant_alarm(&SecurityContext::anonymous(), alarm) + .await; + } +} + +/// Pure aged-unallocated detector (factored out so it is unit-testable without a +/// database): fold the `UNALLOCATED` lines into a per-grain MIN post time using +/// the `entry_id -> posted_at_utc` map, then flag a grain iff its oldest line is +/// older than `cutoff` AND the cache holds `balance_minor > 0`. The grain key +/// `(payer_tenant_id, account_id, currency)` mirrors +/// `BalanceProjector::derive_grains`' unallocated grain. +fn aged_grains( + entries: &[journal_entry::Model], + lines: &[journal_line::Model], + cache: &[unallocated_balance::Model], + now: DateTime, + cutoff: DateTime, +) -> Vec { + // entry_id -> posted_at_utc (the age source; `journal_line` carries none). + let posted_at: HashMap> = entries + .iter() + .map(|e| (e.entry_id, e.posted_at_utc)) + .collect(); + + // (payer_tenant_id, account_id, currency) -> oldest contributing post time. + let mut oldest: HashMap<(Uuid, Uuid, String), DateTime> = HashMap::new(); + for line in lines { + // A line whose entry isn't in the map can't be aged (defensive — entries + // and lines are read in the same scope, so this should not happen). + let Some(ts) = posted_at.get(&line.entry_id).copied() else { + continue; + }; + let key = (line.payer_tenant_id, line.account_id, line.currency.clone()); + oldest + .entry(key) + .and_modify(|cur| { + if ts < *cur { + *cur = ts; + } + }) + .or_insert(ts); + } + + // Flag a parked cache grain (`balance_minor > 0`) whose oldest line is aged. + cache + .iter() + .filter(|c| c.balance_minor > 0) + .filter_map(|c| { + let key = (c.payer_tenant_id, c.account_id, c.currency.clone()); + let oldest_ts = *oldest.get(&key)?; + (oldest_ts < cutoff).then(|| AgedUnallocatedGrain { + tenant_id: c.tenant_id, + payer_tenant_id: c.payer_tenant_id, + account_id: c.account_id, + currency: c.currency.clone(), + balance_minor: c.balance_minor, + age_secs: (now - oldest_ts).num_seconds(), + }) + }) + .collect() +} + +/// Pure refund-clearing-aging detector (factored out so it is unit-testable +/// without a database, mirroring [`aged_grains`]): fold the `REFUND_CLEARING` +/// lines into a per-account MIN post time via the `entry_id -> posted_at_utc` map, +/// then flag an `account_balance` `REFUND_CLEARING` grain iff its oldest line is +/// older than `warn_cutoff` AND the cache holds `balance_minor > 0`. A grain whose +/// oldest line is also older than `page_cutoff` is marked `paged` (the +/// `STUCK_REFUND_CLEARING` 14-day escalation). Feeds the §9 balance/age gauges per +/// grain via `metrics` (a side effect kept here so the live + test paths agree). +/// The grain key is `(account_id, currency)` — a clearing account is a stream-less +/// system grain (no payer dimension, unlike the unallocated pool). +#[allow(clippy::too_many_arguments)] +fn aged_refund_clearing_grains( + _tenant_id: Uuid, + entries: &[journal_entry::Model], + lines: &[journal_line::Model], + cache: &[account_balance::Model], + now: DateTime, + warn_cutoff: DateTime, + page_cutoff: DateTime, + metrics: &dyn crate::domain::ports::metrics::LedgerMetricsPort, +) -> Vec { + let posted_at: HashMap> = entries + .iter() + .map(|e| (e.entry_id, e.posted_at_utc)) + .collect(); + + // (account_id, currency) -> oldest contributing REFUND_CLEARING post time. + let mut oldest: HashMap<(Uuid, String), DateTime> = HashMap::new(); + for line in lines { + let Some(ts) = posted_at.get(&line.entry_id).copied() else { + continue; + }; + let key = (line.account_id, line.currency.clone()); + oldest + .entry(key) + .and_modify(|cur| { + if ts < *cur { + *cur = ts; + } + }) + .or_insert(ts); + } + + cache + .iter() + .filter(|c| c.balance_minor > 0) + .filter_map(|c| { + let key = (c.account_id, c.currency.clone()); + let oldest_ts = *oldest.get(&key)?; + // Feed the §9 gauges for every OPEN grain (not just aged ones): the + // balance + its current age, by tenant. + let age_secs = (now - oldest_ts).num_seconds(); + metrics.refund_clearing_balance_minor(c.tenant_id, c.balance_minor); + #[allow(clippy::cast_precision_loss)] + metrics.refund_clearing_aged_seconds(c.tenant_id, age_secs as f64); + (oldest_ts < warn_cutoff).then(|| AgedRefundClearingGrain { + tenant_id: c.tenant_id, + account_id: c.account_id, + currency: c.currency.clone(), + balance_minor: c.balance_minor, + age_secs, + paged: oldest_ts < page_cutoff, + }) + }) + .collect() +} + +/// Pure stage-1-orphan detector (factored out so it is unit-testable without a +/// database): group a tenant's `refund` rows by `psp_refund_id`, and flag a +/// `psp_refund_id` whose set of rows contains a stage-1 `initiated` but NO terminal +/// phase (`confirmed` / `rejected` / `voided` / `unknown_final`) AND whose stage-1 +/// `created_at_utc` is older than `cutoff`. The stage-1 row carries the stuck +/// amount + currency. A stage-1 that already advanced (any non-`initiated` phase +/// row exists for the same `psp_refund_id`) is NOT an orphan. +fn stage1_orphans( + tenant_id: Uuid, + rows: &[refund::Model], + now: DateTime, + cutoff: DateTime, +) -> Vec { + // psp_refund_id -> (has a terminal/advanced phase?, the stage-1 row if present). + let mut by_psp: HashMap<&str, (bool, Option<&refund::Model>)> = HashMap::new(); + for r in rows { + let entry = by_psp + .entry(r.psp_refund_id.as_str()) + .or_insert((false, None)); + if r.phase == PHASE_INITIATED { + entry.1 = Some(r); + } else { + // Any non-initiated phase (confirmed / rejected / voided / unknown_final) + // means the stage-1 advanced — not an orphan. + entry.0 = true; + } + } + + by_psp + .into_values() + .filter_map(|(advanced, stage1)| { + let stage1 = stage1?; + if advanced || stage1.created_at_utc >= cutoff { + return None; + } + Some(Stage1OrphanRefund { + tenant_id, + psp_refund_id: stage1.psp_refund_id.clone(), + currency: stage1.currency.clone(), + amount_minor: stage1.amount_minor, + age_secs: (now - stage1.created_at_utc).num_seconds(), + }) + }) + .collect() +} + +#[cfg(test)] +#[path = "aged_alarms_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/jobs/aged_alarms_tests.rs b/gears/bss/ledger/ledger/src/infra/jobs/aged_alarms_tests.rs new file mode 100644 index 000000000..8363ed319 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/aged_alarms_tests.rs @@ -0,0 +1,927 @@ +//! Tests for `AgedAlarmJob` — the aged-work `Warn` alarms (Group C). +//! +//! The load-bearing coverage is the pure [`aged_grains`] detector (the resolved +//! G-P5a age proxy: oldest contributing `UNALLOCATED` line's post time, gated on +//! a positive cached balance) plus the queue-age filter — both exercised in +//! plain unit tests. Docker-gated integration tests boot Postgres, seed an aged +//! `QUEUED` row + an aged parked unallocated grain, and assert the cross-tenant +//! scans find them and `run()` completes. +//! +//! Ignored Docker tests run with +//! `cargo test -p bss-ledger --lib 'infra::jobs::aged_alarms::tests' -- --ignored`. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::doc_markdown, + clippy::similar_names, + clippy::inconsistent_struct_constructor +)] + +use std::sync::Arc; + +use chrono::{DateTime, Datelike, Duration as ChronoDuration, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement, TransactionTrait}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +use super::{ + AgedAlarmJob, AgedUnallocatedGrain, NegativeTaxGrain, aged_grains, aged_refund_clearing_grains, + is_beyond_filing_window, stage1_orphans, +}; +use crate::domain::ports::metrics::NoopLedgerMetrics; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::storage::entity::{ + account_balance, journal_entry, journal_line, refund, unallocated_balance, +}; +use crate::infra::storage::migrations::Migrator; + +// --------------------------------------------------------------------------- +// Pure-function fixtures (no DB) — the age-proxy logic. +// --------------------------------------------------------------------------- + +const TENANT: u128 = 0xA1; +const PAYER: u128 = 0xB1; +const ACCOUNT: u128 = 0xC1; + +fn entry(entry_id: Uuid, posted_at: DateTime) -> journal_entry::Model { + journal_entry::Model { + entry_id, + tenant_id: Uuid::from_u128(TENANT), + legal_entity_id: Uuid::from_u128(TENANT), + period_id: "202606".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: "PAYMENT_SETTLE".to_owned(), + source_business_id: "pay-1".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: Uuid::from_u128(TENANT), + correlation_id: Uuid::from_u128(TENANT), + rounding_evidence: serde_json::Value::Null, + created_seq: 1, + row_hash: None, + prev_hash: None, + prev_entry_id: None, + prev_period_id: None, + } +} + +fn unalloc_line(entry_id: Uuid, account: u128) -> journal_line::Model { + journal_line::Model { + line_id: Uuid::now_v7(), + entry_id, + tenant_id: Uuid::from_u128(TENANT), + period_id: "202606".to_owned(), + payer_tenant_id: Uuid::from_u128(PAYER), + seller_tenant_id: None, + resource_tenant_id: None, + account_id: Uuid::from_u128(account), + account_class: "UNALLOCATED".to_owned(), + gl_code: None, + side: "CR".to_owned(), + amount_minor: 1_000, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: "RESOLVED".to_owned(), + functional_amount_minor: None, + functional_currency: None, + rate_snapshot_ref: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn unalloc_cache(account: u128, balance_minor: i64) -> unallocated_balance::Model { + unallocated_balance::Model { + tenant_id: Uuid::from_u128(TENANT), + payer_tenant_id: Uuid::from_u128(PAYER), + account_id: Uuid::from_u128(account), + currency: "USD".to_owned(), + balance_minor, + functional_balance_minor: None, + functional_currency: None, + last_entry_seq: None, + version: 0, + } +} + +#[test] +fn aged_grains_flags_old_grain_with_positive_balance() { + let now = Utc::now(); + let cutoff = now - ChronoDuration::seconds(86_400); + // One UNALLOCATED line posted 2 days ago — older than the 1-day cutoff. + let e = Uuid::now_v7(); + let entries = vec![entry(e, now - ChronoDuration::days(2))]; + let lines = vec![unalloc_line(e, ACCOUNT)]; + let cache = vec![unalloc_cache(ACCOUNT, 1_000)]; + + let aged = aged_grains(&entries, &lines, &cache, now, cutoff); + assert_eq!(aged.len(), 1, "an old grain with parked cash is aged"); + assert_eq!( + aged[0], + AgedUnallocatedGrain { + tenant_id: Uuid::from_u128(TENANT), + payer_tenant_id: Uuid::from_u128(PAYER), + account_id: Uuid::from_u128(ACCOUNT), + currency: "USD".to_owned(), + balance_minor: 1_000, + age_secs: aged[0].age_secs, + } + ); + assert!( + aged[0].age_secs >= 86_400, + "age proxy is ~2 days in seconds" + ); +} + +#[test] +fn aged_grains_uses_oldest_contributing_line_not_latest() { + // Two lines for the SAME grain: one fresh (today), one old (3 days). The age + // proxy is the OLDEST (3 days), NOT the latest — the resolved G-P5a decision + // (NOT `last_entry_seq`, which would point at the fresh line). + let now = Utc::now(); + let cutoff = now - ChronoDuration::seconds(86_400); + let old = Uuid::now_v7(); + let fresh = Uuid::now_v7(); + let entries = vec![entry(old, now - ChronoDuration::days(3)), entry(fresh, now)]; + let lines = vec![unalloc_line(old, ACCOUNT), unalloc_line(fresh, ACCOUNT)]; + let cache = vec![unalloc_cache(ACCOUNT, 1_000)]; + + let aged = aged_grains(&entries, &lines, &cache, now, cutoff); + assert_eq!(aged.len(), 1, "grain is aged via its oldest line"); + assert!( + aged[0].age_secs >= 3 * 86_400, + "age must be measured from the OLDEST (3d) line, not the fresh one: {}", + aged[0].age_secs + ); +} + +#[test] +fn aged_grains_skips_fresh_grain() { + // Oldest line is fresh (1h) — below the 1-day cutoff ⇒ not aged. + let now = Utc::now(); + let cutoff = now - ChronoDuration::seconds(86_400); + let e = Uuid::now_v7(); + let entries = vec![entry(e, now - ChronoDuration::hours(1))]; + let lines = vec![unalloc_line(e, ACCOUNT)]; + let cache = vec![unalloc_cache(ACCOUNT, 1_000)]; + + assert!( + aged_grains(&entries, &lines, &cache, now, cutoff).is_empty(), + "a recently-funded pool is not aged" + ); +} + +#[test] +fn aged_grains_skips_zero_balance_grain() { + // Old line, but the cache balance is 0 (fully allocated) ⇒ not aged (the + // `balance_minor > 0` gate). A drained pool needs no alarm even if its lines + // are old. + let now = Utc::now(); + let cutoff = now - ChronoDuration::seconds(86_400); + let e = Uuid::now_v7(); + let entries = vec![entry(e, now - ChronoDuration::days(5))]; + let lines = vec![unalloc_line(e, ACCOUNT)]; + let cache = vec![unalloc_cache(ACCOUNT, 0)]; + + assert!( + aged_grains(&entries, &lines, &cache, now, cutoff).is_empty(), + "a drained (zero-balance) pool is not aged" + ); +} + +#[test] +fn aged_grains_keys_per_grain() { + // Two distinct accounts: one old+parked (aged), one fresh+parked (not). Only + // the old grain is flagged — confirms the (payer, account, currency) keying. + let now = Utc::now(); + let cutoff = now - ChronoDuration::seconds(86_400); + let e_old = Uuid::now_v7(); + let e_fresh = Uuid::now_v7(); + let other_account = 0xC2; + let entries = vec![ + entry(e_old, now - ChronoDuration::days(2)), + entry(e_fresh, now - ChronoDuration::minutes(5)), + ]; + let lines = vec![ + unalloc_line(e_old, ACCOUNT), + unalloc_line(e_fresh, other_account), + ]; + let cache = vec![ + unalloc_cache(ACCOUNT, 1_000), + unalloc_cache(other_account, 2_000), + ]; + + let aged = aged_grains(&entries, &lines, &cache, now, cutoff); + assert_eq!(aged.len(), 1, "only the old grain is aged"); + assert_eq!(aged[0].account_id, Uuid::from_u128(ACCOUNT)); +} + +#[test] +fn aged_grains_empty_inputs() { + let now = Utc::now(); + let cutoff = now - ChronoDuration::seconds(86_400); + assert!(aged_grains(&[], &[], &[], now, cutoff).is_empty()); +} + +// --------------------------------------------------------------------------- +// Group F: refund-clearing aging + stage-1-orphan pure detectors (no DB). +// --------------------------------------------------------------------------- + +const WARN_SECS: i64 = 7 * 24 * 60 * 60; +const PAGE_SECS: i64 = 14 * 24 * 60 * 60; + +fn clearing_line(entry_id: Uuid, account: u128) -> journal_line::Model { + journal_line::Model { + account_class: "REFUND_CLEARING".to_owned(), + ..unalloc_line(entry_id, account) + } +} + +fn clearing_cache(account: u128, balance_minor: i64) -> account_balance::Model { + account_balance::Model { + tenant_id: Uuid::from_u128(TENANT), + account_id: Uuid::from_u128(account), + currency: "USD".to_owned(), + account_class: "REFUND_CLEARING".to_owned(), + normal_side: "CR".to_owned(), + balance_minor, + functional_balance_minor: None, + functional_currency: None, + last_entry_seq: None, + version: 0, + } +} + +fn refund_row(psp: &str, phase: &str, created_at: DateTime) -> refund::Model { + refund::Model { + tenant_id: Uuid::from_u128(TENANT), + refund_id: format!("rf-{psp}-{phase}"), + psp_refund_id: psp.to_owned(), + phase: phase.to_owned(), + pattern: "A_UNALLOCATED".to_owned(), + payment_id: "pay-1".to_owned(), + invoice_id: None, + currency: "USD".to_owned(), + amount_minor: 500, + clearing_state: "PENDING".to_owned(), + relates_to_refund_id: None, + reverses_entry_id: None, + created_at_utc: created_at, + version: 0, + } +} + +#[test] +fn refund_clearing_aged_flags_open_grain_past_7d_warn() { + let now = Utc::now(); + let warn = now - ChronoDuration::seconds(WARN_SECS); + let page = now - ChronoDuration::seconds(PAGE_SECS); + // Clearing line posted 8 days ago — past the 7d Warn, under the 14d Page. + let e = Uuid::now_v7(); + let entries = vec![entry(e, now - ChronoDuration::days(8))]; + let lines = vec![clearing_line(e, ACCOUNT)]; + let cache = vec![clearing_cache(ACCOUNT, 500)]; + + let aged = aged_refund_clearing_grains( + Uuid::from_u128(TENANT), + &entries, + &lines, + &cache, + now, + warn, + page, + &NoopLedgerMetrics, + ); + assert_eq!(aged.len(), 1, "an 8-day-open clearing grain is aged (Warn)"); + assert_eq!(aged[0].account_id, Uuid::from_u128(ACCOUNT)); + assert_eq!(aged[0].balance_minor, 500); + assert!(!aged[0].paged, "8 days is past Warn but not the 14d Page"); +} + +#[test] +fn refund_clearing_aged_marks_paged_past_14d() { + let now = Utc::now(); + let warn = now - ChronoDuration::seconds(WARN_SECS); + let page = now - ChronoDuration::seconds(PAGE_SECS); + // 15 days open — past BOTH thresholds ⇒ paged (STUCK_REFUND_CLEARING). + let e = Uuid::now_v7(); + let entries = vec![entry(e, now - ChronoDuration::days(15))]; + let lines = vec![clearing_line(e, ACCOUNT)]; + let cache = vec![clearing_cache(ACCOUNT, 500)]; + + let aged = aged_refund_clearing_grains( + Uuid::from_u128(TENANT), + &entries, + &lines, + &cache, + now, + warn, + page, + &NoopLedgerMetrics, + ); + assert_eq!(aged.len(), 1); + assert!( + aged[0].paged, + "15 days is past the 14d Page → close-blocking" + ); +} + +#[test] +fn refund_clearing_skips_fresh_and_drained_grains() { + let now = Utc::now(); + let warn = now - ChronoDuration::seconds(WARN_SECS); + let page = now - ChronoDuration::seconds(PAGE_SECS); + // Fresh (2 days) open grain + an old (10 days) but DRAINED (0) grain — neither + // is aged (the 7d cutoff + the `balance_minor > 0` gate). + let fresh = Uuid::now_v7(); + let drained = Uuid::now_v7(); + let other = 0xC2; + let entries = vec![ + entry(fresh, now - ChronoDuration::days(2)), + entry(drained, now - ChronoDuration::days(10)), + ]; + let lines = vec![clearing_line(fresh, ACCOUNT), clearing_line(drained, other)]; + let cache = vec![clearing_cache(ACCOUNT, 500), clearing_cache(other, 0)]; + + assert!( + aged_refund_clearing_grains( + Uuid::from_u128(TENANT), + &entries, + &lines, + &cache, + now, + warn, + page, + &NoopLedgerMetrics, + ) + .is_empty(), + "a fresh open grain and a drained old grain are both un-aged" + ); +} + +#[test] +fn stage1_orphan_flags_unmatched_aged_stage1() { + let now = Utc::now(); + let cutoff = now - ChronoDuration::seconds(WARN_SECS); + // A stage-1 `initiated` 8 days old with NO terminal phase ⇒ orphan. + let rows = vec![refund_row( + "psp-orphan", + "initiated", + now - ChronoDuration::days(8), + )]; + let orphans = stage1_orphans(Uuid::from_u128(TENANT), &rows, now, cutoff); + assert_eq!(orphans.len(), 1, "an unmatched aged stage-1 is an orphan"); + assert_eq!(orphans[0].psp_refund_id, "psp-orphan"); + assert_eq!(orphans[0].amount_minor, 500); + assert!(orphans[0].age_secs >= WARN_SECS); +} + +#[test] +fn stage1_orphan_skips_advanced_and_fresh_stage1() { + let now = Utc::now(); + let cutoff = now - ChronoDuration::seconds(WARN_SECS); + let rows = vec![ + // Advanced: stage-1 + a matching `confirmed` (same psp) ⇒ NOT an orphan, + // even though the stage-1 is old. + refund_row("psp-done", "initiated", now - ChronoDuration::days(9)), + refund_row("psp-done", "confirmed", now - ChronoDuration::days(8)), + // A stage-1 reversal also counts as advanced. + refund_row("psp-reversed", "initiated", now - ChronoDuration::days(9)), + refund_row("psp-reversed", "rejected", now - ChronoDuration::days(8)), + // A FRESH unmatched stage-1 (2 days) is under the threshold ⇒ not yet. + refund_row("psp-fresh", "initiated", now - ChronoDuration::days(2)), + ]; + assert!( + stage1_orphans(Uuid::from_u128(TENANT), &rows, now, cutoff).is_empty(), + "an advanced or fresh stage-1 is not an orphan" + ); +} + +// --------------------------------------------------------------------------- +// Group 2 (Slice-3 Phase-3): NEGATIVE_TAX_SUBBALANCE beyond-filing-window filter. +// --------------------------------------------------------------------------- + +#[test] +fn beyond_window_flags_prior_filing_period() { + // A negative tax sub-balance in a CLOSED (prior) filing period is beyond its + // window ⇒ flagged. (June 2026 current; May 2026 is closed.) + assert!( + is_beyond_filing_window("202605", "202606"), + "a strictly-earlier filing period is beyond the window" + ); + // Crossing a year boundary still orders chronologically (YYYYMM lexicographic). + assert!(is_beyond_filing_window("202512", "202601")); +} + +#[test] +fn beyond_window_skips_current_filing_period() { + // An in-window (current-period) negative is a legitimate reversal ⇒ NOT flagged. + assert!( + !is_beyond_filing_window("202606", "202606"), + "the current filing period is in-window (legitimate reversal)" + ); +} + +#[test] +fn beyond_window_skips_future_filing_period() { + // A future filing period (clock skew / pre-staged) is not a closed period ⇒ + // NOT flagged. + assert!( + !is_beyond_filing_window("202607", "202606"), + "a future filing period is not a closed prior period" + ); +} + +// --------------------------------------------------------------------------- +// Docker (testcontainers) integration — the cross-tenant scans + run(). +// --------------------------------------------------------------------------- + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot + migrate; return a raw conn (for SQL seeds) and a secure provider. +async fn setup(container_url: &str) -> (DatabaseConnection, DBProvider) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + (raw, DBProvider::::new(tdb)) +} + +/// `run()` over an empty ledger completes with `Ok(())` (no aged work). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn run_over_empty_ledger_completes() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, provider) = setup(&url).await; + + let job = AgedAlarmJob::new(provider, Arc::new(LedgerEventPublisher::noop())); + job.run() + .await + .expect("run must succeed over an empty ledger"); +} + +/// An aged `QUEUED` `PAYMENT_ALLOCATE` row is surfaced by the cross-tenant queue +/// scan, and `run()` (which emits the alarm) completes `Ok`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn aged_queue_row_is_detected_and_run_completes() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + + let tenant = Uuid::now_v7(); + // Seed one QUEUED PAYMENT_ALLOCATE row queued 3 days ago (older than the 1-day + // threshold) — directly via SQL (the durable work-state row the scan reads). + raw.execute(pg(format!( + "INSERT INTO bss.ledger_pending_event_queue \ + (tenant_id, flow, business_id, payload, queued_at, apply_after, status, attempts) \ + VALUES ('{tenant}', 'PAYMENT_ALLOCATE', 'alloc-aged-1', '{{}}'::jsonb, \ + now() - interval '3 days', NULL, 'QUEUED', 0)" + ))) + .await + .unwrap(); + // A fresh row (queued now) must NOT be aged. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_pending_event_queue \ + (tenant_id, flow, business_id, payload, queued_at, apply_after, status, attempts) \ + VALUES ('{tenant}', 'PAYMENT_ALLOCATE', 'alloc-fresh-1', '{{}}'::jsonb, \ + now(), NULL, 'QUEUED', 0)" + ))) + .await + .unwrap(); + + let job = AgedAlarmJob::new(provider, Arc::new(LedgerEventPublisher::noop())); + + // The internal scan finds exactly the aged row (not the fresh one). + let aged = job + .aged_queue_rows( + "PAYMENT_ALLOCATE", + Utc::now(), + ChronoDuration::seconds(86_400), + ) + .await + .expect("aged_queue_rows must succeed"); + assert_eq!(aged.len(), 1, "only the 3-day-old row is aged"); + assert_eq!(aged[0].business_id, "alloc-aged-1"); + assert_eq!(aged[0].tenant_id, tenant); + assert!(aged[0].age_secs >= 86_400); + + // run() over the seeded ledger completes Ok (emits the Warn alarm). + job.run() + .await + .expect("run must succeed with an aged queue row"); +} + +/// An aged `CHARGEBACK` queue row is surfaced by the same scan under the +/// `CHARGEBACK` flow (the dispute-phase-queued family). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn aged_chargeback_queue_row_is_detected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + + let tenant = Uuid::now_v7(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_pending_event_queue \ + (tenant_id, flow, business_id, payload, queued_at, apply_after, status, attempts) \ + VALUES ('{tenant}', 'CHARGEBACK', 'cb-aged-1', '{{}}'::jsonb, \ + now() - interval '2 days', NULL, 'QUEUED', 0)" + ))) + .await + .unwrap(); + + let job = AgedAlarmJob::new(provider, Arc::new(LedgerEventPublisher::noop())); + let aged = job + .aged_queue_rows("CHARGEBACK", Utc::now(), ChronoDuration::seconds(86_400)) + .await + .expect("aged_queue_rows(CHARGEBACK) must succeed"); + assert_eq!(aged.len(), 1, "the 2-day-old chargeback row is aged"); + assert_eq!(aged[0].business_id, "cb-aged-1"); +} + +/// Group F: an aged open `REFUND_CLEARING` balance is surfaced by the cross-tenant +/// refund-clearing scan (8 days → Warn), and `run()` (which emits the alarm + the +/// §9 gauges) completes `Ok`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn aged_refund_clearing_is_detected_and_run_completes() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + + let tenant = Uuid::now_v7(); + let account = Uuid::now_v7(); + let source_account = Uuid::now_v7(); + let entry_id = Uuid::now_v7(); + // A REFUND_CLEARING journal entry posted 8 days ago (the age proxy source), + // seeded with a BALANCED pair of lines in ONE transaction so the deferrable + // `trg_journal_entry_balanced` constraint trigger fires exactly once — at the + // txn COMMIT, against a complete, valid entry. (A per-statement autocommit + // seed would fire the deferred trigger at the entry's own COMMIT, before any + // line exists, raising LEDGER_ENTRY_EMPTY.) + let txn = raw.begin().await.unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, source_doc_type, \ + source_business_id, posted_at_utc, effective_at, origin, posted_by_actor_id, \ + correlation_id, rounding_evidence, created_seq) \ + VALUES ('{entry_id}', '{tenant}', '{tenant}', '202606', 'USD', 'REFUND', \ + 'psp-stuck:initiated', now() - interval '8 days', '2026-06-01', 'SYSTEM', '{tenant}', \ + '{entry_id}', '{{}}'::jsonb, 1)" + ))) + .await + .unwrap(); + // … its REFUND_CLEARING line (the grain the aged scan reads) … + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, account_class, \ + side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{}', '{entry_id}', '{tenant}', '202606', '{tenant}', '{account}', \ + 'REFUND_CLEARING', 'CR', 500, 'USD', 2, 'RESOLVED')", + Uuid::now_v7() + ))) + .await + .unwrap(); + // … and its balancing DR leg (the money source a stage-1 refund reserves from), + // making the entry zero-sum per (currency, currency_scale) so the deferred + // balanced-trigger passes at COMMIT. + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, account_class, \ + side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{}', '{entry_id}', '{tenant}', '202606', '{tenant}', '{source_account}', \ + 'UNALLOCATED', 'DR', 500, 'USD', 2, 'RESOLVED')", + Uuid::now_v7() + ))) + .await + .unwrap(); + txn.commit().await.unwrap(); + // … and the open cache grain (`balance_minor > 0`); `ledger_account_balance` + // has no balanced-trigger, so a plain autocommit insert is fine here. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_account_balance \ + (tenant_id, account_id, currency, account_class, normal_side, balance_minor, version) \ + VALUES ('{tenant}', '{account}', 'USD', 'REFUND_CLEARING', 'CR', 500, 0)" + ))) + .await + .unwrap(); + + let job = AgedAlarmJob::new(provider, Arc::new(LedgerEventPublisher::noop())); + let aged = job + .aged_refund_clearing_grains(Utc::now()) + .await + .expect("aged_refund_clearing_grains must succeed"); + assert_eq!(aged.len(), 1, "the 8-day-open clearing grain is aged"); + assert_eq!(aged[0].tenant_id, tenant); + assert_eq!(aged[0].balance_minor, 500); + assert!(!aged[0].paged, "8 days is Warn, not the 14d Page"); + + job.run() + .await + .expect("run must succeed with an aged clearing"); +} + +/// Group F: an orphaned stage-1 refund (an `initiated` row 8 days old with no +/// matching terminal phase) is surfaced by the cross-tenant stage-1-orphan scan. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn stage1_orphan_refund_is_detected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + + let tenant = Uuid::now_v7(); + // An `initiated` stage-1 refund 8 days old, NO terminal phase ⇒ orphan. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_refund \ + (tenant_id, refund_id, psp_refund_id, phase, pattern, payment_id, currency, \ + amount_minor, clearing_state, created_at_utc, version) \ + VALUES ('{tenant}', 'rf-orphan', 'psp-orphan', 'initiated', 'A_UNALLOCATED', 'pay-1', \ + 'USD', 500, 'PENDING', now() - interval '8 days', 0)" + ))) + .await + .unwrap(); + // A fully-advanced refund (initiated + confirmed) must NOT be flagged. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_refund \ + (tenant_id, refund_id, psp_refund_id, phase, pattern, payment_id, currency, \ + amount_minor, clearing_state, created_at_utc, version) \ + VALUES ('{tenant}', 'rf-done-1', 'psp-done', 'initiated', 'A_UNALLOCATED', 'pay-2', \ + 'USD', 500, 'PENDING', now() - interval '9 days', 0)" + ))) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_refund \ + (tenant_id, refund_id, psp_refund_id, phase, pattern, payment_id, currency, \ + amount_minor, clearing_state, created_at_utc, version) \ + VALUES ('{tenant}', 'rf-done-2', 'psp-done', 'confirmed', 'A_UNALLOCATED', 'pay-2', \ + 'USD', 500, 'SETTLED', now() - interval '8 days', 0)" + ))) + .await + .unwrap(); + + let job = AgedAlarmJob::new(provider, Arc::new(LedgerEventPublisher::noop())); + let orphans = job + .stage1_orphan_refunds(Utc::now()) + .await + .expect("stage1_orphan_refunds must succeed"); + assert_eq!(orphans.len(), 1, "only the unmatched stage-1 is an orphan"); + assert_eq!(orphans[0].psp_refund_id, "psp-orphan"); + assert_eq!(orphans[0].tenant_id, tenant); +} + +/// Seed a balanced `DR / CR UNALLOCATED` journal entry whose +/// `posted_at_utc` is `age_days` in the past, and the matching open +/// `unallocated_balance` cache grain (`balance_minor`). Mirrors the +/// refund-clearing integration seed: the deferrable `trg_journal_entry_balanced` +/// trigger needs a complete, zero-sum entry at COMMIT, so the entry + both legs +/// land in ONE transaction. The CR UNALLOCATED line (the grain the unallocated +/// age scan reads) is balanced by a DR CASH_CLEARING leg so the entry is zero-sum +/// per `(currency, currency_scale)`. The `ledger_unallocated_balance` cache has +/// no balanced-trigger, so a plain autocommit insert is fine. +async fn seed_unallocated_grain( + raw: &DatabaseConnection, + tenant: Uuid, + payer: Uuid, + unallocated_account: Uuid, + age_days: i64, + balance_minor: i64, +) { + let entry_id = Uuid::now_v7(); + let cash_account = Uuid::now_v7(); + let txn = raw.begin().await.unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, source_doc_type, \ + source_business_id, posted_at_utc, effective_at, origin, posted_by_actor_id, \ + correlation_id, rounding_evidence, created_seq) \ + VALUES ('{entry_id}', '{tenant}', '{tenant}', '202606', 'USD', 'PAYMENT_SETTLE', \ + 'pay-{entry_id}', now() - interval '{age_days} days', '2026-06-01', 'SYSTEM', '{tenant}', \ + '{entry_id}', '{{}}'::jsonb, 1)" + ))) + .await + .unwrap(); + // The CR UNALLOCATED leg (the grain the aged-unallocated scan reads): its + // owning entry's `posted_at_utc` is the age proxy (the resolved G-P5a oldest + // contributing line's post time). + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, account_class, \ + side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{}', '{entry_id}', '{tenant}', '202606', '{payer}', '{unallocated_account}', \ + 'UNALLOCATED', 'CR', 1000, 'USD', 2, 'RESOLVED')", + Uuid::now_v7() + ))) + .await + .unwrap(); + // … and its balancing DR CASH_CLEARING leg, so the entry is zero-sum at COMMIT. + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, account_class, \ + side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{}', '{entry_id}', '{tenant}', '202606', '{payer}', '{cash_account}', \ + 'CASH_CLEARING', 'DR', 1000, 'USD', 2, 'RESOLVED')", + Uuid::now_v7() + ))) + .await + .unwrap(); + txn.commit().await.unwrap(); + // The open cache grain (`balance_minor`), keyed (tenant, payer, currency). + raw.execute(pg(format!( + "INSERT INTO bss.ledger_unallocated_balance \ + (tenant_id, payer_tenant_id, account_id, currency, balance_minor, version) \ + VALUES ('{tenant}', '{payer}', '{unallocated_account}', 'USD', {balance_minor}, 0)" + ))) + .await + .unwrap(); +} + +/// Group C: an aged parked `unallocated_balance` grain (oldest contributing +/// `UNALLOCATED` line posted 3 days ago, cache still holding cash) is surfaced by +/// the per-tenant DB scan `aged_unallocated_grains` — the read path that +/// enumerates tenants from the cache, reads each tenant's journal entries + +/// `UNALLOCATED` lines + cache, builds the `entry_id -> posted_at_utc` age map, +/// and folds it through [`aged_grains`]. `run()` (which emits the `AGED_UNALLOCATED` +/// Warn alarm) then completes `Ok`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn aged_unallocated_grain_is_detected_and_run_completes() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let account = Uuid::now_v7(); + // A pool funded 3 days ago (older than the 1-day cutoff) still holding 700. + seed_unallocated_grain(&raw, tenant, payer, account, 3, 700).await; + + let job = AgedAlarmJob::new(provider, Arc::new(LedgerEventPublisher::noop())); + + // The per-tenant DB scan finds exactly the aged grain, with the age proxy read + // from the contributing entry's posted_at (≥ the 1-day threshold) and the + // parked balance carried through. + let aged = job + .aged_unallocated_grains(Utc::now(), ChronoDuration::seconds(86_400)) + .await + .expect("aged_unallocated_grains must succeed"); + assert_eq!(aged.len(), 1, "the 3-day-old parked pool is aged"); + assert_eq!(aged[0].tenant_id, tenant); + assert_eq!(aged[0].payer_tenant_id, payer); + assert_eq!(aged[0].account_id, account); + assert_eq!(aged[0].balance_minor, 700, "the parked balance is reported"); + assert_eq!(aged[0].currency, "USD"); + assert!( + aged[0].age_secs >= 86_400, + "age proxy is the oldest contributing line's post time (~3 days): {}", + aged[0].age_secs + ); + + // run() over the seeded ledger completes Ok (emits the AGED_UNALLOCATED Warn). + job.run() + .await + .expect("run must succeed with an aged unallocated grain"); +} + +/// Group C complement: a freshly-funded pool (oldest contributing line posted +/// just now) is NOT aged, and a long-parked-but-DRAINED grain (`balance_minor = +/// 0`) is NOT aged either — the `balance_minor > 0` gate. The cross-tenant +/// per-tenant scan returns nothing in both cases, and `run()` still completes +/// `Ok` (no alarm to emit). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn fresh_or_drained_unallocated_grain_is_not_aged() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + + // Tenant A: a pool funded JUST NOW (0 days), still holding cash ⇒ not aged + // (its oldest line is under the 1-day cutoff). + let tenant_fresh = Uuid::now_v7(); + seed_unallocated_grain(&raw, tenant_fresh, Uuid::now_v7(), Uuid::now_v7(), 0, 700).await; + // Tenant B: a pool funded 5 days ago but fully DRAINED to 0 ⇒ not aged (the + // `balance_minor > 0` gate — a drained pool needs no alarm even when old). + let tenant_drained = Uuid::now_v7(); + seed_unallocated_grain(&raw, tenant_drained, Uuid::now_v7(), Uuid::now_v7(), 5, 0).await; + + let job = AgedAlarmJob::new(provider, Arc::new(LedgerEventPublisher::noop())); + let aged = job + .aged_unallocated_grains(Utc::now(), ChronoDuration::seconds(86_400)) + .await + .expect("aged_unallocated_grains must succeed"); + assert!( + aged.is_empty(), + "neither a fresh pool nor a drained old pool is aged: {aged:?}" + ); + + job.run() + .await + .expect("run must succeed with no aged unallocated grain"); +} + +/// Seed one `tax_subbalance` cache grain directly (a self-contained grain — no +/// journal age computation, unlike the unallocated / refund-clearing scans, so a +/// plain autocommit insert suffices). `balance_minor < 0` makes it a negative +/// grain; `filing_period` (`YYYYMM`) places it in a closed-prior or in-window +/// period. Mirrors the `seed_unallocated_grain` direct-seed idiom. +async fn seed_tax_subbalance( + raw: &DatabaseConnection, + tenant: Uuid, + account: Uuid, + jurisdiction: &str, + filing_period: &str, + balance_minor: i64, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_tax_subbalance \ + (tenant_id, account_id, tax_jurisdiction, tax_filing_period, balance_minor, version) \ + VALUES ('{tenant}', '{account}', '{jurisdiction}', '{filing_period}', {balance_minor}, 0)" + ))) + .await + .unwrap(); +} + +/// Group 2 (Slice-3 Phase-3): the `NEGATIVE_TAX_SUBBALANCE` cross-tenant DB scan +/// `negative_tax_subbalances` — the read path that currently has only pure-function +/// coverage of its `is_beyond_filing_window` filter. A `tax_subbalance` that went +/// negative in a CLOSED (prior) filing period is beyond its window and is flagged; +/// an in-window (current-period) negative is a legitimate reversal and is NOT +/// flagged; a non-negative prior-period grain is not flagged either. `run()` (which +/// emits the `Critical` `NEGATIVE_TAX_SUBBALANCE` alarm per flagged grain) then +/// completes `Ok`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn negative_tax_subbalance_beyond_window_is_detected_and_run_completes() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + + let now = Utc::now(); + let current_period = format!("{:04}{:02}", now.year(), now.month()); + let tenant = Uuid::now_v7(); + let account = Uuid::now_v7(); + + // (a) A negative grain in a long-closed prior period (200001) ⇒ beyond the + // filing window ⇒ flagged (Revenue Assurance must reconcile). + seed_tax_subbalance(&raw, tenant, account, "US-CA", "200001", -250).await; + // (b) A negative grain in the CURRENT filing period ⇒ in-window legitimate + // reversal ⇒ NOT flagged. + seed_tax_subbalance(&raw, tenant, account, "US-NY", ¤t_period, -100).await; + // (c) A non-negative grain in a closed prior period ⇒ not negative ⇒ NOT flagged. + seed_tax_subbalance(&raw, tenant, account, "US-TX", "200001", 500).await; + + let job = AgedAlarmJob::new(provider, Arc::new(LedgerEventPublisher::noop())); + + let grains: Vec = job + .negative_tax_subbalances(now) + .await + .expect("negative_tax_subbalances must succeed"); + assert_eq!( + grains.len(), + 1, + "only the negative-beyond-window grain is flagged (in-window + non-negative skipped): {grains:?}" + ); + let g = &grains[0]; + assert_eq!(g.tenant_id, tenant); + assert_eq!(g.account_id, account); + assert_eq!(g.tax_jurisdiction, "US-CA"); + assert_eq!(g.tax_filing_period, "200001"); + assert_eq!(g.balance_minor, -250); + + // run() over the seeded ledger completes Ok (emits the Critical alarm). + job.run() + .await + .expect("run must succeed with a negative-beyond-window tax sub-balance"); +} diff --git a/gears/bss/ledger/ledger/src/infra/jobs/attribution_sweep.rs b/gears/bss/ledger/ledger/src/infra/jobs/attribution_sweep.rs new file mode 100644 index 000000000..0f8a9cbf0 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/attribution_sweep.rs @@ -0,0 +1,114 @@ +//! `AttributionSweepJob` — the `payer-attribution-drift` detective seam +//! (design §4.7). +//! +//! ## What the design wants +//! +//! Every posted journal line carries three tenant attributions: the +//! `payer_tenant_id` (who pays), the `seller_tenant_id` (who sells), and the +//! `resource_tenant_id` (which tenant the consumed resource belongs to). The +//! design's §4.7 `payer-attribution-drift` row is a **detective** control: a +//! background sweep resolves each entry's `resource_tenant_id` up to its +//! nearest `self_managed` ancestor (the tenant that actually owns the billing +//! relationship) and compares that resolved payer to the line's recorded +//! `payer_tenant_id`. A mismatch is *drift* — the books attribute the charge to +//! a payer that the tenant hierarchy no longer agrees with — and is reported as +//! a [`AlarmCategory::PayerAttributionDrift`] alarm. +//! +//! This is the detective complement to the preventive checks on the posting +//! hot path: posting validates payer *consistency within an entry* +//! (`MixedPayer`/`MissingPayer`), but it cannot, on its own, know whether the +//! recorded payer still matches the live tenant tree — that needs the resolver. +//! +//! ## Why this is a no-op seam in the MVP +//! +//! Resolving `resource_tenant_id` → nearest `self_managed` ancestor requires +//! the `TenantResolverClient` (the AuthZ/Tenant subtree resolver). That client +//! is **not wired into this gear** — the same hermetic-test constraint that +//! kept the Slice 2C subtree work resolver-free: the gear's tests must run +//! without standing up the AuthZ/Tenant service. Without the resolver there is +//! nothing to compare against, so [`AttributionSweepJob::run`] is a documented +//! no-op: it logs (at debug) that attribution-drift detection is inactive +//! pending the resolver, and returns `Ok(())`. +//! +//! ## The drop-in future +//! +//! When the resolver lands, `run()` gains: (1) a cross-tenant enumeration of +//! posted lines under the all-tenants system scope (mirroring +//! [`crate::infra::jobs::tieout`] / [`crate::infra::jobs::verifier`]); (2) per +//! line, `resolver.nearest_self_managed_ancestor(resource_tenant_id)` compared +//! to the recorded `payer_tenant_id`; and (3) on mismatch, a fire-and-forget +//! [`AlarmCategory::PayerAttributionDrift`] alarm via `self.publisher` +//! (severity/route come from [`crate::infra::events::alarm_catalog`]). A +//! `serve()` ticker is deliberately NOT wired here — it is added together with +//! the resolver, so the gear never schedules a job that can only no-op. + +use std::sync::Arc; + +use toolkit_db::{DBProvider, DbError}; + +use crate::infra::events::publisher::LedgerEventPublisher; + +/// Failure of an attribution sweep. Mirrors the chain Verifier's error shape +/// ([`crate::infra::jobs::verifier::VerifyError`]): raised ONLY on an +/// infrastructure fault (DB unreachable / read failure). Detected drift will be +/// reported via an alarm, never as `Err` — same as tie-out and the Verifier. +/// +/// In the MVP no-op seam no variant is constructed yet; it exists so the +/// resolver-wired future slots in without changing the public signature. +#[derive(Debug, thiserror::Error)] +pub enum SweepError { + /// Storage / connection failure (driver text bounded by the caller). + #[error("attribution-sweep db error: {0}")] + Db(String), +} + +/// The `payer-attribution-drift` detective sweep (§4.7). +/// +/// Holds the wiring the resolver-backed future needs — a database provider for +/// the cross-tenant line enumeration and the event publisher for the +/// out-of-band drift alarm — even though the MVP `run()` touches neither. +pub struct AttributionSweepJob { + /// Database provider for the (future) cross-tenant line enumeration. + db: DBProvider, + /// Publisher for the (future) out-of-band `PayerAttributionDrift` alarm. + publisher: Arc, +} + +impl AttributionSweepJob { + /// Build the job over one database provider and the event publisher. + #[must_use] + pub fn new(db: DBProvider, publisher: Arc) -> Self { + Self { db, publisher } + } + + /// Run one attribution-drift sweep. + /// + /// **MVP: documented no-op.** Resolving each entry's `resource_tenant_id` + /// to its nearest `self_managed` ancestor needs the `TenantResolverClient`, + /// which is not wired into this gear (hermetic-test constraint). With no + /// resolver there is nothing to compare, so this logs that detection is + /// inactive and returns `Ok(())`. The resolver wiring, the per-entry + /// `resource_tenant` → `payer_tenant` comparison, and the + /// [`crate::infra::events::payloads::AlarmCategory::PayerAttributionDrift`] + /// emission are the drop-in future (see the module docs). + /// + /// # Errors + /// Never `Err` in the MVP seam. The resolver-wired future returns + /// [`SweepError::Db`] on an infrastructure read failure; detected drift is + /// reported via an alarm, not as `Err`. + #[allow( + clippy::unused_async, + reason = "async kept to match the resolver-wired future + the other jobs' run() shape" + )] + pub async fn run(&self) -> Result<(), SweepError> { + // Touch the held wiring so it is not dead-code in the MVP seam; the + // resolver-wired future enumerates lines via `self.db` and alarms via + // `self.publisher`. + let _ = (&self.db, &self.publisher); + tracing::debug!( + "bss-ledger: attribution-drift sweep is inactive pending the AuthZ/Tenant resolver \ + (§4.7 payer-attribution-drift detective seam); no-op" + ); + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/jobs/period_open.rs b/gears/bss/ledger/ledger/src/infra/jobs/period_open.rs new file mode 100644 index 000000000..806a644d1 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/period_open.rs @@ -0,0 +1,109 @@ +//! `PeriodOpenJob` — fiscal-period-open automation. +//! +//! Ensures the current and next `period_id` (YYYYMM, +1 month — no +//! `chrono-tz`) exist with `status = OPEN` for every legal entity that has a +//! `fiscal_calendar` row. Idempotent insert. System-context / cross-tenant: +//! the calendar LIST is unscoped (`list_all_fiscal_calendars`), while each +//! period INSERT is scoped per-calendar by `insert_fiscal_period_if_absent_txn` +//! (`AccessScope::for_tenant(row.tenant_id)`). + +use sea_orm::DbErr; +use toolkit_db::{DBProvider, DbError}; +use tracing::warn; + +use crate::domain::model::FiscalPeriodRow; +use crate::domain::status::PERIOD_STATUS_OPEN; +use crate::infra::storage::repo::ReferenceRepo; + +/// Granularity literal honored by the MVP period-open job (monthly only). +const GRANULARITY_MONTH: &str = "MONTH"; + +/// Outcome of one period-open pass. +pub struct PeriodOpenReport { + /// Number of `fiscal_period` rows newly created across all calendars. + pub periods_created: u64, +} + +/// Ensures current + next fiscal periods exist for every legal entity with a +/// calendar. +pub struct PeriodOpenJob { + db: DBProvider, +} + +impl PeriodOpenJob { + /// Build the job over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// Ensure the current and next fiscal periods exist for every legal + /// entity with a calendar (idempotent). Only `MONTH` granularity is + /// honored in the MVP; any other granularity is logged and skipped. + /// + /// # Errors + /// Returns `Err` on an infrastructure failure (DB unreachable, list or + /// insert failure). + pub async fn run(&self) -> anyhow::Result { + let repo = ReferenceRepo::new(self.db.clone()); + let calendars = repo.list_all_fiscal_calendars().await?; + + let mut periods_created: u64 = 0; + for cal in calendars { + if cal.granularity != GRANULARITY_MONTH { + warn!( + tenant_id = %cal.tenant_id, + legal_entity_id = %cal.legal_entity_id, + granularity = %cal.granularity, + "bss-ledger: period-open skipping non-MONTH calendar (MVP supports MONTH only)" + ); + continue; + } + + let cur = crate::domain::period::period_id_for(chrono::Utc::now()); + let next = crate::domain::period::next_period_id(&cur); + // Flatten the current + (optional) next period ids. + let period_ids: Vec = [Some(cur), next].into_iter().flatten().collect(); + + // One transaction per calendar: insert each absent period and carry + // the created-count out of the closure on the COMMIT (`Ok`) path. + // The closure error type is fixed to `DbError`, so a `RepoError` is + // encoded as a `DbErr::Custom` and surfaced after the transaction. + let repo = repo.clone(); + let tenant_id = cal.tenant_id; + let legal_entity_id = cal.legal_entity_id; + let fiscal_tz = cal.fiscal_tz.clone(); + let created: u64 = self + .db + .transaction(move |txn| { + Box::pin(async move { + let mut created = 0_u64; + for period_id in period_ids { + let inserted = repo + .insert_fiscal_period_if_absent_txn( + txn, + FiscalPeriodRow { + tenant_id, + legal_entity_id, + period_id, + fiscal_tz: fiscal_tz.clone(), + status: PERIOD_STATUS_OPEN.to_owned(), + }, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + if inserted { + created += 1; + } + } + Ok::(created) + }) + }) + .await + .map_err(|e| anyhow::anyhow!("period-open insert: {e}"))?; + periods_created += created; + } + + Ok(PeriodOpenReport { periods_created }) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/jobs/queue_applier.rs b/gears/bss/ledger/ledger/src/infra/jobs/queue_applier.rs new file mode 100644 index 000000000..dedac273a --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/queue_applier.rs @@ -0,0 +1,374 @@ +//! `QueueApplierJob` — the periodic sweep that drains queued payment +//! allocations AND out-of-order chargeback phases (Slice 2b, Groups B–D). +//! +//! The drain-on-settle hook ([`crate::infra::payment::settle::SettlementService`]) +//! applies a tenant's allocation queue right after a settle commits, but a queued +//! allocation whose settlement arrived through some OTHER path (or whose +//! drain-on-settle swallowed an error) still needs a backstop. This job is that +//! backstop: each tick it finds due `QUEUED` rows ACROSS ALL TENANTS for both the +//! `PAYMENT_ALLOCATE` flow (allocation-before-settlement) and the `CHARGEBACK` +//! flow (an out-of-order `won`/`lost` whose `opened` has since landed), then +//! drains each tenant under its own scope. A fresh `opened` also drains the +//! CHARGEBACK flow inline (`ChargebackService::record_phase`, mirroring +//! drain-on-settle); this periodic sweep is the backstop. +//! +//! ## System-context / cross-tenant (mirrors `period_open`) +//! Finding due rows is an UNSCOPED, cross-tenant read +//! ([`PendingQueueRepo::list_all_due`], the sanctioned system-context pattern of +//! [`crate::infra::storage::repo::ReferenceRepo::list_all_fiscal_calendars`]). +//! Each APPLY is then scoped per-tenant by `AccessScope::for_tenant(tenant)` via +//! the [`QueueApplier`] (which claims under `SKIP LOCKED` in its own txn, so the +//! cross-tenant list is only a candidate feed — a row another applier grabbed +//! first is simply skipped). The gauge read is likewise unscoped +//! ([`PendingQueueRepo::count_all_by_status`]). + +use std::collections::BTreeSet; +use std::sync::Arc; + +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::payment::queue_apply::QueueApplier; +use crate::infra::storage::repo::PendingQueueRepo; + +/// The allocation deferred-apply queue flow this job sweeps — the +/// `PAYMENT_ALLOCATE` literal (kept in lockstep with +/// `SourceDocType::PaymentAllocate`; the same literal `AllocationService` stamps +/// on its dedup key + queue rows). +const FLOW_PAYMENT_ALLOCATE: &str = "PAYMENT_ALLOCATE"; + +/// The chargeback deferred-apply queue flow this job sweeps — the `CHARGEBACK` +/// literal (kept in lockstep with `SourceDocType::Chargeback`; the same literal +/// `ChargebackService` stamps on its dedup key + queue rows for an out-of-order +/// `won`/`lost`). +const FLOW_CHARGEBACK: &str = "CHARGEBACK"; + +/// The refund-of-refund claw-back deferred-apply queue flow this job sweeps — the +/// `REFUND_CLAWBACK` literal (the same literal `RefundHandler` stamps on its dedup +/// key + queue rows for an out-of-order / would-underflow claw-back, Group E). A +/// row here is a claw-back whose money-out decrement underflowed at intake; the +/// sweep re-tries it (its matching outbound refund stage-1 may have since landed) +/// and escalates one that aged out. Kept distinct from the engine `REFUND` +/// source-doc. +const FLOW_REFUND_CLAWBACK: &str = "REFUND_CLAWBACK"; + +/// The refund-quarantine deferred-de-quarantine queue flow this job sweeps — the +/// `REFUND_QUARANTINE` literal (the same literal `RefundHandler` stamps on a +/// refund-before-payment, Group G). A row here is a refund whose origin payment was +/// absent at intake; the sweep RE-VALIDATES it (the origin may have since landed) +/// and gates an over-D2 release to approval (never auto-posts). Distinct flow. +const FLOW_REFUND_QUARANTINE: &str = "REFUND_QUARANTINE"; + +/// The refund dispute-hold queue flow this job sweeps — the `REFUND_DISPUTE_HOLD` +/// literal (the same literal `RefundHandler` stamps on a refund whose origin payment +/// has an OPEN dispute, Z5-2 / design §5). A row here is a refund whose cash leg was +/// held while the dispute is sub judice; the sweep RE-READS the dispute (WON → +/// re-drive the gated post / open an approval over D2; LOST → cancel + escalate, the +/// chargeback already returned the money; still-OPEN → back off, aged out → cancel + +/// escalate). Distinct flow. +const FLOW_REFUND_DISPUTE_HOLD: &str = "REFUND_DISPUTE_HOLD"; + +/// `QUEUED` dedup/queue-row status — the rows this sweep targets (and gauges). +const STATUS_QUEUED: &str = "QUEUED"; + +/// Per-tenant cap on one sweep pass. A tenant with a backlog larger than this +/// drains the remainder on the next tick (the job is idempotent — already-applied +/// rows are terminal and never re-claimed). Generous relative to the +/// drain-on-settle cap so the sweep makes real progress on a backlog. +const SWEEP_PER_TENANT_CAP: u64 = 500; + +/// Upper bound on the cross-tenant candidate list read per tick — a ceiling on +/// how many distinct tenants one pass discovers, so a pathological queue can't +/// load an unbounded candidate set into memory. (Distinct tenants ≤ this.) +const SWEEP_DISCOVERY_LIMIT: u64 = 10_000; + +/// Outcome of one sweep pass (aggregated across every tenant drained). +#[derive(Debug, Default)] +pub struct QueueApplierReport { + /// Distinct tenants that had at least one due queued row this pass. + pub tenants_swept: u64, + /// Rows that posted + flipped `→APPLIED` this pass. + pub applied: u64, + /// Rows left `QUEUED` because the payment is not yet settled. + pub not_ready: u64, + /// Rows left `QUEUED` after an apply-time cap/precondition rejection. + pub blocked: u64, +} + +/// Periodic sweep that drains due queued allocations across all tenants. +pub struct QueueApplierJob { + db: DBProvider, + publisher: Arc, + metrics: Arc, + /// The dual-control engine (Group G de-quarantine): threaded into the + /// `QueueApplier` so an over-THEN-CURRENT-D2 refund de-quarantine routes to + /// approval (never auto-posts). `None` ⇒ the de-quarantine drain is un-gated. + approval: Option>, + /// Per-allocation touched-invoice cap (`payments` config), threaded into the + /// `QueueApplier` so the deferred-apply drain uses the SAME cap as the inline + /// path. Defaults to the allocate module's `MAX_INVOICES_PER_ALLOCATION`. + max_invoices: usize, +} + +impl QueueApplierJob { + /// Build the job over one database provider, the event publisher (threaded + /// into the posting engine the applier builds), and the metrics sink (for the + /// queue-depth gauge). Mirrors [`crate::infra::jobs::period_open::PeriodOpenJob::new`] + /// plus the publisher + metrics the apply path needs. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + Self { + db, + publisher, + metrics, + approval: None, + max_invoices: crate::infra::payment::allocate::MAX_INVOICES_PER_ALLOCATION, + } + } + + /// Override the per-allocation touched-invoice cap (from `payments` config), + /// threaded into the `QueueApplier`. Builder form; defaults to the allocate + /// module's `MAX_INVOICES_PER_ALLOCATION`. + #[must_use] + pub fn with_max_invoices_per_allocation(mut self, max_invoices: usize) -> Self { + self.max_invoices = max_invoices; + self + } + + /// Attach the dual-control engine (Group G): the de-quarantine drain then gates + /// an over-THEN-CURRENT-D2 refund release to approval. Builder form (defaults to + /// `None`). + #[must_use] + pub fn with_approval( + mut self, + approval: Arc, + ) -> Self { + self.approval = Some(approval); + self + } + + /// Drain due queued allocations across all tenants, then emit the queue-depth + /// gauge. A per-tenant drain error is isolated (logged, the pass continues) so + /// one flaky tenant doesn't abort the sweep — mirrors the tie-out job. + /// + /// # Errors + /// Returns `Err` only on an infrastructure failure reading the cross-tenant + /// candidate list (the pass cannot start); per-tenant drain faults are + /// swallowed within the pass. + pub async fn run(&self) -> anyhow::Result { + let repo = PendingQueueRepo::new(self.db.clone()); + let now = chrono::Utc::now(); + + // Cross-tenant candidate feed (UNSCOPED, system-context). Collapse to the + // distinct tenant set — the per-tenant `drain` re-claims under SKIP LOCKED + // (and caps at `SWEEP_PER_TENANT_CAP`), so we only need the tenant ids + // here, not the rows themselves. + let due = repo + .list_all_due(FLOW_PAYMENT_ALLOCATE, now, SWEEP_DISCOVERY_LIMIT) + .await?; + let tenants: BTreeSet = due.into_iter().map(|r| r.tenant_id).collect(); + + // System-context actor for the applied posts (the sweep is not a + // per-request actor; the entry header's actor is stamped from this ctx). + let ctx = SecurityContext::anonymous(); + let mut applier = QueueApplier::new( + self.db.clone(), + Arc::clone(&self.publisher), + Arc::clone(&self.metrics), + ) + .with_max_invoices_per_allocation(self.max_invoices); + // Gate the de-quarantine drain over the THEN-CURRENT D2 threshold (Group G). + if let Some(approval) = &self.approval { + applier = applier.with_approval(Arc::clone(approval)); + } + + let mut report = QueueApplierReport::default(); + let mut swept: BTreeSet = BTreeSet::new(); + for tenant in tenants { + let scope = AccessScope::for_tenant(tenant); + match applier + .drain(&ctx, &scope, tenant, SWEEP_PER_TENANT_CAP) + .await + { + Ok(drain) => { + swept.insert(tenant); + report.applied += drain.applied; + report.not_ready += drain.not_ready; + report.blocked += drain.blocked; + } + Err(e) => { + // Isolate per-tenant infra faults: log and continue. + tracing::error!( + tenant_id = %tenant, + error = %e, + "bss-ledger: queue-applier sweep failed for tenant; continuing" + ); + } + } + } + + // CHARGEBACK flow (out-of-order won/lost whose opened has since landed) — + // the same cross-tenant candidate feed + per-tenant scoped drain shape as + // the allocation flow. Aggregated into the same report counters; a tenant + // is counted once even if it had both flows queued (the `swept` set). + let cb_due = repo + .list_all_due(FLOW_CHARGEBACK, now, SWEEP_DISCOVERY_LIMIT) + .await?; + let cb_tenants: BTreeSet = cb_due.into_iter().map(|r| r.tenant_id).collect(); + for tenant in cb_tenants { + let scope = AccessScope::for_tenant(tenant); + match applier + .drain_chargeback(&ctx, &scope, tenant, SWEEP_PER_TENANT_CAP) + .await + { + Ok(drain) => { + swept.insert(tenant); + report.applied += drain.applied; + report.not_ready += drain.not_ready; + report.blocked += drain.blocked; + } + Err(e) => { + tracing::error!( + tenant_id = %tenant, + error = %e, + "bss-ledger: chargeback queue-applier sweep failed for tenant; continuing" + ); + } + } + } + + // REFUND_CLAWBACK flow (deferred refund-of-refund claw-backs whose matching + // outbound refund stage-1 may have since landed) — the same cross-tenant + // candidate feed + per-tenant scoped drain shape (Group E). A reconciled + // claw-back posts (APPLIED); one that aged out without reconciling is + // CANCELLED + escalated (the alarm fires inside the drain). Aggregated into + // the same report counters (a still-deferred / escalated row counts as + // neither applied nor not_ready — only `applied` is reflected here, mirroring + // how the report aggregates the other flows' applied totals). + let cb_clawback_due = repo + .list_all_due(FLOW_REFUND_CLAWBACK, now, SWEEP_DISCOVERY_LIMIT) + .await?; + let clawback_tenants: BTreeSet = + cb_clawback_due.into_iter().map(|r| r.tenant_id).collect(); + for tenant in clawback_tenants { + let scope = AccessScope::for_tenant(tenant); + match applier + .drain_clawback(&ctx, &scope, tenant, SWEEP_PER_TENANT_CAP) + .await + { + Ok(drain) => { + swept.insert(tenant); + report.applied += drain.applied; + } + Err(e) => { + tracing::error!( + tenant_id = %tenant, + error = %e, + "bss-ledger: claw-back queue-applier sweep failed for tenant; continuing" + ); + } + } + } + // REFUND_QUARANTINE flow (refund-before-payment whose origin may have since + // landed) — the de-quarantine sweep (Group G). Same cross-tenant feed + + // per-tenant scoped GATED drain shape. A released refund posts (or opens an + // approval if over the THEN-CURRENT D2 — never auto-posts); an origin that + // never lands ages out → CANCELLED + escalate. Aggregated `applied` reflects + // only the released-and-posted rows. + let q_due = repo + .list_all_due(FLOW_REFUND_QUARANTINE, now, SWEEP_DISCOVERY_LIMIT) + .await?; + let q_tenants: BTreeSet = q_due.into_iter().map(|r| r.tenant_id).collect(); + for tenant in q_tenants { + let scope = AccessScope::for_tenant(tenant); + match applier + .drain_quarantine(&ctx, &scope, tenant, SWEEP_PER_TENANT_CAP) + .await + { + Ok(drain) => { + swept.insert(tenant); + report.applied += drain.released; + } + Err(e) => { + tracing::error!( + tenant_id = %tenant, + error = %e, + "bss-ledger: refund-quarantine de-quarantine sweep failed for tenant; \ + continuing" + ); + } + } + } + // REFUND_DISPUTE_HOLD flow (refund whose origin payment has an OPEN dispute, + // whose dispute may have since resolved) — the dispute-hold drain (Z5-2 / + // design §5). Same cross-tenant feed + per-tenant scoped GATED drain shape. A + // WON dispute re-drives the gated post (or opens an approval if over the + // THEN-CURRENT D2 — never auto-posts); a LOST dispute cancels the hold (the + // chargeback already returned the money — posting would double-pay); a dispute + // that never resolves ages out → CANCELLED + escalate. Aggregated `applied` + // reflects only the released-and-posted rows. + let dh_due = repo + .list_all_due(FLOW_REFUND_DISPUTE_HOLD, now, SWEEP_DISCOVERY_LIMIT) + .await?; + let dh_tenants: BTreeSet = dh_due.into_iter().map(|r| r.tenant_id).collect(); + for tenant in dh_tenants { + let scope = AccessScope::for_tenant(tenant); + match applier + .drain_dispute_hold(&ctx, &scope, tenant, SWEEP_PER_TENANT_CAP) + .await + { + Ok(drain) => { + swept.insert(tenant); + report.applied += drain.released; + } + Err(e) => { + tracing::error!( + tenant_id = %tenant, + error = %e, + "bss-ledger: refund dispute-hold sweep failed for tenant; continuing" + ); + } + } + } + report.tenants_swept = u64::try_from(swept.len()).unwrap_or(u64::MAX); + + // Queue-depth gauge: the live cross-tenant backlog of still-`QUEUED` + // allocations AFTER this pass (a count failure is non-fatal — log + skip + // the emit; the sweep itself succeeded). The gauge tracks the allocation + // flow (the dispute-phase queue depth has no dedicated gauge in this phase). + match repo + .count_all_by_status(FLOW_PAYMENT_ALLOCATE, STATUS_QUEUED) + .await + { + Ok(depth) => self.metrics.allocation_queue_depth(depth), + Err(e) => tracing::warn!( + error = %e, + "bss-ledger: queue-applier failed to read queue depth for gauge" + ), + } + + // Refund-quarantine depth gauge (`ledger_refund_quarantine_depth`, §9 / + // Group G): the live cross-tenant backlog of still-`QUEUED` quarantined + // refunds AFTER this pass. Non-fatal on a count failure. + match repo + .count_all_by_status(FLOW_REFUND_QUARANTINE, STATUS_QUEUED) + .await + { + Ok(depth) => self.metrics.refund_quarantine_depth(depth), + Err(e) => tracing::warn!( + error = %e, + "bss-ledger: queue-applier failed to read refund-quarantine depth for gauge" + ), + } + + Ok(report) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/jobs/rate_sync.rs b/gears/bss/ledger/ledger/src/infra/jobs/rate_sync.rs new file mode 100644 index 000000000..e2157586f --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/rate_sync.rs @@ -0,0 +1,249 @@ +//! `RateSyncJob` — the periodic FX rate-sync ticker (Slice 5, Group C / design +//! §4.6). Each tick it pulls the latest published rates from the configured +//! [`RateProviderV1`] plugin in ONE round-trip and upserts them into the local +//! `ledger_fx_rate` store for every provisioned tenant, so the lock-time +//! [`RateSource`](crate::infra::fx::rate_source::RateSource) (a local read, +//! never a provider call on the posting path — C3/I-6) always resolves against +//! fresh local rows. +//! +//! ## Provider-agnostic + fail-safe +//! The provider is resolved from `ClientHub`; the default is +//! [`UnconfiguredRateProviderV1`](bss_ledger_sdk::UnconfiguredRateProviderV1) +//! (`provider_id() == "none"`), whose `fetch_latest` always errors. With no +//! adapter configured the job is INERT: it logs at debug and returns WITHOUT +//! alarming (a missing adapter is a deployment state, not a books defect). A +//! CONFIGURED provider that fails to fetch raises the `FX_SNAPSHOT_MISSING` alarm +//! (the local store goes stale → FX-needing posts block at lock time) and +//! returns — a provider outage NEVER aborts the gear. +//! +//! ## System-context / cross-tenant (mirrors `PeriodOpenJob` / `RecognitionRunJob`) +//! A provider publishes GLOBAL rates (EUR→USD is not per-tenant), but the +//! `ledger_fx_rate` store is tenant-scoped (RLS). So the job enumerates the +//! provisioned tenants from the fiscal-calendar feed (the same provisioned-LE +//! source [`PeriodOpenJob`](crate::infra::jobs::period_open) uses, under +//! [`AccessScope::allow_all`](toolkit_db::secure::AccessScope::allow_all)) and +//! upserts each fetched rate into every tenant's store via +//! [`FxRepo::upsert_rate`] (`AccessScope::for_tenant` inside the repo). A +//! per-tenant upsert fault is isolated (logged, the pass continues). The fetch +//! actor is the system-context [`SecurityContext::anonymous`] (not a per-request +//! caller). + +use std::collections::BTreeSet; +use std::sync::Arc; + +use bss_ledger_sdk::{ProviderRate, RateProviderError, RateProviderV1}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::infra::events::alarm_catalog; +use crate::infra::events::payloads::{AlarmCategory, LedgerInvariantAlarm}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::storage::repo::{FxRepo, NewFxRate, ReferenceRepo}; + +/// The `ledger_fx_rate.fallback_order` stamped on a synced row. The lock-time +/// `RateSource` recomputes the effective precedence from the configured +/// `provider_order` (the stored value is only a stable secondary sort key), so a +/// freshly-synced row carries the neutral `0`. +const SYNC_FALLBACK_ORDER: i32 = 0; + +/// Outcome of one rate-sync tick (returned for testability; the serve loop only +/// logs a tick error). +#[derive(Debug, Default, PartialEq, Eq)] +pub struct RateSyncReport { + /// `true` when the configured provider fetched this tick (also `true` for an + /// empty fetch); `false` when no adapter is configured or a configured + /// provider failed. + pub fetched: bool, + /// Rates returned by the provider this tick. + pub rates: u64, + /// Provisioned tenants the rates were fanned out to. + pub tenants: u64, + /// Tenants whose upsert raised an isolated fault (logged, skipped). + pub failed_tenants: u64, +} + +/// Periodic FX rate-sync job: pull the provider's latest rates and upsert them +/// into every provisioned tenant's local store. +pub struct RateSyncJob { + db: DBProvider, + provider: Arc, + repo: FxRepo, + publisher: Arc, +} + +impl RateSyncJob { + /// Build the job over one database provider (the provisioned-tenant + /// enumeration), the resolved rate-provider plugin (the default + /// `UnconfiguredRateProviderV1` when no adapter is registered), the FX repo + /// (the upsert target), and the event publisher (the `FX_SNAPSHOT_MISSING` + /// alarm on a configured-provider failure). + #[must_use] + pub fn new( + db: DBProvider, + provider: Arc, + repo: FxRepo, + publisher: Arc, + ) -> Self { + Self { + db, + provider, + repo, + publisher, + } + } + + /// Run one rate-sync pass: fetch the provider's latest rates and fan them out + /// to every provisioned tenant's `ledger_fx_rate` store. + /// + /// # Errors + /// Returns `Err` only on an infrastructure failure enumerating the + /// provisioned tenants (the fan-out cannot start). A provider fetch failure is + /// NOT an error (it alarms/logs and returns `Ok` with `fetched = false`); a + /// per-tenant upsert fault is isolated within the pass. + pub async fn run(&self) -> anyhow::Result { + let ctx = SecurityContext::anonymous(); + let request_id = Uuid::now_v7().to_string(); + + // ONE round-trip — `&[]` asks the adapter for its whole published table + // (it returns everything it has, omitting pairs it cannot serve). MUST NOT + // be called on the posting path (design C3/I-6) — only here. + let rates = match self.provider.fetch_latest(&ctx, &[], &request_id).await { + Ok(rates) => rates, + Err(e) => { + if self.provider.provider_id() == "none" { + // The fail-safe default (no adapter wired): a deployment state, + // not a books defect — log at debug, do NOT alarm. + tracing::debug!( + "bss-ledger: FX rate-sync skipped (no rate-provider adapter configured)" + ); + } else { + // A CONFIGURED provider failed: the local store goes stale, so + // FX-needing posts will block at lock time. Alarm + log; never + // abort the gear. + tracing::error!( + provider = self.provider.provider_id(), + error = %e, + "bss-ledger: FX rate-sync provider fetch failed; raising FX_SNAPSHOT_MISSING" + ); + self.emit_snapshot_missing(&ctx, self.provider.provider_id(), &e) + .await; + } + return Ok(RateSyncReport::default()); + } + }; + + if rates.is_empty() { + tracing::info!( + provider = self.provider.provider_id(), + "bss-ledger: FX rate-sync returned no rates this tick" + ); + return Ok(RateSyncReport { + fetched: true, + ..RateSyncReport::default() + }); + } + + // Fan the global rates out to every provisioned tenant's local store. + let tenants = self.provisioned_tenants().await?; + let provider_id = self.provider.provider_id(); + let mut report = RateSyncReport { + fetched: true, + rates: u64::try_from(rates.len()).unwrap_or(u64::MAX), + tenants: u64::try_from(tenants.len()).unwrap_or(u64::MAX), + failed_tenants: 0, + }; + for tenant in tenants { + if let Err(e) = self.upsert_tenant(tenant, &rates, provider_id).await { + report.failed_tenants += 1; + tracing::error!( + tenant_id = %tenant, + provider = provider_id, + error = %e, + "bss-ledger: FX rate upsert failed for tenant; continuing" + ); + } + } + if report.failed_tenants > 0 { + tracing::warn!( + failed_tenants = report.failed_tenants, + tenants = report.tenants, + "bss-ledger: FX rate-sync tick completed with per-tenant upsert failures" + ); + } + Ok(report) + } + + /// Enumerate the distinct provisioned tenants (the fiscal-calendar feed under + /// the system-context `allow_all`, deduped) — the rate fan-out target set. + /// + /// # Errors + /// Returns `Err` on an infrastructure failure reading the calendar feed. + async fn provisioned_tenants(&self) -> anyhow::Result> { + let repo = ReferenceRepo::new(self.db.clone()); + let calendars = repo + .list_all_fiscal_calendars() + .await + .map_err(|e| anyhow::anyhow!("rate-sync: enumerate provisioned tenants: {e}"))?; + Ok(calendars.into_iter().map(|c| c.tenant_id).collect()) + } + + /// Upsert every fetched rate into one tenant's `ledger_fx_rate` store under + /// the provider id. A whole tenant is one isolation unit (the caller logs + + /// continues on `Err`). + async fn upsert_tenant( + &self, + tenant: Uuid, + rates: &[ProviderRate], + provider_id: &str, + ) -> anyhow::Result<()> { + for rate in rates { + self.repo + .upsert_rate(&NewFxRate { + tenant_id: tenant, + base_currency: rate.base.clone(), + quote_currency: rate.quote.clone(), + provider: provider_id.to_owned(), + rate_micro: rate.rate_micro, + as_of: rate.as_of, + fallback_order: SYNC_FALLBACK_ORDER, + }) + .await + .map_err(|e| { + anyhow::anyhow!("upsert {}->{} ({provider_id}): {e}", rate.base, rate.quote) + })?; + } + Ok(()) + } + + /// Emit the system-scoped `FX_SNAPSHOT_MISSING` alarm on a configured-provider + /// fetch failure. The outage is GLOBAL (the provider, not a tenant), so the + /// alarm carries the nil tenant + an `fx-rate-sync` system scope — it signals + /// "no tenant's rates are being refreshed". Severity is taken from the §4.7 + /// catalog (`Critical`), like the posting service's `alarm_for`. + async fn emit_snapshot_missing( + &self, + ctx: &SecurityContext, + provider_id: &str, + err: &RateProviderError, + ) { + let category = AlarmCategory::FxSnapshotMissing; + let alarm = LedgerInvariantAlarm { + category, + severity: alarm_catalog::severity(category), + tenant_id: Uuid::nil(), + scope: "fx-rate-sync".to_owned(), + code: category.as_str().to_owned(), + detail: format!( + "FX rate-sync provider '{provider_id}' fetch failed: {err}; \ + local rate store not refreshed" + ), + affected: vec![], + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + } +} + +#[cfg(test)] +#[path = "rate_sync_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/jobs/rate_sync_tests.rs b/gears/bss/ledger/ledger/src/infra/jobs/rate_sync_tests.rs new file mode 100644 index 000000000..5867d4b0b --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/rate_sync_tests.rs @@ -0,0 +1,195 @@ +//! Tests for `RateSyncJob`. +//! +//! The fetch-handling branches (no adapter / configured-provider failure / empty +//! fetch) all return BEFORE the per-tenant fan-out touches the DB, so they run +//! against a bare in-memory SQLite provider + a `noop()` publisher. The +//! tenant-fan-out path reads `fiscal_calendar` and upserts `ledger_fx_rate`, so it +//! is a Docker-gated `#[ignore]` testcontainer test. +//! +//! Ignored Docker tests run with +//! `cargo test -p bss-ledger --lib 'infra::jobs::rate_sync::tests' -- --ignored`. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::doc_markdown +)] + +use async_trait::async_trait; +use bss_ledger_sdk::{CurrencyPair, UnconfiguredRateProviderV1}; +use chrono::Utc; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::{ConnectOpts, connect_db}; + +use super::*; +use crate::infra::storage::migrations::Migrator; + +/// A configurable fake `RateProviderV1`: a stable id + a fixed fetch outcome. +/// `id` is owned (not `&'static`) so `provider_id` returns a borrowed `&self.id` +/// genuinely tied to `&self` — matching how a real adapter holds its id. +struct FakeProvider { + id: String, + outcome: Outcome, +} + +enum Outcome { + Ok(Vec), + Fail, +} + +#[async_trait] +impl RateProviderV1 for FakeProvider { + fn provider_id(&self) -> &str { + &self.id + } + async fn fetch_latest( + &self, + _ctx: &SecurityContext, + _pairs: &[CurrencyPair], + _request_id: &str, + ) -> Result, RateProviderError> { + match &self.outcome { + Outcome::Ok(rates) => Ok(rates.clone()), + Outcome::Fail => Err(RateProviderError::Unreachable("fake outage".to_owned())), + } + } +} + +/// A `ProviderRate` literal (timestamp = now; staleness is the resolver's +/// concern, not the sync job's). +fn rate(base: &str, quote: &str, rate_micro: i64) -> ProviderRate { + ProviderRate { + base: base.to_owned(), + quote: quote.to_owned(), + rate_micro, + as_of: Utc::now(), + } +} + +/// A job over a bare in-memory SQLite provider (no migrations) + a `noop()` +/// publisher — enough for the fetch-handling branches, which return before the +/// per-tenant DB fan-out. +async fn job_no_db(provider: Arc) -> RateSyncJob { + let db = connect_db( + "sqlite:file:fx_rate_sync_unit?mode=memory&cache=shared", + ConnectOpts::default(), + ) + .await + .unwrap(); + let dbp = DBProvider::::new(db); + let repo = FxRepo::new(dbp.clone()); + let publisher = Arc::new(LedgerEventPublisher::noop()); + RateSyncJob::new(dbp, provider, repo, publisher) +} + +#[tokio::test] +async fn unconfigured_provider_is_inert_and_does_not_alarm() { + // The fail-safe default (`provider_id() == "none"`) fetch-errors; the job logs + // at debug and returns the default (unfetched) report WITHOUT alarming — a + // missing adapter is a deployment state, not a books defect. + let job = job_no_db(Arc::new(UnconfiguredRateProviderV1)).await; + let report = job.run().await.expect("an inert sync is Ok"); + assert_eq!(report, RateSyncReport::default()); + assert!(!report.fetched); +} + +#[tokio::test] +async fn configured_provider_failure_never_aborts() { + // A CONFIGURED provider that fails to fetch emits FX_SNAPSHOT_MISSING (here to + // the noop publisher) and returns Ok with `fetched = false` — a provider + // outage must never abort the gear. + let job = job_no_db(Arc::new(FakeProvider { + id: "ecb".to_owned(), + outcome: Outcome::Fail, + })) + .await; + let report = job.run().await.expect("a provider outage never aborts"); + assert!(!report.fetched); + assert_eq!(report.rates, 0); +} + +#[tokio::test] +async fn empty_fetch_is_fetched_with_no_fanout() { + // An empty (but successful) fetch is `fetched = true` with nothing to fan out; + // it returns before the per-tenant DB write. + let job = job_no_db(Arc::new(FakeProvider { + id: "ecb".to_owned(), + outcome: Outcome::Ok(vec![]), + })) + .await; + let report = job.run().await.expect("ok"); + assert!(report.fetched); + assert_eq!(report.rates, 0); + assert_eq!(report.tenants, 0); +} + +// --------------------------------------------------------------------------- +// Docker (testcontainers) — the per-tenant fan-out path. +// --------------------------------------------------------------------------- + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// A successful fetch fans every published rate out to EVERY provisioned tenant's +/// `ledger_fx_rate` store, stamped with the provider id, and reports the counts. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn fans_rates_out_to_every_provisioned_tenant() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let dbp = + DBProvider::::new(connect_db(&repo_url, ConnectOpts::default()).await.unwrap()); + + // Two provisioned tenants (each a fiscal_calendar row — the same provisioned-LE + // feed PeriodOpenJob enumerates). + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + for t in [tenant_a, tenant_b] { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_calendar + (tenant_id, legal_entity_id, fiscal_tz, granularity, fy_start_month) + VALUES ('{t}','{t}','UTC','MONTH',1)" + ))) + .await + .unwrap(); + } + + // A provider that publishes two pairs. + let provider = Arc::new(FakeProvider { + id: "ecb".to_owned(), + outcome: Outcome::Ok(vec![ + rate("EUR", "USD", 1_100_000), + rate("GBP", "USD", 1_250_000), + ]), + }); + let repo = FxRepo::new(dbp.clone()); + let publisher = Arc::new(LedgerEventPublisher::noop()); + let job = RateSyncJob::new(dbp.clone(), provider, repo.clone(), publisher); + + let report = job.run().await.expect("fan-out run is Ok"); + assert!(report.fetched); + assert_eq!(report.rates, 2); + assert_eq!(report.tenants, 2); + assert_eq!(report.failed_tenants, 0); + + // Each tenant's store now carries both pairs under the provider id. + for t in [tenant_a, tenant_b] { + let eur = repo.latest_rates(t, "EUR", "USD").await.unwrap(); + assert_eq!(eur.len(), 1, "EUR->USD upserted for {t}"); + assert_eq!(eur[0].provider, "ecb"); + assert_eq!(eur[0].rate_micro, 1_100_000); + let gbp = repo.latest_rates(t, "GBP", "USD").await.unwrap(); + assert_eq!(gbp.len(), 1, "GBP->USD upserted for {t}"); + assert_eq!(gbp[0].rate_micro, 1_250_000); + } +} diff --git a/gears/bss/ledger/ledger/src/infra/jobs/recognition_run.rs b/gears/bss/ledger/ledger/src/infra/jobs/recognition_run.rs new file mode 100644 index 000000000..51f50b12a --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/recognition_run.rs @@ -0,0 +1,169 @@ +//! `RecognitionRunJob` — the periodic ASC 606 S6 **release** ticker (Slice 4, +//! Group F2). Each tick it finds the tenants with due `PENDING`/`QUEUED` +//! recognition segments ACROSS ALL TENANTS and triggers one recognition run per +//! tenant **for the CURRENT period**, releasing that period's due segments +//! (the current period + any past-due catch-up; FUTURE segments wait until their +//! period is current — never recognized early, H1/Z6-1) through the +//! [`RecognitionRunService`] (the same orchestration the `POST +//! /recognition-runs` REST endpoint drives). +//! +//! The REST endpoint is the on-demand trigger; this job is the automatic +//! backstop so a due period's segments release promptly once the period opens +//! even with no operator call — exactly as the `QueueApplierJob` backstops the +//! drain-on-settle hook. Both are idempotent: a run releases each segment +//! at-most-once via the per-segment `RECOGNITION` idempotency gate (a re-run +//! re-credits nothing), so a redundant tick is harmless. +//! +//! ## System-context / cross-tenant (mirrors `QueueApplierJob` / `period_open`) +//! Finding due work is an UNSCOPED, cross-tenant read +//! ([`RecognitionRepo::list_due_tenant_periods`] under +//! [`AccessScope::allow_all`], the sanctioned system-context pattern). Each run +//! is then SCOPED per-tenant by [`AccessScope::for_tenant`] when the +//! [`RecognitionRunService`] posts — the cross-tenant list is only a candidate +//! feed. A per-`(tenant, period)` run error is isolated (logged, the pass +//! continues) so one flaky tenant doesn't starve the rest, exactly like the +//! peer jobs. The run actor is the system-context [`SecurityContext::anonymous`] +//! (the same anonymous actor the queue-applier sweep posts under — this is not a +//! per-request caller). + +use std::sync::Arc; + +use chrono::Utc; +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::recognition::run_service::RecognitionRunService; +use crate::infra::storage::repo::RecognitionRepo; + +/// Upper bound on the cross-tenant due-segment candidate scan per tick — a +/// ceiling on how many `PENDING` segment rows one pass loads to derive the +/// distinct `(tenant, period)` work set (mirrors the sweep job's +/// `SWEEP_DISCOVERY_LIMIT`). A backlog beyond this drains over subsequent ticks +/// (each run is idempotent), so a pathological queue can't load an unbounded +/// candidate set into memory. +const DISCOVERY_LIMIT: u64 = 10_000; + +/// Outcome of one recognition-run tick (aggregated across every `(tenant, +/// period)` triggered). +#[derive(Debug, Default, PartialEq, Eq)] +pub struct RecognitionRunReport { + /// Distinct tenants with due work in/before the current period that were run + /// this tick (each run targets the current period). + pub pairs: u64, + /// Pairs whose run completed (ran-in-order or queued-out-of-order). + pub triggered: u64, + /// Pairs whose run raised an error (isolated; logged + skipped). + pub failed: u64, +} + +/// Periodic recognition-run job over every tenant/period with due `PENDING` +/// recognition segments. +pub struct RecognitionRunJob { + db: DBProvider, + publisher: Arc, + metrics: Arc, +} + +impl RecognitionRunJob { + /// Build the job over one database provider, the event publisher (threaded + /// into the run-service's posting engine + the recognition alarms), and the + /// metrics sink (the §9 recognition metrics). Mirrors + /// [`crate::infra::jobs::queue_applier::QueueApplierJob::new`]. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + Self { + db, + publisher, + metrics, + } + } + + /// Trigger a recognition run for every `(tenant, period)` with due `PENDING` + /// work. A per-pair run error is isolated (logged, the pass continues) so one + /// flaky tenant doesn't abort the tick — mirrors the queue-applier / tie-out + /// jobs. + /// + /// # Errors + /// Returns `Err` only on an infrastructure failure reading the cross-tenant + /// due-work feed (the pass cannot start); per-pair run faults are swallowed + /// within the pass. + pub async fn run(&self) -> anyhow::Result { + let repo = RecognitionRepo::new(self.db.clone()); + let pairs = repo + .list_due_tenant_periods(DISCOVERY_LIMIT) + .await + .map_err(|e| anyhow::anyhow!("recognition-run: enumerate due tenant/periods: {e}"))?; + + // One run-service for the whole pass (same db/publisher/metrics clones as + // the REST surface's in-process client builds). + let svc = RecognitionRunService::new( + self.db.clone(), + Arc::clone(&self.publisher), + Arc::clone(&self.metrics), + ); + // System-context actor for the released posts (the tick is not a + // per-request actor; the entry header's actor is stamped from this ctx). + let ctx = SecurityContext::anonymous(); + + // The CURRENT fiscal period (wall-clock month). A run TARGETS this period: + // `run_period` releases the due segments (`period_id <= current` — the + // current period + any past-due catch-up via the E-2 missed-close + // reassignment) and naturally EXCLUDES future segments. Triggering a run per + // distinct pending period instead (incl. the NEXT period `PeriodOpenJob` + // pre-opens) would recognize next month's segment THIS month — an ASC 606 + // early-recognition break (H1/Z6-1). A tenant whose only due work is a + // future period has nothing to release yet and is skipped this tick. + let current = crate::domain::period::period_id_for(Utc::now()); + let tenants: std::collections::BTreeSet = pairs + .iter() + .filter(|(_, period_id)| period_id.as_str() <= current.as_str()) + .map(|(tenant, _)| *tenant) + .collect(); + + let tenant_count = u64::try_from(tenants.len()).unwrap_or(u64::MAX); + let mut report = RecognitionRunReport::default(); + for tenant in tenants { + // Per-tenant scope: the run posts into the seller's own ledger (the + // cross-tenant feed above is only a candidate set), targeting the + // CURRENT period. A fresh `run_id` is minted per tick (None) — the + // ticker is the backstop, not an idempotency-keyed retry; per-segment + // at-most-once makes a re-run safe regardless. + let scope = AccessScope::for_tenant(tenant); + match svc.trigger(&ctx, &scope, tenant, ¤t, None).await { + Ok(_) => report.triggered += 1, + Err(e) => { + // Isolate per-tenant faults: log and continue so one flaky + // tenant doesn't starve the rest of the tick. + report.failed += 1; + tracing::error!( + tenant_id = %tenant, + period_id = %current, + error = %e, + "bss-ledger: recognition-run trigger failed for tenant; continuing" + ); + } + } + } + report.pairs = tenant_count; + if report.failed > 0 { + tracing::warn!( + failed = report.failed, + pairs = report.pairs, + "bss-ledger: recognition-run tick completed with per-pair failures" + ); + } + Ok(report) + } +} + +#[cfg(test)] +#[path = "recognition_run_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/jobs/recognition_run_tests.rs b/gears/bss/ledger/ledger/src/infra/jobs/recognition_run_tests.rs new file mode 100644 index 000000000..7c555a658 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/recognition_run_tests.rs @@ -0,0 +1,31 @@ +//! Tests for `RecognitionRunJob` — the periodic S6 release ticker (Group F2). +//! +//! The load-bearing coverage (cross-tenant due-work enumeration → per-pair run → +//! at-most-once release, and the drain of a QUEUED out-of-order segment by a +//! later tick) is in the Docker-gated integration test +//! `tests/postgres_recognition_run.rs`, which boots Postgres and drives the real +//! `RecognitionRunService`. These plain unit tests pin the `RecognitionRunReport` +//! accounting shape (the per-tick tally the ticker logs) without a database. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use super::RecognitionRunReport; + +#[test] +fn report_default_is_all_zero() { + let report = RecognitionRunReport::default(); + assert_eq!(report.pairs, 0); + assert_eq!(report.triggered, 0); + assert_eq!(report.failed, 0); +} + +#[test] +fn report_counts_are_independent() { + // triggered + failed partition the pairs that had work; a tick with one + // success and one failure tallies both legs against two pairs. + let report = RecognitionRunReport { + pairs: 2, + triggered: 1, + failed: 1, + }; + assert_eq!(report.pairs, report.triggered + report.failed); +} diff --git a/gears/bss/ledger/ledger/src/infra/jobs/revaluation_run.rs b/gears/bss/ledger/ledger/src/infra/jobs/revaluation_run.rs new file mode 100644 index 000000000..4877c5328 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/revaluation_run.rs @@ -0,0 +1,240 @@ +//! `RevaluationRunJob` — the periodic Mode-B unrealized-revaluation ticker +//! (Slice 5 Phase 3, design §4.5 / plan I1). Each tick, for every provisioned +//! tenant, it: +//! +//! - **forward-revalues the current period at period end** — only on a tick that +//! falls on the last UTC day of the period (so the resolved rate is the +//! period-end rate and the period is still OPEN to post into). A mid-period +//! tick does NOT forward-revalue (it would freeze a mid-period rate); the +//! explicit `POST /fx/revaluation-runs` (I2) is the operator/ERP trigger for +//! precise control / a missed period-end. +//! - **reverses the previous period every tick** — `reverse_period` is idempotent +//! and self-deferring (it no-ops until that period has CLOSED and a later OPEN +//! period exists), so running it every tick across the whole next month is a +//! robust catch-up with a wide window (unlike the one-day forward window). +//! +//! ## Provider-agnostic + fail-safe (mirrors `RateSyncJob` / `PeriodOpenJob`) +//! Disabled (Mode A / unset `revaluation_enabled`) ⇒ the tick is a no-op. The +//! provisioned tenants come from the fiscal-calendar feed under the system-context +//! `allow_all`; a per-tenant failure (e.g. no period-end rate) is isolated +//! (logged, the pass continues) and NEVER aborts the gear. The actor is the +//! system-context [`SecurityContext::anonymous`]. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Instant; + +use chrono::{Duration, Utc}; +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::config::FxConfig; +use crate::domain::fx::revaluation_mode::RevaluationMode; +use crate::domain::period::{period_id_for, previous_period_id}; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::fx::revaluation_run::UnrealizedRevaluationRun; +use crate::infra::storage::repo::{FxRevaluationModeRepo, FxRevaluationRunRepo, ReferenceRepo}; + +/// Outcome of one revaluation tick (returned for testability; the serve loop only +/// logs a tick error). +#[derive(Debug, Default, PartialEq, Eq)] +pub struct RevaluationRunReport { + /// `false` when Mode-B is disabled (the tick was a no-op). + pub enabled: bool, + /// `true` when this tick fell on a period-end day (forward revaluation ran). + pub forward_attempted: bool, + /// Provisioned tenants processed this tick. + pub tenants: u64, + /// Tenants whose forward/reversal raised an isolated fault (logged, skipped). + pub failed_tenants: u64, +} + +/// Periodic Mode-B unrealized-revaluation job: forward-revalue at period end + +/// reverse the previous period every tick. +pub struct RevaluationRunJob { + db: DBProvider, + publisher: Arc, + metrics: Arc, + fx: FxConfig, + /// Per-tenant FX revaluation mode (VHP-1986): resolves Mode A/B for each + /// provisioned tenant each tick (an explicit row wins; else the fleet default + /// from `fx.revaluation_enabled`). + mode_repo: FxRevaluationModeRepo, +} + +impl RevaluationRunJob { + /// Build the job over one database provider (the provisioned-tenant + /// enumeration + the runner), the event publisher (threaded into the posting + /// engine), the metrics sink (the run-pass duration histogram), and the FX + /// config (the Mode-B gate + rate source). + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + fx: FxConfig, + ) -> Self { + let mode_repo = FxRevaluationModeRepo::new(db.clone()); + Self { + db, + publisher, + metrics, + fx, + mode_repo, + } + } + + /// Run one revaluation pass across every provisioned tenant. + /// + /// # Errors + /// Returns `Err` only on an infrastructure failure enumerating the + /// provisioned tenants (the pass cannot start). A per-tenant forward/reversal + /// fault is isolated within the pass. + pub async fn run(&self) -> anyhow::Result { + // VHP-1986: no global early-return — the Mode A/B decision is per-tenant + // (resolved in the loop below). The global `fx.revaluation_enabled` is only + // the fleet default for tenants without an explicit per-tenant mode row. + let started = Instant::now(); + let tenants = self.provisioned_tenants().await?; + let runner = UnrealizedRevaluationRun::new( + self.db.clone(), + Arc::clone(&self.publisher), + self.fx.clone(), + ) + .with_metrics(Arc::clone(&self.metrics)); + let ctx = SecurityContext::anonymous(); + + // Period-end detection: a tick is a period-end tick when tomorrow falls in + // a different period (the last UTC day of the current period). Plain UTC + // month arithmetic (decision 1). + let now = Utc::now(); + let today = period_id_for(now); + let is_period_end = period_id_for(now + Duration::days(1)) != today; + let prev = previous_period_id(&today); + + let mut report = RevaluationRunReport { + // The fleet default (VHP-1986): `fx.revaluation_enabled` is the default + // for tenants WITHOUT an explicit mode row, NOT a hard global gate. + enabled: self.fx.revaluation_enabled, + forward_attempted: is_period_end, + tenants: u64::try_from(tenants.len()).unwrap_or(u64::MAX), + failed_tenants: 0, + }; + for tenant in tenants { + let scope = AccessScope::for_tenant(tenant); + // VHP-1986 per-tenant Mode A/B: an explicit row wins; else the global + // `fx.revaluation_enabled` is the fleet default (on→ModeB, off→ModeA). + let mode = match self + .mode_repo + .read_effective_mode(&scope, tenant, now) + .await + { + Ok(stored) => { + stored.unwrap_or(RevaluationMode::fleet_default(self.fx.revaluation_enabled)) + } + Err(e) => { + report.failed_tenants += 1; + tracing::error!( + tenant_id = %tenant, + error = %e, + "bss-ledger: revaluation mode read failed for tenant; skipping" + ); + continue; + } + }; + if let Err(e) = self + .process_tenant( + &runner, + &ctx, + &scope, + tenant, + &today, + prev.as_deref(), + is_period_end, + mode.revalues(), + ) + .await + { + report.failed_tenants += 1; + tracing::error!( + tenant_id = %tenant, + error = %e, + "bss-ledger: revaluation tick failed for tenant; continuing" + ); + } + } + if report.failed_tenants > 0 { + tracing::warn!( + failed_tenants = report.failed_tenants, + tenants = report.tenants, + "bss-ledger: revaluation tick completed with per-tenant failures" + ); + } + self.metrics + .fx_revaluation_duration(started.elapsed().as_secs_f64()); + Ok(report) + } + + /// Forward-revalue the current period (period-end ticks only) and reverse the + /// previous period (every tick). A whole tenant is one isolation unit. + #[allow(clippy::too_many_arguments)] + async fn process_tenant( + &self, + runner: &UnrealizedRevaluationRun, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + today: &str, + prev: Option<&str>, + is_period_end: bool, + revalue: bool, + ) -> anyhow::Result<()> { + // Forward revaluation runs only for a current Mode-B tenant (VHP-1986). + if is_period_end && revalue { + runner + .run_period(ctx, scope, tenant, today, true) + .await + .map_err(|e| anyhow::anyhow!("forward revaluation {today}: {e}"))?; + // C3: record the period-end revaluation COMPLETE so the period-close + // gate can REQUIRE it (a failed/lagged run leaves no marker → close + // blocks + alarms, instead of certifying a period whose missing + // FX_REVALUATION the closed-period guard makes unpostable forever). The + // run's posts already committed; this is an out-of-band idempotent + // upsert on a scoped connection, so a retried tick refreshes it. + let conn = self + .db + .conn() + .map_err(|e| anyhow::anyhow!("revaluation marker conn: {e}"))?; + FxRevaluationRunRepo::mark_complete(&conn, scope, tenant, today) + .await + .map_err(|e| anyhow::anyhow!("mark revaluation complete {today}: {e}"))?; + } + // Reversal is always attempted (idempotent + self-deferring): it reverses a + // prior Mode-B period even if the tenant has since switched to Mode A, and + // no-ops (NothingToReverse) for a tenant that never revalued. + if let Some(prev) = prev { + runner + .reverse_period(ctx, scope, tenant, prev, true) + .await + .map_err(|e| anyhow::anyhow!("reversal {prev}: {e}"))?; + } + Ok(()) + } + + /// Enumerate the distinct provisioned tenants (the fiscal-calendar feed under + /// the system-context `allow_all`, deduped). + /// + /// # Errors + /// Returns `Err` on an infrastructure failure reading the calendar feed. + async fn provisioned_tenants(&self) -> anyhow::Result> { + let repo = ReferenceRepo::new(self.db.clone()); + let calendars = repo + .list_all_fiscal_calendars() + .await + .map_err(|e| anyhow::anyhow!("revaluation: enumerate provisioned tenants: {e}"))?; + Ok(calendars.into_iter().map(|c| c.tenant_id).collect()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/jobs/tieout.rs b/gears/bss/ledger/ledger/src/infra/jobs/tieout.rs new file mode 100644 index 000000000..9df634d7c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/tieout.rs @@ -0,0 +1,1925 @@ +//! `TieOutJob` — daily self-reconciliation of the ledger. +//! +//! Recomputes `account_balance` (all-grain) from the posted journal lines and +//! compares against the cache, runs an entry-balance backstop independent of +//! the commit trigger, re-checks the no-negative guarded set, and counts +//! PENDING-mapped lines — raising `billing.ledger.invariant.alarm` on any +//! defect. System-context / cross-tenant. +//! +//! **Architecture:** the gear has no raw-SQL / unscoped / DB-side-aggregate +//! access (`DbConn` wraps a `pub(crate)` connection, `SecureSelect` exposes no +//! `GROUP BY`/`SUM`). Every aggregation here is therefore done IN MEMORY. To +//! keep peak memory bounded regardless of tenant history, the high-cardinality +//! `journal_line` / `journal_entry` sets are read in keyset pages (ordered by +//! the row's unique id, see `TIE_OUT_PAGE_SIZE`) and folded incrementally into +//! per-grain accumulators rather than materialized whole; the working set is +//! one page plus the grain-cardinality-bounded maps. The all-tenants +//! enumeration uses [`AccessScope::allow_all`] (the sanctioned all-tenants +//! system scope, same as AM's reaper/lease paths); per-tenant reads use +//! [`AccessScope::for_tenant`]. +//! +//! **Scope:** tie-out is **per-tenant, all-time** — the running +//! `account_balance` cache is not period-scoped, so the recompute sums *all* of +//! a tenant's lines (paginated). Period-pruning (only re-summing +//! recently-touched periods) is a further optimization, deferred. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use bss_ledger_sdk::AccountClass; +use sea_orm::{ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{AccessScope, DBRunner, SecureEntityExt}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::status::{AR_STATUS_DISPUTED, PERIOD_STATUS_CLOSED, PERIOD_STATUS_OPEN}; +use crate::infra::events::payloads::{ + AffectedItem, AlarmCategory, AlarmSeverity, LedgerInvariantAlarm, +}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::storage::entity::verified_balance::{ + GRAIN_ACCOUNT, GRAIN_AR_INVOICE, GRAIN_AR_INVOICE_DISPUTED, GRAIN_AR_PAYER, + GRAIN_REUSABLE_CREDIT, GRAIN_TAX, GRAIN_UNALLOCATED, +}; +use crate::infra::storage::entity::{ + account_balance, ar_invoice_balance, ar_payer_balance, fiscal_period, journal_entry, + journal_line, payment_allocation, payment_settlement, reusable_credit_subbalance, + tax_subbalance, tenant_account, unallocated_balance, +}; +use crate::infra::storage::repo::{BaselineRow, VerifiedBalanceRepo}; + +/// Debit side code (matches `bss_ledger_sdk::Side::Debit`). +const SIDE_DEBIT: &str = "DR"; +/// Credit side code (matches `bss_ledger_sdk::Side::Credit`). +const SIDE_CREDIT: &str = "CR"; +/// PENDING mapping-status code (matches `bss_ledger_sdk::MappingStatus::Pending`). +const MAPPING_PENDING: &str = "PENDING"; +/// AR account-class code (matches `bss_ledger_sdk::AccountClass::Ar`). +const CLASS_AR: &str = "AR"; +/// Tax-payable account-class code (matches `bss_ledger_sdk::AccountClass::TaxPayable`). +const CLASS_TAX_PAYABLE: &str = "TAX_PAYABLE"; +/// Unallocated-pool account-class code (matches `bss_ledger_sdk::AccountClass::Unallocated`). +const CLASS_UNALLOCATED: &str = "UNALLOCATED"; +/// Reusable-credit account-class code (matches `bss_ledger_sdk::AccountClass::ReusableCredit`). +const CLASS_REUSABLE_CREDIT: &str = "REUSABLE_CREDIT"; +/// PSP-fee-expense account-class code (matches `bss_ledger_sdk::AccountClass::PspFeeExpense`). +const CLASS_PSP_FEE_EXPENSE: &str = "PSP_FEE_EXPENSE"; +/// `source_doc_type` of the settlement entry (matches `bss_ledger_sdk::SourceDocType::PaymentSettle`). +const DOC_PAYMENT_SETTLE: &str = "PAYMENT_SETTLE"; +/// `source_doc_type` of a settlement-return entry (matches +/// `bss_ledger_sdk::SourceDocType::SettlementReturn`). Its presence for a tenant +/// makes the journal-recomputed `settled_minor` AND `fee_minor` unsafe (a return +/// decrements both cached counters via a `psp_return_id`-keyed entry that carries +/// no `payment_id`, so neither decrement is journal→payment recoverable) — both +/// are then skipped for that tenant (see +/// [`recompute_payment_counter_variances`]). +const DOC_SETTLEMENT_RETURN: &str = "SETTLEMENT_RETURN"; + +/// Cap on the per-alarm `affected` list — bounds the event size on a wide +/// defect while still naming enough grains for an operator to act. +const MAX_AFFECTED: usize = 50; + +/// Page size for the full-fold `journal_line` / `journal_entry` scans. The +/// tie-out recompute is all-time per-tenant, so these tables are read in keyset +/// pages (ordered by the row's unique id) and folded incrementally rather than +/// materialized whole — peak memory is one page plus the grain-bounded +/// accumulators. Large enough to amortize round-trips, small enough to bound the +/// working set on a long-lived tenant. +const TIE_OUT_PAGE_SIZE: u64 = 5_000; + +/// A recomputed `account_balance` grain that disagrees with the cache (or has +/// no cache counterpart / a stray cache row). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AccountBalanceVariance { + /// Account whose balance grain diverged. + pub account_id: Uuid, + /// Currency of the grain. + pub currency: String, + /// Balance recomputed from the journal lines (`0` if the cache row has no + /// computed counterpart). + pub computed: i64, + /// Cached `account_balance.balance_minor` (`0` if no cache row exists). + pub cached: i64, +} + +/// A recomputed sub-grain (`ar_payer_balance` / `ar_invoice_balance` / +/// `tax_subbalance`) that disagrees with its cache (or has no cache counterpart +/// / a stray cache row). The same signed-fold rule as `account_balance` drives +/// `computed`; the recompute mirrors `BalanceProjector::derive_grains`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubGrainVariance { + /// Which sub-grain cache diverged + /// (`"ar_payer_balance"`/`"ar_invoice_balance"`/`"tax_subbalance"`). + pub grain: &'static str, + /// Human-readable grain key (ids only — no PII), for the alarm diagnostic. + pub key: String, + /// Balance recomputed from the journal lines (`0` if no computed + /// counterpart for a stray cache row). + pub computed: i64, + /// Cached `balance_minor` (`0` if no cache row exists for a computed grain). + pub cached: i64, +} + +/// A posted entry that fails the entry-balance backstop. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImbalancedEntry { + /// The entry id. + pub entry_id: Uuid, + /// Currency of the imbalanced group. + pub currency: String, + /// Net minor units (`sum(DR) - sum(CR)`); non-zero is a defect. + pub net_minor: i64, + /// Number of lines in the group. + pub line_count: u64, + /// Distinct `payer_tenant_id` count (`> 1` is a defect). + pub payer_count: u64, +} + +/// A guarded `account_balance` grain that has gone negative. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NegativeGrain { + /// Account whose balance is negative. + pub account_id: Uuid, + /// Currency of the grain. + pub currency: String, + /// The offending (negative) balance. + pub balance_minor: i64, +} + +/// A `payment_settlement` counter that disagrees with the value recomputed from +/// the truth (`payment_allocation` rows for `allocated_minor`; the +/// `PAYMENT_SETTLE` journal entry for `settled_minor` / `fee_minor`). Shares the +/// [`AlarmCategory::TieOutVariance`] alarm class with the balance variances (a +/// cache disagreeing with truth). `counter` names which counter diverged. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentCounterVariance { + /// The payment whose counter diverged (`payment_settlement.payment_id`). + pub payment_id: String, + /// Which counter (`"allocated_minor"` / `"settled_minor"` / `"fee_minor"`). + pub counter: &'static str, + /// Value recomputed from the truth (allocation rows / settle journal). + pub computed: i64, + /// Cached `payment_settlement` counter value. + pub cached: i64, +} + +/// Per-tenant tie-out result. Clean iff every defect vec is empty AND there are +/// no PENDING-mapped lines. +pub struct TieOutReport { + /// Tenant this report covers. + pub tenant_id: Uuid, + /// Count of posted `journal_line` rows the report tied out — the denominator + /// for the Slice 7 AR↔derived rounding tolerance (X4: ≤ N minor units per + /// 1,000 posted lines). + pub posted_line_count: u64, + /// `account_balance` cache divergences. + pub account_balance_variances: Vec, + /// Sub-grain cache divergences (`ar_payer_balance` / `ar_invoice_balance` / + /// `tax_subbalance`). + pub sub_grain_variances: Vec, + /// Entries failing the entry-balance backstop. + pub imbalanced_entries: Vec, + /// Guarded grains that went negative. + pub negative_grains: Vec, + /// `payment_settlement` counter divergences (`allocated_minor` / + /// `settled_minor` / `fee_minor`). + pub payment_counter_variances: Vec, + /// Count of PENDING-mapped lines (a soft defect — blocks a clean report). + pub pending_lines: u64, +} + +impl TieOutReport { + /// `true` when no defect of any class was found. + #[must_use] + pub fn is_clean(&self) -> bool { + self.account_balance_variances.is_empty() + && self.sub_grain_variances.is_empty() + && self.imbalanced_entries.is_empty() + && self.negative_grains.is_empty() + && self.payment_counter_variances.is_empty() + && self.pending_lines == 0 + } + + /// One-line count summary (no PII — counts and the tenant id only). + #[must_use] + pub fn summary(&self) -> String { + format!( + "tenant={} variances={} sub_grain_variances={} imbalanced_entries={} \ + negative_grains={} payment_counter_variances={} pending_lines={}", + self.tenant_id, + self.account_balance_variances.len(), + self.sub_grain_variances.len(), + self.imbalanced_entries.len(), + self.negative_grains.len(), + self.payment_counter_variances.len(), + self.pending_lines, + ) + } +} + +/// Daily tie-out job over every tenant with posted rows. +pub struct TieOutJob { + db: DBProvider, + publisher: Arc, +} + +impl TieOutJob { + /// Build the job over one database provider and the event publisher + /// (used out-of-band to emit invariant alarms on a separate connection). + #[must_use] + pub fn new(db: DBProvider, publisher: Arc) -> Self { + Self { db, publisher } + } + + /// Tie out a single tenant: recompute `account_balance` from the journal + /// lines and compare to the cache, run the entry-balance backstop, re-check + /// the no-negative guarded set, and count PENDING-mapped lines. All + /// aggregation is in memory (see the module docs). All-time, per-tenant. + /// + /// # Errors + /// Returns `Err` only on an infrastructure failure (DB unreachable / read + /// failure); tie-out *defects* are reported in the [`TieOutReport`], not as + /// `Err`. + pub async fn tie_out_tenant(&self, tenant_id: Uuid) -> anyhow::Result { + let conn = self.db.conn()?; + self.tie_out_on(&conn, tenant_id).await + } + + /// Tie out one tenant for a daily tick: the incremental path (baseline + open + /// fold, VHP-1843) when `full` is false AND the tenant has a baseline, else + /// the full all-time fold. Both yield a [`TieOutReport`] so the alarm path is + /// shared. The incremental report omits the full-only defect classes + /// (imbalanced entries / negative grains / PENDING lines) — those ride the + /// periodic full backstop. + /// + /// # Errors + /// Infrastructure / read failure. + async fn tie_out_for(&self, tenant_id: Uuid, full: bool) -> anyhow::Result { + if !full { + let conn = self.db.conn()?; + if let Some(inc) = self.tie_out_incremental(&conn, tenant_id).await? { + return Ok(inc.into_tie_out_report(tenant_id)); + } + } + self.tie_out_tenant(tenant_id).await + } + + /// Tie out a single tenant against the supplied executor. The daily job + /// passes a plain connection; period-close passes its `SERIALIZABLE` + /// transaction so these reads join close's snapshot — a concurrent post + /// then conflicts (SSI), forcing close to retry and re-tie-out instead of + /// certifying a period an in-flight entry is landing in. + pub async fn tie_out_on( + &self, + runner: &R, + tenant_id: Uuid, + ) -> anyhow::Result { + let scope = AccessScope::for_tenant(tenant_id); + + // Per-tenant secure scoped reads of the (bounded) cache / reference tables + // → `Vec`. The high-cardinality `journal_line` / `journal_entry` + // sets are NOT materialized here — they are folded page-by-page below (see + // `TIE_OUT_PAGE_SIZE`) so peak memory stays bounded regardless of tenant + // history. Aggregation is still in memory (SecureORM exposes no DB-side + // SUM/GROUP BY); pagination only bounds the working set. + let accounts = tenant_account::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(tenant_account::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read tenant_account: {e}"))?; + let balances = account_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(account_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read account_balance: {e}"))?; + let ar_payer_cache = ar_payer_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(ar_payer_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read ar_payer_balance: {e}"))?; + let ar_invoice_cache = ar_invoice_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(ar_invoice_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read ar_invoice_balance: {e}"))?; + let tax_cache = tax_subbalance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(tax_subbalance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read tax_subbalance: {e}"))?; + let unallocated_cache = unallocated_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(unallocated_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read unallocated_balance: {e}"))?; + let reusable_credit_cache = reusable_credit_subbalance::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all().add(reusable_credit_subbalance::Column::TenantId.eq(tenant_id)), + ) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read reusable_credit_subbalance: {e}"))?; + // Payment-counter cache inputs: the cached counters + the allocation rows + // (truth for `allocated_minor`). The PAYMENT_SETTLE headers that carry + // `source_business_id = payment_id` (and the SETTLEMENT_RETURN gate) are + // folded from the paginated `journal_entry` scan below. + let settlement_cache = payment_settlement::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(payment_settlement::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read payment_settlement: {e}"))?; + let allocations = payment_allocation::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(payment_allocation::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read payment_allocation: {e}"))?; + + // account_id -> normal_side ("DR"/"CR"). + let normal_side_map: HashMap = accounts + .iter() + .map(|a| (a.account_id, a.normal_side.clone())) + .collect(); + // (account_id, currency) -> cached balance row. + let cache_map: HashMap<(Uuid, String), &account_balance::Model> = balances + .iter() + .map(|b| ((b.account_id, b.currency.clone()), b)) + .collect(); + + // Fold `journal_entry` page-by-page into the PAYMENT_SETTLE index + // (entry_id → payment_id) + the tenant-wide SETTLEMENT_RETURN flag. Only + // the (settle-entry-bounded) index is retained; keyset by the unique + // `entry_id`. + let mut settle_payment_by_entry: HashMap = HashMap::new(); + let mut has_settlement_return = false; + let mut entry_cursor: Option = None; + loop { + let mut cond = Condition::all().add(journal_entry::Column::TenantId.eq(tenant_id)); + if let Some(after) = entry_cursor { + cond = cond.add(journal_entry::Column::EntryId.gt(after)); + } + let page = journal_entry::Entity::find() + .secure() + .scope_with(&scope) + .filter(cond) + .order_by(journal_entry::Column::EntryId, sea_orm::Order::Asc) + .limit(TIE_OUT_PAGE_SIZE) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read journal_entry: {e}"))?; + let Some(last) = page.last() else { break }; + entry_cursor = Some(last.entry_id); + let fetched = u64::try_from(page.len()).unwrap_or(u64::MAX); + let (page_index, page_has_return) = settle_index(&page); + settle_payment_by_entry.extend(page_index); + has_settlement_return = has_settlement_return || page_has_return; + if fetched < TIE_OUT_PAGE_SIZE { + break; + } + } + + // Fold `journal_line` page-by-page into every accumulator in a single + // pass, keyset by the unique `line_id`. Peak memory is one page of lines + // plus the grain-cardinality-bounded accumulator maps — never the whole + // all-time line set. + let mut account_balance = AccountBalanceAcc::default(); + let mut sub_grain = SubGrainAcc::default(); + let mut entry_balance = EntryBackstopAcc::default(); + let mut payment_counter = PaymentCounterAcc::default(); + let mut pending_lines: u64 = 0; + let mut posted_line_count: u64 = 0; + let mut line_cursor: Option = None; + loop { + let mut cond = Condition::all().add(journal_line::Column::TenantId.eq(tenant_id)); + if let Some(after) = line_cursor { + cond = cond.add(journal_line::Column::LineId.gt(after)); + } + let page = journal_line::Entity::find() + .secure() + .scope_with(&scope) + .filter(cond) + .order_by(journal_line::Column::LineId, sea_orm::Order::Asc) + .limit(TIE_OUT_PAGE_SIZE) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("tie-out: read journal_line: {e}"))?; + let Some(last) = page.last() else { break }; + line_cursor = Some(last.line_id); + let fetched = u64::try_from(page.len()).unwrap_or(u64::MAX); + account_balance.fold(&page, &normal_side_map); + sub_grain.fold(&page, &normal_side_map); + entry_balance.fold(&page); + payment_counter.fold(&page, &settle_payment_by_entry); + let page_pending = u64::try_from( + page.iter() + .filter(|l| l.mapping_status == MAPPING_PENDING) + .count(), + ) + .unwrap_or(u64::MAX); + pending_lines = pending_lines.saturating_add(page_pending); + posted_line_count = posted_line_count.saturating_add(fetched); + if fetched < TIE_OUT_PAGE_SIZE { + break; + } + } + + let account_balance_variances = account_balance.finalize(&cache_map, &balances); + let sub_grain_variances = sub_grain.finalize( + &ar_payer_cache, + &ar_invoice_cache, + &tax_cache, + &unallocated_cache, + &reusable_credit_cache, + ); + let payment_counter_variances = + payment_counter.finalize(&allocations, &settlement_cache, has_settlement_return); + let imbalanced_entries = entry_balance.finalize(); + let negative_grains = negative_grains(&balances); + + Ok(TieOutReport { + tenant_id, + posted_line_count, + account_balance_variances, + sub_grain_variances, + imbalanced_entries, + negative_grains, + payment_counter_variances, + pending_lines, + }) + } + + /// Run a tie-out pass over every tenant that has ever posted, raising one + /// invariant alarm per defect class found. + /// + /// Tenants are enumerated from `journal_entry` — the source of truth — so a + /// tenant whose balance-cache rows are missing (the exact projector failure + /// tie-out exists to catch) is still reconciled. The set is unioned with + /// `account_balance` to also surface an orphan cache row with no journal. + /// Enumeration runs under the all-tenants [`AccessScope::allow_all`] system + /// scope; per-tenant reads use [`AccessScope::for_tenant`]. + /// + /// # Errors + /// Returns `Err` only if the up-front tenant *enumeration* fails (DB + /// unreachable). A per-tenant tie-out failure is logged and skipped — one + /// flaky tenant must not starve the rest — and tie-out *defects* are + /// reported via alarms, not as `Err`. + pub async fn run(&self) -> anyhow::Result<()> { + self.run_tick(true).await + } + + /// One tie-out tick over every tenant. `full` forces the all-time fold for + /// every tenant (the periodic drift backstop); otherwise each tenant takes the + /// incremental path (baseline + open fold) when it has a baseline, falling + /// back to the full fold when it does not (VHP-1843). + /// + /// # Errors + /// Returns `Err` only if the up-front tenant *enumeration* fails (DB + /// unreachable). A per-tenant tie-out failure is logged and skipped. + pub async fn run_tick(&self, full: bool) -> anyhow::Result<()> { + // Cross-tenant enumeration under the all-tenants system scope. Scoped to + // a block so the connection is released before the per-tenant loop + // (each `tie_out_tenant` opens its own connection). + let tenant_ids: HashSet = { + let conn = self.db.conn()?; + let entries = journal_entry::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("tie-out: enumerate tenants (journal): {e}"))?; + let balances = account_balance::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .all(&conn) + .await + .map_err(|e| anyhow::anyhow!("tie-out: enumerate tenants (cache): {e}"))?; + entries + .iter() + .map(|e| e.tenant_id) + .chain(balances.iter().map(|b| b.tenant_id)) + .collect() + }; + + let mut failed = 0_usize; + for tenant_id in tenant_ids { + let report = match self.tie_out_for(tenant_id, full).await { + Ok(report) => report, + Err(e) => { + // Isolate per-tenant infra failures: log and continue so a + // single flaky tenant doesn't abort the whole tick. + failed += 1; + tracing::error!( + tenant_id = %tenant_id, + error = %e, + "bss-ledger: tie-out failed for tenant; continuing" + ); + continue; + } + }; + if report.is_clean() { + continue; + } + + let summary = report.summary(); + tracing::warn!( + tenant_id = %tenant_id, + variances = report.account_balance_variances.len(), + sub_grain_variances = report.sub_grain_variances.len(), + imbalanced_entries = report.imbalanced_entries.len(), + negative_grains = report.negative_grains.len(), + payment_counter_variances = report.payment_counter_variances.len(), + pending_lines = report.pending_lines, + "bss-ledger: tie-out defects detected" + ); + + // One Critical alarm per non-empty defect class, each carrying the + // specific affected grains/entries (capped) so an operator sees what + // diverged and by how much. PENDING lines alone get no dedicated + // category (logged above + folded into every alarm's `detail`). + // Sub-grain + payment-counter variances share the `TieOutVariance` + // category with account-balance variances (each is a cache + // disagreeing with truth). + if !report.account_balance_variances.is_empty() + || !report.sub_grain_variances.is_empty() + || !report.payment_counter_variances.is_empty() + { + let affected = report + .account_balance_variances + .iter() + .map(|v| AffectedItem { + id: v.account_id.to_string(), + currency: v.currency.clone(), + expected_minor: v.computed, + actual_minor: v.cached, + }) + .chain(report.sub_grain_variances.iter().map(|v| AffectedItem { + id: v.key.clone(), + currency: String::new(), + expected_minor: v.computed, + actual_minor: v.cached, + })) + .chain( + report + .payment_counter_variances + .iter() + .map(|v| AffectedItem { + id: format!("payment={}/{}", v.payment_id, v.counter), + currency: String::new(), + expected_minor: v.computed, + actual_minor: v.cached, + }), + ) + .take(MAX_AFFECTED) + .collect(); + self.emit(tenant_id, AlarmCategory::TieOutVariance, &summary, affected) + .await; + } + if !report.imbalanced_entries.is_empty() { + let affected = report + .imbalanced_entries + .iter() + .map(|ie| AffectedItem { + id: ie.entry_id.to_string(), + currency: ie.currency.clone(), + expected_minor: 0, + actual_minor: ie.net_minor, + }) + .take(MAX_AFFECTED) + .collect(); + self.emit(tenant_id, AlarmCategory::EntryImbalance, &summary, affected) + .await; + } + if !report.negative_grains.is_empty() { + let affected = report + .negative_grains + .iter() + .map(|g| AffectedItem { + id: g.account_id.to_string(), + currency: g.currency.clone(), + expected_minor: 0, + actual_minor: g.balance_minor, + }) + .take(MAX_AFFECTED) + .collect(); + self.emit( + tenant_id, + AlarmCategory::NegativeBalanceViolation, + &summary, + affected, + ) + .await; + } + } + if failed > 0 { + tracing::warn!( + failed, + "bss-ledger: tie-out tick completed with per-tenant failures" + ); + } + Ok(()) + } + + /// Emit one fire-and-forget invariant alarm for `category` against `tenant`, + /// carrying the specific `affected` grains/entries that diverged. + async fn emit( + &self, + tenant_id: Uuid, + category: AlarmCategory, + detail: &str, + affected: Vec, + ) { + let code = category.as_str().to_owned(); + let alarm = LedgerInvariantAlarm { + category, + severity: AlarmSeverity::Critical, + tenant_id, + scope: format!("tenant:{tenant_id}"), + code, + detail: detail.to_owned(), + affected, + }; + self.publisher + .emit_invariant_alarm(&SecurityContext::anonymous(), alarm) + .await; + } +} + +/// A line's signed minor delta: `+amount_minor` when its side equals the +/// account's `normal_side`, else `-amount_minor` — the SAME rule the +/// `BalanceProjector` uses for every grain. `None` when the account has no +/// `normal_side` in the map (a defect handled by the `account_balance` pass). +fn signed(line: &journal_line::Model, normal_side_map: &HashMap) -> Option { + let normal_side = normal_side_map.get(&line.account_id)?; + Some(if &line.side == normal_side { + line.amount_minor + } else { + -line.amount_minor + }) +} + +/// Fold the lines into `(account_id, currency) -> signed sum` and diff against +/// the cache. A line whose account has no `normal_side` (itself a defect — a +/// posting against an unknown chart-of-accounts row) is excluded from the sum +/// and its grain is force-flagged as a variance so it always surfaces. Also +/// flags a cache grain with no computed counterpart and vice-versa. +/// Streaming accumulator for the `account_balance` recompute. Folds journal +/// lines into `(account_id, currency) -> signed sum` page-by-page so the full +/// line set is never materialized; the retained state is bounded by the number +/// of distinct grains, not the number of lines. +#[derive(Default)] +struct AccountBalanceAcc { + computed: HashMap<(Uuid, String), i64>, + /// Grains whose recompute is untrustworthy (missing normal_side) — always a + /// variance regardless of whether the cache happens to match. + forced: HashSet<(Uuid, String)>, +} + +impl AccountBalanceAcc { + /// Fold one page of lines into the running sums. + fn fold(&mut self, lines: &[journal_line::Model], normal_side_map: &HashMap) { + for line in lines { + let key = (line.account_id, line.currency.clone()); + let Some(delta) = signed(line, normal_side_map) else { + self.forced.insert(key); + continue; + }; + *self.computed.entry(key).or_insert(0) += delta; + } + } + + /// Diff the folded sums against the cache. A line whose account has no + /// `normal_side` (itself a defect — a posting against an unknown + /// chart-of-accounts row) is excluded from the sum and its grain is + /// force-flagged as a variance so it always surfaces. Also flags a cache + /// grain with no computed counterpart and vice-versa. + fn finalize( + self, + cache_map: &HashMap<(Uuid, String), &account_balance::Model>, + balances: &[account_balance::Model], + ) -> Vec { + let Self { computed, forced } = self; + let mut variances = Vec::new(); + + // Computed grains: compare to the cache (missing cache → cached = 0). + for (key, computed_sum) in &computed { + let cached = cache_map.get(key).map_or(0_i64, |m| m.balance_minor); + if *computed_sum != cached || forced.contains(key) { + variances.push(AccountBalanceVariance { + account_id: key.0, + currency: key.1.clone(), + computed: *computed_sum, + cached, + }); + } + } + + // Force-flagged grains that had NO surviving computed entry (every line + // for the grain was dropped for a missing normal_side). + for key in &forced { + if !computed.contains_key(key) { + let cached = cache_map.get(key).map_or(0_i64, |m| m.balance_minor); + variances.push(AccountBalanceVariance { + account_id: key.0, + currency: key.1.clone(), + computed: 0, + cached, + }); + } + } + + // Stray cache grains with no computed counterpart (computed treated as 0). + for b in balances { + let key = (b.account_id, b.currency.clone()); + if !computed.contains_key(&key) && !forced.contains(&key) && b.balance_minor != 0 { + variances.push(AccountBalanceVariance { + account_id: b.account_id, + currency: b.currency.clone(), + computed: 0, + cached: b.balance_minor, + }); + } + } + + variances + } +} + +/// Diff a computed `GrainKey -> signed sum` map against a cache keyed the same +/// way, emitting a [`SubGrainVariance`] for every key where the sums disagree +/// (a computed grain with no cache row → `cached = 0`; a stray non-zero cache +/// row with no computed counterpart → `computed = 0`). `grain` labels the +/// cache; `label` renders a key into the human (ids-only) diagnostic string. +fn diff_grain( + grain: &'static str, + computed: &HashMap, + cache: &HashMap, + label: F, +) -> Vec +where + K: std::hash::Hash + Eq, + F: Fn(&K) -> String, +{ + let mut variances = Vec::new(); + // Computed grains: compare to the cache (missing cache → cached = 0). + for (key, computed_sum) in computed { + let cached = cache.get(key).copied().unwrap_or(0); + if *computed_sum != cached { + variances.push(SubGrainVariance { + grain, + key: label(key), + computed: *computed_sum, + cached, + }); + } + } + // Stray non-zero cache grains with no computed counterpart (computed = 0). + for (key, cached) in cache { + if *cached != 0 && !computed.contains_key(key) { + variances.push(SubGrainVariance { + grain, + key: label(key), + computed: 0, + cached: *cached, + }); + } + } + variances +} + +/// Recompute the sub-grain caches in memory from the journal lines and diff each +/// against its cache, mirroring `BalanceProjector::derive_grains`: +/// - `ar_payer_balance` `(payer, account, currency)` from `AR` lines; +/// - `ar_invoice_balance.balance_minor` `(payer, account, invoice)` from `AR` +/// lines carrying an `invoice_id`; +/// - `ar_invoice_balance.disputed_minor` — a SECOND `(payer, account, invoice)` +/// map summing the signed delta of ONLY the `ar_status == "DISPUTED"` AR lines +/// (mirrors `projector.rs:788`: a DISPUTED leg routes its signed amount onto +/// `disputed_minor`; `DR +`, `CR −`); +/// - `tax_subbalance` `(account, jurisdiction, filing)` from `TAX_PAYABLE` +/// lines carrying BOTH tax dims; +/// - `unallocated_balance` `(payer, account, currency)` from `UNALLOCATED` lines; +/// - `reusable_credit_subbalance` `(payer, account, currency, +/// credit_grant_event_type)` from `REUSABLE_CREDIT` lines (a `None` +/// `credit_grant_event_type` keys as `""`, mirroring the projector's +/// `unwrap_or_default()`). +/// +/// All use the same signed-delta rule as `account_balance` (see [`signed`]). A +/// line whose account has no `normal_side` is skipped here — it is force-flagged +/// by the `account_balance` pass, so double-counting it would surface a spurious +/// sub-grain variance (the projector would also have aborted the post outright, +/// so clean books never carry such a line). +/// Streaming accumulator for the projector sub-grain caches. Folds journal +/// lines into per-grain signed sums page-by-page (retained state bounded by the +/// grain cardinality, not the line count), mirroring +/// `BalanceProjector::derive_grains` — see [`SubGrainAcc::finalize`] for the +/// grain→cache mapping. +#[derive(Default)] +struct SubGrainAcc { + // (payer_tenant_id, account_id, currency) -> signed sum. + ar_payer: HashMap<(Uuid, Uuid, String), i64>, + // (payer_tenant_id, account_id, invoice_id) -> signed sum (balance_minor). + ar_invoice: HashMap<(Uuid, Uuid, String), i64>, + // (payer_tenant_id, account_id, invoice_id) -> signed sum of DISPUTED legs only. + ar_invoice_disputed: HashMap<(Uuid, Uuid, String), i64>, + // (account_id, tax_jurisdiction, tax_filing_period) -> signed sum. + tax: HashMap<(Uuid, String, String), i64>, + // (payer_tenant_id, account_id, currency) -> signed sum. + unallocated: HashMap<(Uuid, Uuid, String), i64>, + // (payer_tenant_id, account_id, currency, credit_grant_event_type) -> signed sum. + reusable_credit: HashMap<(Uuid, Uuid, String, String), i64>, +} + +impl SubGrainAcc { + /// Fold one page of lines into the per-grain sums. + fn fold(&mut self, lines: &[journal_line::Model], normal_side_map: &HashMap) { + let Self { + ar_payer, + ar_invoice, + ar_invoice_disputed, + tax, + unallocated, + reusable_credit, + } = self; + for line in lines { + // Skip lines whose account lacks a normal_side (already force-flagged + // by the account_balance pass) — never contributes to any projector + // cache. + let Some(delta) = signed(line, normal_side_map) else { + continue; + }; + + if line.account_class == CLASS_AR { + *ar_payer + .entry((line.payer_tenant_id, line.account_id, line.currency.clone())) + .or_insert(0) += delta; + if let Some(invoice_id) = &line.invoice_id { + let key = (line.payer_tenant_id, line.account_id, invoice_id.clone()); + *ar_invoice.entry(key.clone()).or_insert(0) += delta; + // DISPUTED-tagged AR lines additionally route their signed delta + // onto `disputed_minor` (projector.rs:788). Untagged / ACTIVE AR + // lines leave it untouched. + if line.ar_status.as_deref() == Some(AR_STATUS_DISPUTED) { + *ar_invoice_disputed.entry(key).or_insert(0) += delta; + } + } + } + + if line.account_class == CLASS_TAX_PAYABLE + && let (Some(juris), Some(filing)) = + (&line.tax_jurisdiction, &line.tax_filing_period) + { + *tax.entry((line.account_id, juris.clone(), filing.clone())) + .or_insert(0) += delta; + } + + if line.account_class == CLASS_UNALLOCATED { + *unallocated + .entry((line.payer_tenant_id, line.account_id, line.currency.clone())) + .or_insert(0) += delta; + } + + if line.account_class == CLASS_REUSABLE_CREDIT { + // The credit-grant event type sub-divides the wallet (a PK dim); a + // missing one keys as `""` (the projector's `unwrap_or_default()`). + let event_type = line.credit_grant_event_type.clone().unwrap_or_default(); + *reusable_credit + .entry(( + line.payer_tenant_id, + line.account_id, + line.currency.clone(), + event_type, + )) + .or_insert(0) += delta; + } + } + } + + /// Diff the folded per-grain sums against each cache, emitting a + /// [`SubGrainVariance`] per disagreement (see [`diff_grain`]). + fn finalize( + self, + ar_payer_cache: &[ar_payer_balance::Model], + ar_invoice_cache: &[ar_invoice_balance::Model], + tax_cache: &[tax_subbalance::Model], + unallocated_cache: &[unallocated_balance::Model], + reusable_credit_cache: &[reusable_credit_subbalance::Model], + ) -> Vec { + let Self { + ar_payer, + ar_invoice, + ar_invoice_disputed, + tax, + unallocated, + reusable_credit, + } = self; + + // Cache rows keyed the same way as the computed maps. + let ar_payer_cache_map: HashMap<(Uuid, Uuid, String), i64> = ar_payer_cache + .iter() + .map(|r| { + ( + (r.payer_tenant_id, r.account_id, r.currency.clone()), + r.balance_minor, + ) + }) + .collect(); + let ar_invoice_cache_map: HashMap<(Uuid, Uuid, String), i64> = ar_invoice_cache + .iter() + .map(|r| { + ( + (r.payer_tenant_id, r.account_id, r.invoice_id.clone()), + r.balance_minor, + ) + }) + .collect(); + let ar_invoice_disputed_cache_map: HashMap<(Uuid, Uuid, String), i64> = ar_invoice_cache + .iter() + .map(|r| { + ( + (r.payer_tenant_id, r.account_id, r.invoice_id.clone()), + r.disputed_minor, + ) + }) + .collect(); + let tax_cache_map: HashMap<(Uuid, String, String), i64> = tax_cache + .iter() + .map(|r| { + ( + ( + r.account_id, + r.tax_jurisdiction.clone(), + r.tax_filing_period.clone(), + ), + r.balance_minor, + ) + }) + .collect(); + let unallocated_cache_map: HashMap<(Uuid, Uuid, String), i64> = unallocated_cache + .iter() + .map(|r| { + ( + (r.payer_tenant_id, r.account_id, r.currency.clone()), + r.balance_minor, + ) + }) + .collect(); + let reusable_credit_cache_map: HashMap<(Uuid, Uuid, String, String), i64> = + reusable_credit_cache + .iter() + .map(|r| { + ( + ( + r.payer_tenant_id, + r.account_id, + r.currency.clone(), + r.credit_grant_event_type.clone(), + ), + r.balance_minor, + ) + }) + .collect(); + + let mut variances = diff_grain("ar_payer_balance", &ar_payer, &ar_payer_cache_map, |k| { + format!("payer={}/account={}/currency={}", k.0, k.1, k.2) + }); + variances.extend(diff_grain( + "ar_invoice_balance", + &ar_invoice, + &ar_invoice_cache_map, + |k| format!("payer={}/account={}/invoice={}", k.0, k.1, k.2), + )); + variances.extend(diff_grain( + "ar_invoice_disputed", + &ar_invoice_disputed, + &ar_invoice_disputed_cache_map, + |k| format!("payer={}/account={}/invoice={}", k.0, k.1, k.2), + )); + variances.extend(diff_grain("tax_subbalance", &tax, &tax_cache_map, |k| { + format!("account={}/jurisdiction={}/filing={}", k.0, k.1, k.2) + })); + variances.extend(diff_grain( + "unallocated_balance", + &unallocated, + &unallocated_cache_map, + |k| format!("payer={}/account={}/currency={}", k.0, k.1, k.2), + )); + variances.extend(diff_grain( + "reusable_credit_subbalance", + &reusable_credit, + &reusable_credit_cache_map, + |k| { + format!( + "payer={}/account={}/currency={}/event_type={}", + k.0, k.1, k.2, k.3 + ) + }, + )); + variances + } +} + +/// Whole-slice reference wrapper over [`SubGrainAcc`] — retained for the +/// pure-function unit tests that lock the fold/finalize semantics the paginated +/// production path relies on. +#[cfg(test)] +fn recompute_sub_grain_variances( + lines: &[journal_line::Model], + normal_side_map: &HashMap, + ar_payer_cache: &[ar_payer_balance::Model], + ar_invoice_cache: &[ar_invoice_balance::Model], + tax_cache: &[tax_subbalance::Model], + unallocated_cache: &[unallocated_balance::Model], + reusable_credit_cache: &[reusable_credit_subbalance::Model], +) -> Vec { + let mut acc = SubGrainAcc::default(); + acc.fold(lines, normal_side_map); + acc.finalize( + ar_payer_cache, + ar_invoice_cache, + tax_cache, + unallocated_cache, + reusable_credit_cache, + ) +} + +/// Build the `entry_id -> payment_id` index for PAYMENT_SETTLE entries plus the +/// tenant-wide SETTLEMENT_RETURN flag (its presence makes journal-recomputed +/// `settled_minor` AND `fee_minor` un-reconcilable — see +/// [`DOC_SETTLEMENT_RETURN`]). Retained state is bounded by the settle-entry +/// count, so it can be folded from a paginated `journal_entry` scan. +fn settle_index(entries: &[journal_entry::Model]) -> (HashMap, bool) { + let mut settle_payment_by_entry: HashMap = HashMap::new(); + let mut has_settlement_return = false; + for entry in entries { + if entry.source_doc_type == DOC_SETTLEMENT_RETURN { + has_settlement_return = true; + } + if entry.source_doc_type == DOC_PAYMENT_SETTLE { + settle_payment_by_entry.insert(entry.entry_id, entry.source_business_id.clone()); + } + } + (settle_payment_by_entry, has_settlement_return) +} + +/// Streaming accumulator for the journal-recomputed payment counters. Folds the +/// PAYMENT_SETTLE legs page-by-page into per-payment `settled_minor` (Σ CR +/// UNALLOCATED) and `fee_minor` (Σ DR PSP_FEE_EXPENSE) sums — retained state +/// bounded by the settled-payment count, not the line count. +/// +/// Reconciles the `payment_settlement` counters against the truth, per +/// `payment_id`, emitting a [`PaymentCounterVariance`] for each disagreement: +/// +/// - **`allocated_minor`** ← Σ `payment_allocation.amount_minor` for the +/// `payment_id` (the allocation rows ARE the truth — a direct table sum, not a +/// journal recompute). Always reconciled. +/// - **`settled_minor`** ← Σ of the `CR UNALLOCATED` line amounts on the +/// payment's `PAYMENT_SETTLE` journal entry (`= gross`, the settlement seed). +/// Reconciled ONLY when the tenant has NO `SETTLEMENT_RETURN` entry: a return +/// decrements the cached `settled_minor` via `add_settled(-amount)`, but its +/// entry is keyed by `psp_return_id` (no `payment_id` on the header or lines), +/// so the decrement is not journal→payment recoverable in memory. Skipping it +/// tenant-wide (returns are rare) avoids a guaranteed false positive on every +/// returned payment; the skip is logged once. +/// - **`fee_minor`** ← Σ of the `DR PSP_FEE_EXPENSE` line amounts on the +/// `PAYMENT_SETTLE` entry. Reconciled under the SAME `SETTLEMENT_RETURN` gate +/// as `settled_minor`: a return now reverses a proportional fee slice +/// (`add_fee(-fee_share)`, Model N D1) on the SAME `psp_return_id`-keyed entry, +/// so the decrement is equally un-mappable journal→payment in memory. With NO +/// return the fee is finalised at settle and never adjusted, so it reconciles +/// cleanly; with a return present it is skipped tenant-wide alongside +/// `settled_minor`. +/// - **`clawed_back_minor` / `refunded_minor`** — DEFERRED (see [`Self::finalize`]): +/// no clean journal→payment map exists in memory (`clawed_back_minor` comes off +/// a `CHARGEBACK` entry keyed `dispute_id:cycle:phase`, recoverable only via a +/// `ledger_dispute` join + composite-id parse + phase/variant re-derivation; +/// `refunded_minor` has no posting source in this slice — it is seeded `0` and +/// never written). Each is logged once and skipped. +/// +/// The `PAYMENT_SETTLE` header carries `source_business_id = payment_id`; its +/// lines join via `entry_id`. A payment whose settle entry is missing (a counter +/// row with no journal) surfaces as `computed = 0 != cached`; a settle entry with +/// no counter row surfaces as `computed != 0, cached = 0`. +#[derive(Default)] +struct PaymentCounterAcc { + settled_from_journal: HashMap, + fee_from_journal: HashMap, +} + +impl PaymentCounterAcc { + /// Fold one page of lines: attribute each `CR UNALLOCATED` leg to + /// `settled_minor` and each `DR PSP_FEE_EXPENSE` leg to `fee_minor` of the + /// line's PAYMENT_SETTLE entry payment (via `settle_payment_by_entry`). A line + /// on a non-settle entry is ignored — matching the by-entry grouping the + /// former one-shot pass used. + fn fold( + &mut self, + lines: &[journal_line::Model], + settle_payment_by_entry: &HashMap, + ) { + for line in lines { + let Some(payment_id) = settle_payment_by_entry.get(&line.entry_id) else { + continue; + }; + if line.side == SIDE_CREDIT && line.account_class == CLASS_UNALLOCATED { + *self + .settled_from_journal + .entry(payment_id.clone()) + .or_insert(0) += line.amount_minor; + } + if line.side == SIDE_DEBIT && line.account_class == CLASS_PSP_FEE_EXPENSE { + *self.fee_from_journal.entry(payment_id.clone()).or_insert(0) += line.amount_minor; + } + } + } + + /// Diff the folded counters against the settlement cache, per payment. See + /// the [`PaymentCounterAcc`] docs for the per-counter rules and the + /// SETTLEMENT_RETURN gate. + fn finalize( + self, + allocations: &[payment_allocation::Model], + settlement_cache: &[payment_settlement::Model], + has_settlement_return: bool, + ) -> Vec { + let Self { + settled_from_journal, + fee_from_journal, + } = self; + + // `clawed_back_minor` / `refunded_minor` reconciles are deferred — no clean + // in-memory journal→payment map. Logged once per pass (not per payment) so + // the tracked gap is visible without flooding the log. + tracing::debug!( + "bss-ledger: tie-out counter clawed_back_minor reconcile deferred \ + (no clean journal→payment map: CHARGEBACK keyed dispute_id:cycle:phase)" + ); + tracing::debug!( + "bss-ledger: tie-out counter refunded_minor reconcile deferred \ + (no posting source in this slice — counter seeded 0, never written)" + ); + // A tenant with ANY SETTLEMENT_RETURN entry has un-mappable `settled_minor` + // AND `fee_minor` decrements (Model N D1: a return reverses both on its + // `psp_return_id`-keyed entry), so BOTH counters are skipped tenant-wide. + if has_settlement_return { + tracing::debug!( + "bss-ledger: tie-out counter settled_minor + fee_minor reconcile deferred for tenant \ + (a SETTLEMENT_RETURN decrement is not journal→payment recoverable in memory)" + ); + } + + // `allocated_minor` from the allocation rows (truth), per payment. + let mut allocated_from_rows: HashMap = HashMap::new(); + for alloc in allocations { + *allocated_from_rows + .entry(alloc.payment_id.clone()) + .or_insert(0) += alloc.amount_minor; + } + + // Diff each reconciled counter against the cache. The settlement cache row is + // the per-payment anchor; allocation rows / settle-journal sums with NO cache + // row are flagged with `cached = 0`, and a cache counter with no computed + // counterpart is flagged with `computed = 0`. + let mut variances = Vec::new(); + let mut seen_payments: HashSet<&str> = HashSet::new(); + for row in settlement_cache { + seen_payments.insert(row.payment_id.as_str()); + + let allocated = allocated_from_rows + .get(&row.payment_id) + .copied() + .unwrap_or(0); + if allocated != row.allocated_minor { + variances.push(PaymentCounterVariance { + payment_id: row.payment_id.clone(), + counter: "allocated_minor", + computed: allocated, + cached: row.allocated_minor, + }); + } + + // `settled_minor` + `fee_minor` share the SETTLEMENT_RETURN gate: a return + // decrements both on its un-mappable `psp_return_id`-keyed entry (Model N + // D1), so neither is journal→payment recoverable in memory once any return + // exists for the tenant. + if !has_settlement_return { + let fee = fee_from_journal.get(&row.payment_id).copied().unwrap_or(0); + if fee != row.fee_minor { + variances.push(PaymentCounterVariance { + payment_id: row.payment_id.clone(), + counter: "fee_minor", + computed: fee, + cached: row.fee_minor, + }); + } + + let settled = settled_from_journal + .get(&row.payment_id) + .copied() + .unwrap_or(0); + if settled != row.settled_minor { + variances.push(PaymentCounterVariance { + payment_id: row.payment_id.clone(), + counter: "settled_minor", + computed: settled, + cached: row.settled_minor, + }); + } + } + } + + // Computed-but-uncached payments (a settle journal / allocation rows with NO + // `payment_settlement` counter row — the seed that should anchor them is + // missing). Mirror `diff_grain`'s stray-computed rule: flag each non-zero + // computed counter with `cached = 0`. `settled_minor` stays gated on the + // no-return guard. + let mut orphans: HashSet<&str> = HashSet::new(); + orphans.extend(allocated_from_rows.keys().map(String::as_str)); + orphans.extend(settled_from_journal.keys().map(String::as_str)); + orphans.extend(fee_from_journal.keys().map(String::as_str)); + for payment_id in orphans { + if seen_payments.contains(payment_id) { + continue; + } + let allocated = allocated_from_rows.get(payment_id).copied().unwrap_or(0); + if allocated != 0 { + variances.push(PaymentCounterVariance { + payment_id: payment_id.to_owned(), + counter: "allocated_minor", + computed: allocated, + cached: 0, + }); + } + // `fee_minor` + `settled_minor` share the SETTLEMENT_RETURN gate (see the + // main loop): both carry un-mappable return decrements (Model N D1). + if !has_settlement_return { + let fee = fee_from_journal.get(payment_id).copied().unwrap_or(0); + if fee != 0 { + variances.push(PaymentCounterVariance { + payment_id: payment_id.to_owned(), + counter: "fee_minor", + computed: fee, + cached: 0, + }); + } + let settled = settled_from_journal.get(payment_id).copied().unwrap_or(0); + if settled != 0 { + variances.push(PaymentCounterVariance { + payment_id: payment_id.to_owned(), + counter: "settled_minor", + computed: settled, + cached: 0, + }); + } + } + } + + variances + } +} + +/// Whole-slice reference wrapper over [`PaymentCounterAcc`] — retained for the +/// pure-function unit tests that lock the fold/finalize semantics the paginated +/// production path relies on. +#[cfg(test)] +fn recompute_payment_counter_variances( + entries: &[journal_entry::Model], + lines: &[journal_line::Model], + allocations: &[payment_allocation::Model], + settlement_cache: &[payment_settlement::Model], +) -> Vec { + let (settle_payment_by_entry, has_settlement_return) = settle_index(entries); + let mut acc = PaymentCounterAcc::default(); + acc.fold(lines, &settle_payment_by_entry); + acc.finalize(allocations, settlement_cache, has_settlement_return) +} + +/// Per-entry running tally for the entry-balance backstop. +#[derive(Default)] +struct EntryAgg { + net_minor: i64, + line_count: u64, + payers: HashSet, +} + +/// Group lines by `(entry_id, currency, currency_scale)` and flag any group +/// whose net (`sum(DR) - sum(CR)`) is non-zero, that has no lines, or that +/// spans more than one payer. Independent of the commit trigger — catches a +/// malformed entry committed with a missing/buggy trigger. +/// Streaming accumulator for the entry-balance backstop. Folds lines into +/// per-`(entry_id, currency, currency_scale)` tallies page-by-page (retained +/// state bounded by the entry-grain count, not the line count). +#[derive(Default)] +struct EntryBackstopAcc { + groups: HashMap<(Uuid, String, i16), EntryAgg>, +} + +impl EntryBackstopAcc { + /// Fold one page of lines into the per-entry tallies. + fn fold(&mut self, lines: &[journal_line::Model]) { + for line in lines { + let key = (line.entry_id, line.currency.clone(), line.currency_scale); + let agg = self.groups.entry(key).or_default(); + let signed = if line.side == SIDE_DEBIT { + line.amount_minor + } else { + -line.amount_minor + }; + agg.net_minor += signed; + agg.line_count += 1; + agg.payers.insert(line.payer_tenant_id); + } + } + + /// Flag any group whose net (`sum(DR) - sum(CR)`) is non-zero or that spans + /// more than one payer. + fn finalize(self) -> Vec { + let mut imbalanced = Vec::new(); + for ((entry_id, currency, _scale), agg) in self.groups { + let payer_count = u64::try_from(agg.payers.len()).unwrap_or(u64::MAX); + if agg.net_minor != 0 || payer_count > 1 { + imbalanced.push(ImbalancedEntry { + entry_id, + currency, + net_minor: agg.net_minor, + line_count: agg.line_count, + payer_count, + }); + } + } + imbalanced + } +} + +/// Group lines by `(entry_id, currency, currency_scale)` and flag any group +/// whose net (`sum(DR) - sum(CR)`) is non-zero, that has no lines, or that +/// spans more than one payer. Independent of the commit trigger — catches a +/// malformed entry committed with a missing/buggy trigger. +#[cfg(test)] +fn entry_backstop(lines: &[journal_line::Model]) -> Vec { + let mut acc = EntryBackstopAcc::default(); + acc.fold(lines); + acc.finalize() +} + +/// Re-check the no-negative invariant: an `account_balance` row whose balance is +/// negative is a defect when its class is guarded ([`AccountClass::GUARDED`]) OR +/// unknown/unparseable (fail loud on a corrupt class). Classes outside the +/// guarded set may legitimately go negative. +fn negative_grains(balances: &[account_balance::Model]) -> Vec { + balances + .iter() + .filter(|b| { + b.balance_minor < 0 + && b.account_class + .parse::() + .map_or(true, AccountClass::is_guarded) + }) + .map(|b| NegativeGrain { + account_id: b.account_id, + currency: b.currency.clone(), + balance_minor: b.balance_minor, + }) + .collect() +} + +// ──────────────────────────────────────────────────────────────────────────── +// VHP-1843 incremental tie-out: (grain, grain_key) string-space projection. +// +// The full `tie_out_on` folds ALL of a tenant's lines (O(history)). The +// incremental path verifies `baseline + fold(open periods) == cache` instead: +// the baseline is the cumulative VERIFIED balance through the last closed +// period (snapshotted at close, when a clean full tie-out has just proven the +// cache), and the open fold covers only the OPEN-period lines. Both the cache +// projection and the line fold land in the SAME `(grain, grain_key)` string +// space the baseline is stored in, so the three compare like-for-like. +// ──────────────────────────────────────────────────────────────────────────── + +/// Canonical `grain_key` for the `account_balance` grain. +fn key_account(account_id: Uuid, currency: &str) -> String { + format!("{account_id}|{currency}") +} +/// Canonical `grain_key` for `(payer, account, currency)` grains +/// (`ar_payer_balance`, `unallocated_balance`). +fn key_payer_account_ccy(payer: Uuid, account: Uuid, currency: &str) -> String { + format!("{payer}|{account}|{currency}") +} +/// Canonical `grain_key` for `(payer, account, invoice)` grains +/// (`ar_invoice` balance + disputed). +fn key_payer_account_invoice(payer: Uuid, account: Uuid, invoice: &str) -> String { + format!("{payer}|{account}|{invoice}") +} +/// Canonical `grain_key` for the `tax_subbalance` grain. +fn key_tax(account: Uuid, juris: &str, filing: &str) -> String { + format!("{account}|{juris}|{filing}") +} +/// Canonical `grain_key` for the `reusable_credit_subbalance` grain. +fn key_reusable(payer: Uuid, account: Uuid, currency: &str, event_type: &str) -> String { + format!("{payer}|{account}|{currency}|{event_type}") +} + +/// Project the derived caches into `(grain, grain_key) -> balance` — the shared +/// representation the baseline is stored in and the open fold is compared +/// against. Mirrors the cache-map building in `recompute_sub_grain_variances`. +fn cache_grains( + balances: &[account_balance::Model], + ar_payer: &[ar_payer_balance::Model], + ar_invoice: &[ar_invoice_balance::Model], + tax: &[tax_subbalance::Model], + unallocated: &[unallocated_balance::Model], + reusable_credit: &[reusable_credit_subbalance::Model], +) -> HashMap<(&'static str, String), i64> { + let mut m: HashMap<(&'static str, String), i64> = HashMap::new(); + for b in balances { + m.insert( + (GRAIN_ACCOUNT, key_account(b.account_id, &b.currency)), + b.balance_minor, + ); + } + for r in ar_payer { + m.insert( + ( + GRAIN_AR_PAYER, + key_payer_account_ccy(r.payer_tenant_id, r.account_id, &r.currency), + ), + r.balance_minor, + ); + } + for r in ar_invoice { + let key = key_payer_account_invoice(r.payer_tenant_id, r.account_id, &r.invoice_id); + m.insert((GRAIN_AR_INVOICE, key.clone()), r.balance_minor); + m.insert((GRAIN_AR_INVOICE_DISPUTED, key), r.disputed_minor); + } + for r in tax { + m.insert( + ( + GRAIN_TAX, + key_tax(r.account_id, &r.tax_jurisdiction, &r.tax_filing_period), + ), + r.balance_minor, + ); + } + for r in unallocated { + m.insert( + ( + GRAIN_UNALLOCATED, + key_payer_account_ccy(r.payer_tenant_id, r.account_id, &r.currency), + ), + r.balance_minor, + ); + } + for r in reusable_credit { + m.insert( + ( + GRAIN_REUSABLE_CREDIT, + key_reusable( + r.payer_tenant_id, + r.account_id, + &r.currency, + &r.credit_grant_event_type, + ), + ), + r.balance_minor, + ); + } + m +} + +/// Convert a cache projection into baseline rows to snapshot (absolute totals). +/// Used at period close to persist the freshly-verified cache as the baseline. +fn cache_baseline_rows(cache: &HashMap<(&'static str, String), i64>) -> Vec { + cache + .iter() + .map(|((grain, key), balance)| BaselineRow { + grain: (*grain).to_owned(), + grain_key: key.clone(), + balance_minor: *balance, + }) + .collect() +} + +/// Fold journal lines into `(grain, grain_key) -> signed sum`, using the SAME +/// signed-delta rule and per-class grain derivation as the full fold (see +/// [`signed`] and `recompute_sub_grain_variances`). A line whose account lacks a +/// `normal_side` is skipped (the full backstop force-flags it); incremental is a +/// cache-tie-out, not the defect scanner. +fn fold_grains( + lines: &[journal_line::Model], + normal_side_map: &HashMap, +) -> HashMap<(&'static str, String), i64> { + let mut m: HashMap<(&'static str, String), i64> = HashMap::new(); + for line in lines { + let Some(delta) = signed(line, normal_side_map) else { + continue; + }; + // account_balance — every line. + *m.entry((GRAIN_ACCOUNT, key_account(line.account_id, &line.currency))) + .or_insert(0) += delta; + + if line.account_class == CLASS_AR { + *m.entry(( + GRAIN_AR_PAYER, + key_payer_account_ccy(line.payer_tenant_id, line.account_id, &line.currency), + )) + .or_insert(0) += delta; + if let Some(invoice_id) = &line.invoice_id { + let key = + key_payer_account_invoice(line.payer_tenant_id, line.account_id, invoice_id); + *m.entry((GRAIN_AR_INVOICE, key.clone())).or_insert(0) += delta; + if line.ar_status.as_deref() == Some(AR_STATUS_DISPUTED) { + *m.entry((GRAIN_AR_INVOICE_DISPUTED, key)).or_insert(0) += delta; + } + } + } + + if line.account_class == CLASS_TAX_PAYABLE + && let (Some(juris), Some(filing)) = (&line.tax_jurisdiction, &line.tax_filing_period) + { + *m.entry((GRAIN_TAX, key_tax(line.account_id, juris, filing))) + .or_insert(0) += delta; + } + + if line.account_class == CLASS_UNALLOCATED { + *m.entry(( + GRAIN_UNALLOCATED, + key_payer_account_ccy(line.payer_tenant_id, line.account_id, &line.currency), + )) + .or_insert(0) += delta; + } + + if line.account_class == CLASS_REUSABLE_CREDIT { + let event_type = line.credit_grant_event_type.clone().unwrap_or_default(); + *m.entry(( + GRAIN_REUSABLE_CREDIT, + key_reusable( + line.payer_tenant_id, + line.account_id, + &line.currency, + &event_type, + ), + )) + .or_insert(0) += delta; + } + } + m +} + +/// Verify `baseline + open_fold == cache` per grain, emitting a +/// [`SubGrainVariance`] for every mismatch (over the union of all keys, so a +/// stray cache row, a missing baseline grain, or an open-only grain all +/// surface). A clean ledger yields an empty vec. +fn verify_incremental( + baseline: &HashMap<(&'static str, String), i64>, + open_fold: &HashMap<(&'static str, String), i64>, + cache: &HashMap<(&'static str, String), i64>, +) -> Vec { + let mut keys: HashSet<&(&'static str, String)> = HashSet::new(); + keys.extend(baseline.keys()); + keys.extend(open_fold.keys()); + keys.extend(cache.keys()); + + let mut variances = Vec::new(); + for key in keys { + let computed = + baseline.get(key).copied().unwrap_or(0) + open_fold.get(key).copied().unwrap_or(0); + let cached = cache.get(key).copied().unwrap_or(0); + if computed != cached { + variances.push(SubGrainVariance { + grain: key.0, + key: key.1.clone(), + computed, + cached, + }); + } + } + variances +} + +/// The result of an incremental tie-out: the cache-vs-(baseline+open) +/// disagreements, plus how many open-period lines were folded (diagnostics). +#[derive(Clone, Debug, Default)] +pub struct IncrementalReport { + /// Per-grain disagreements (`computed = baseline + open_fold`, `cached`). + pub sub_grain_variances: Vec, + /// Number of open-period lines folded (the bounded cost vs all-time). + pub open_line_count: u64, + /// Max `created_seq` the baseline is verified through (the incremental + /// boundary) — advanced onto `reconciliation_run.watermark` by the recon tick. + pub watermark: Option, +} + +impl IncrementalReport { + /// `true` when no grain diverged. + #[must_use] + pub fn is_clean(&self) -> bool { + self.sub_grain_variances.is_empty() + } + + /// Adapt to a [`TieOutReport`] so the shared tolerance/alarm paths consume it + /// unchanged: the per-grain disagreements land in `sub_grain_variances` (the + /// account grain included, keyed by its `GRAIN_ACCOUNT` discriminator) and + /// `posted_line_count` carries the open-line count. The full-fold-only defect + /// classes (imbalanced entries, negative grains, PENDING lines) are empty — + /// incremental is a cache tie-out, not the defect scanner (the periodic full + /// backstop and the posting-time guards cover those). + #[must_use] + pub fn into_tie_out_report(self, tenant_id: Uuid) -> TieOutReport { + TieOutReport { + tenant_id, + posted_line_count: self.open_line_count, + account_balance_variances: Vec::new(), + sub_grain_variances: self.sub_grain_variances, + imbalanced_entries: Vec::new(), + negative_grains: Vec::new(), + payment_counter_variances: Vec::new(), + pending_lines: 0, + } + } +} + +impl TieOutJob { + /// Incremental tie-out (VHP-1843): verify `baseline + fold(open periods) == + /// cache`, bounding the daily/recon cost to the open period instead of + /// folding all of history. Returns `Ok(None)` — signalling the caller to run + /// the full [`tie_out_on`](Self::tie_out_on) — when the tenant has no stored + /// baseline yet (never closed a period) OR carries a period in a transitional + /// state (anything other than `OPEN`/`CLOSED`, e.g. a `REOPENED` period whose + /// once-closed contribution the cumulative baseline can no longer isolate; the + /// next clean close re-snapshots a fresh baseline and incremental resumes). + /// + /// # Errors + /// Returns `Err` only on an infrastructure failure (DB unreachable / read + /// failure); tie-out *defects* are reported in the [`IncrementalReport`]. + pub async fn tie_out_incremental( + &self, + runner: &R, + tenant_id: Uuid, + ) -> anyhow::Result> { + let scope = AccessScope::for_tenant(tenant_id); + + // 1. Baseline. Empty → never closed a period → full fold. + let baseline_rows = VerifiedBalanceRepo::load_baseline(runner, &scope, tenant_id) + .await + .map_err(|e| anyhow::anyhow!("incremental tie-out: load baseline: {e}"))?; + if baseline_rows.is_empty() { + return Ok(None); + } + + // 2. Period partition. A non-OPEN/CLOSED status (transitional / REOPENED) + // means the cumulative baseline may no longer isolate the closed + // contribution — fall back to the full fold until the next close. + let periods = fiscal_period::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(fiscal_period::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("incremental tie-out: read fiscal_period: {e}"))?; + let mut open_period_ids: Vec = Vec::new(); + for p in &periods { + match p.status.as_str() { + PERIOD_STATUS_OPEN => open_period_ids.push(p.period_id.clone()), + PERIOD_STATUS_CLOSED => {} + _ => return Ok(None), + } + } + + // 3. Open-period lines only (the bounded read) + the chart for normal_side. + let open_lines = if open_period_ids.is_empty() { + Vec::new() + } else { + journal_line::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(journal_line::Column::TenantId.eq(tenant_id)) + .add(journal_line::Column::PeriodId.is_in(open_period_ids)), + ) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("incremental tie-out: read open journal_line: {e}"))? + }; + let accounts = tenant_account::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(tenant_account::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("incremental tie-out: read tenant_account: {e}"))?; + + // 4. The caches (all-time totals — the verify target). + let balances = account_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(account_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("incremental tie-out: read account_balance: {e}"))?; + let ar_payer_cache = ar_payer_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(ar_payer_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("incremental tie-out: read ar_payer_balance: {e}"))?; + let ar_invoice_cache = ar_invoice_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(ar_invoice_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("incremental tie-out: read ar_invoice_balance: {e}"))?; + let tax_cache = tax_subbalance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(tax_subbalance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("incremental tie-out: read tax_subbalance: {e}"))?; + let unallocated_cache = unallocated_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(unallocated_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("incremental tie-out: read unallocated_balance: {e}"))?; + let reusable_credit_cache = reusable_credit_subbalance::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all().add(reusable_credit_subbalance::Column::TenantId.eq(tenant_id)), + ) + .all(runner) + .await + .map_err(|e| { + anyhow::anyhow!("incremental tie-out: read reusable_credit_subbalance: {e}") + })?; + + // 5. Build the three maps and verify. + let normal_side_map: HashMap = accounts + .iter() + .map(|a| (a.account_id, a.normal_side.clone())) + .collect(); + let baseline_map: HashMap<(&'static str, String), i64> = baseline_rows + .iter() + .filter_map(|r| { + grain_label(&r.grain).map(|g| ((g, r.grain_key.clone()), r.verified_balance_minor)) + }) + .collect(); + let open_fold = fold_grains(&open_lines, &normal_side_map); + let cache_map = cache_grains( + &balances, + &ar_payer_cache, + &ar_invoice_cache, + &tax_cache, + &unallocated_cache, + &reusable_credit_cache, + ); + + Ok(Some(IncrementalReport { + sub_grain_variances: verify_incremental(&baseline_map, &open_fold, &cache_map), + open_line_count: u64::try_from(open_lines.len()).unwrap_or(u64::MAX), + watermark: baseline_rows.iter().map(|r| r.watermark_seq).max(), + })) + } + + /// Snapshot the current caches as the cumulative VERIFIED baseline for + /// `tenant_id` through `through_period`, in the caller's (close) transaction. + /// Called right after a clean full tie-out in the period-close SERIALIZABLE + /// txn — the caches are proven there, so they ARE the verified cumulative + /// total through the closing period; the snapshot rolls back with the close + /// txn on abort. `watermark_seq` is the max `created_seq` among the closing + /// period's entries (the incremental boundary recorded for diagnostics). + /// + /// # Errors + /// Returns `Err` on an infrastructure / storage failure. + pub async fn snapshot_baseline( + &self, + runner: &R, + tenant_id: Uuid, + through_period: &str, + ) -> anyhow::Result<()> { + let scope = AccessScope::for_tenant(tenant_id); + let balances = account_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(account_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("snapshot baseline: read account_balance: {e}"))?; + let ar_payer_cache = ar_payer_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(ar_payer_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("snapshot baseline: read ar_payer_balance: {e}"))?; + let ar_invoice_cache = ar_invoice_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(ar_invoice_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("snapshot baseline: read ar_invoice_balance: {e}"))?; + let tax_cache = tax_subbalance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(tax_subbalance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("snapshot baseline: read tax_subbalance: {e}"))?; + let unallocated_cache = unallocated_balance::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(unallocated_balance::Column::TenantId.eq(tenant_id))) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("snapshot baseline: read unallocated_balance: {e}"))?; + let reusable_credit_cache = reusable_credit_subbalance::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all().add(reusable_credit_subbalance::Column::TenantId.eq(tenant_id)), + ) + .all(runner) + .await + .map_err(|e| { + anyhow::anyhow!("snapshot baseline: read reusable_credit_subbalance: {e}") + })?; + + let cache_map = cache_grains( + &balances, + &ar_payer_cache, + &ar_invoice_cache, + &tax_cache, + &unallocated_cache, + &reusable_credit_cache, + ); + let rows = cache_baseline_rows(&cache_map); + + // Watermark = max created_seq among the closing period's entries. + let entries = journal_entry::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::PeriodId.eq(through_period)), + ) + .all(runner) + .await + .map_err(|e| anyhow::anyhow!("snapshot baseline: read journal_entry: {e}"))?; + let watermark_seq = entries.iter().map(|e| e.created_seq).max().unwrap_or(0); + + VerifiedBalanceRepo::snapshot( + runner, + &scope, + tenant_id, + through_period, + watermark_seq, + &rows, + ) + .await + .map_err(|e| anyhow::anyhow!("snapshot baseline: persist: {e}"))?; + Ok(()) + } +} + +/// Map a stored grain discriminator string back to its `'static` label so the +/// baseline keys live in the same space as the cache/fold maps. Returns `None` +/// for an unknown discriminator (a corrupt row — excluded so it surfaces as a +/// missing-baseline variance rather than panicking). +fn grain_label(grain: &str) -> Option<&'static str> { + match grain { + GRAIN_ACCOUNT => Some(GRAIN_ACCOUNT), + GRAIN_AR_PAYER => Some(GRAIN_AR_PAYER), + GRAIN_AR_INVOICE => Some(GRAIN_AR_INVOICE), + GRAIN_AR_INVOICE_DISPUTED => Some(GRAIN_AR_INVOICE_DISPUTED), + GRAIN_TAX => Some(GRAIN_TAX), + GRAIN_UNALLOCATED => Some(GRAIN_UNALLOCATED), + GRAIN_REUSABLE_CREDIT => Some(GRAIN_REUSABLE_CREDIT), + _ => None, + } +} + +#[cfg(test)] +#[path = "tieout_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/jobs/tieout_tests.rs b/gears/bss/ledger/ledger/src/infra/jobs/tieout_tests.rs new file mode 100644 index 000000000..1edfd4d87 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/tieout_tests.rs @@ -0,0 +1,1081 @@ +//! Postgres tests for `TieOutJob::run()` + `emit()` — the cross-tenant sweep. +//! Boots a container, seeds a clean and a drifted tenant, calls `job.run()`, +//! asserts it completes and that a drifted tenant produces a non-clean report. +//! Uses `noop()` publisher (no broker/assert-via-report). +//! +//! Ignored by default; run with +//! `cargo test -p bss-ledger --lib 'infra::jobs::tieout::tests' -- --ignored`. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::doc_markdown, + clippy::similar_names, + clippy::inconsistent_struct_constructor +)] + +use std::sync::Arc; + +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::{ + NegativeGrain, TieOutReport, cache_baseline_rows, cache_grains, entry_backstop, fold_grains, + key_account, negative_grains, recompute_payment_counter_variances, + recompute_sub_grain_variances, verify_incremental, +}; +use crate::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use crate::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::jobs::tieout::TieOutJob; +use crate::infra::posting::service::PostingService; +use crate::infra::storage::entity::verified_balance::GRAIN_ACCOUNT; +use crate::infra::storage::entity::{ + account_balance, ar_invoice_balance, ar_payer_balance, journal_entry, journal_line, + payment_allocation, payment_settlement, reusable_credit_subbalance, tax_subbalance, + unallocated_balance, +}; +use crate::infra::storage::migrations::Migrator; +use crate::infra::storage::repo::ReferenceRepo; + +fn bal(account_id: u128, class: &str, balance_minor: i64) -> account_balance::Model { + account_balance::Model { + tenant_id: Uuid::from_u128(0xA1), + account_id: Uuid::from_u128(account_id), + currency: "USD".to_owned(), + account_class: class.to_owned(), + normal_side: "DR".to_owned(), + balance_minor, + functional_balance_minor: None, + functional_currency: None, + last_entry_seq: None, + version: 0, + } +} + +#[test] +fn negative_grains_flags_guarded_and_unknown_not_legal_negative_class() { + // `AR` is guarded (must stay `>= 0`) — a negative AR balance is a defect. + // `REVENUE` may legitimately go negative — not a defect. An unknown / + // corrupt class that is negative is flagged (fail loud). A non-negative + // guarded balance is fine. + let ar_neg = bal(1, "AR", -100); + let revenue_neg = bal(2, "REVENUE", -100); + let ar_ok = bal(3, "AR", 50); + let unknown_neg = bal(4, "NOT_A_REAL_CLASS", -100); + let grains = negative_grains(&[ar_neg, revenue_neg, ar_ok, unknown_neg]); + let mut flagged: Vec = grains.iter().map(|g| g.account_id).collect(); + flagged.sort_unstable(); + assert_eq!( + flagged, + vec![Uuid::from_u128(1), Uuid::from_u128(4)], + "negative guarded (AR) and unknown classes are defects; legal REVENUE is not" + ); +} + +fn line_for(entry_id: Uuid, side: &str, amount_minor: i64) -> journal_line::Model { + journal_line::Model { + line_id: Uuid::now_v7(), + entry_id, + tenant_id: Uuid::from_u128(0xA1), + period_id: "2025-01".to_owned(), + payer_tenant_id: Uuid::from_u128(0xA1), + seller_tenant_id: None, + resource_tenant_id: None, + account_id: Uuid::from_u128(0xBB), + account_class: "AR".to_owned(), + gl_code: None, + side: side.to_owned(), + amount_minor, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: "RESOLVED".to_owned(), + functional_amount_minor: None, + functional_currency: None, + rate_snapshot_ref: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn clean_report() -> TieOutReport { + TieOutReport { + tenant_id: Uuid::from_u128(0xA1), + posted_line_count: 0, + account_balance_variances: vec![], + sub_grain_variances: vec![], + imbalanced_entries: vec![], + negative_grains: vec![], + payment_counter_variances: vec![], + pending_lines: 0, + } +} + +#[test] +fn is_clean_true_only_when_all_defect_vecs_empty() { + let clean = clean_report(); + assert!(clean.is_clean()); + + let mut dirty = clean_report(); + dirty.negative_grains.push(NegativeGrain { + account_id: Uuid::from_u128(1), + currency: "USD".to_owned(), + balance_minor: -50, + }); + assert!(!dirty.is_clean()); + assert!( + dirty.summary().contains("negative"), + "summary must name the negative_grains count" + ); +} + +#[test] +fn is_clean_false_on_pending_lines() { + let mut r = clean_report(); + r.pending_lines = 1; + assert!(!r.is_clean()); +} + +#[test] +fn entry_backstop_flags_unbalanced_entry() { + let entry_id = Uuid::now_v7(); + let lines = vec![ + line_for(entry_id, "DR", 1000), + line_for(entry_id, "CR", 999), + ]; + let flagged = entry_backstop(&lines); + assert_eq!(flagged.len(), 1, "1-minor drift must be caught"); + assert_eq!(flagged[0].entry_id, entry_id); + assert_eq!(flagged[0].net_minor, 1); // DR 1000 - CR 999 = +1 +} + +#[test] +fn entry_backstop_passes_balanced_entry() { + let entry_id = Uuid::now_v7(); + let lines = vec![ + line_for(entry_id, "DR", 1000), + line_for(entry_id, "CR", 1000), + ]; + assert!(entry_backstop(&lines).is_empty()); +} + +#[test] +fn entry_backstop_empty_input() { + assert!(entry_backstop(&[]).is_empty()); +} + +// --------------------------------------------------------------------------- +// Docker (testcontainers) helpers +// --------------------------------------------------------------------------- + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +struct Fixture { + tenant: Uuid, + ar_account: Uuid, + cash_account: Uuid, + legal_entity: Uuid, + period_id: String, +} + +/// Boot, migrate, seed USD@2 + OPEN period + AR/CASH accounts. +async fn setup( + container_url: &str, +) -> ( + DatabaseConnection, + PostingService, + DBProvider, + Fixture, +) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = "202606".to_owned(); + let ar_account = Uuid::now_v7(); + let cash_account = Uuid::now_v7(); + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{legal_entity}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + + reference + .insert_account(AccountRow { + account_id: ar_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + reference + .insert_account(AccountRow { + account_id: cash_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "CASH_CLEARING".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "CR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + let service = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + ( + raw, + service, + provider, + Fixture { + tenant, + ar_account, + cash_account, + legal_entity, + period_id, + }, + ) +} + +fn balanced_entry(f: &Fixture, business_id: &str, amount: i64) -> (NewEntry, Vec) { + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: f.tenant, + legal_entity_id: f.legal_entity, + period_id: f.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: business_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: f.tenant, + correlation_id: f.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + new_line(f, f.ar_account, AccountClass::Ar, Side::Debit, amount), + new_line( + f, + f.cash_account, + AccountClass::CashClearing, + Side::Credit, + amount, + ), + ]; + (entry, lines) +} + +fn new_line(f: &Fixture, account: Uuid, class: AccountClass, side: Side, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: f.tenant, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Boot + seed + post one balanced entry; return raw conn, provider, fixture. +async fn setup_with_one_balanced_post( + url: &str, +) -> (DatabaseConnection, DBProvider, Fixture) { + let (raw, service, provider, f) = setup(url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + let (entry, lines) = balanced_entry(&f, "biz-run-1", 1000); + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("balanced post must succeed"); + (raw, provider, f) +} + +// --------------------------------------------------------------------------- +// Docker tests (ignored by default — require Docker / testcontainers) +// --------------------------------------------------------------------------- + +/// `run()` over a single clean tenant completes with `Ok(())`. +/// Per-tenant report is also verified to be clean (confirms `run` calls +/// `tie_out_tenant` and no defect triggers `emit`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn run_over_clean_tenant_completes() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (_raw, provider, f) = setup_with_one_balanced_post(&url).await; + + let job = TieOutJob::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + + // `run()` must return Ok and trigger no defects. + job.run() + .await + .expect("run must succeed for a clean tenant"); + + // Independently verify the per-tenant report is clean (pins the + // `tie_out_tenant` call within `run`). + let report = TieOutJob::new(provider, Arc::new(LedgerEventPublisher::noop())) + .tie_out_tenant(f.tenant) + .await + .expect("tie_out_tenant must succeed"); + assert!( + report.is_clean(), + "clean books must tie out: {}", + report.summary() + ); +} + +/// `run()` over a drifted tenant completes with `Ok(())` AND the per-tenant +/// report is NOT clean, which means `run` reaches its `emit()` branch for that +/// tenant. +/// +/// Observation limit: the alarm itself is not captured. `emit()` is observable +/// only through the broker (`LedgerEventPublisher::new` needs two live +/// `AsyncProducer`s) or its metrics mirror (the `noop` publisher carries +/// `metrics: None`); a capturing double would require standing up the outbox or +/// adding a production-only test constructor, both out of scope for a test-only +/// change. This test therefore asserts (a) `run` completes `Ok` over a drifted +/// tenant and (b) the exact divergence that drives `emit` — executing the +/// emit-dispatch line for coverage — but does not assert the emitted category. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn run_over_drifted_tenant_emits_alarm() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, f) = setup_with_one_balanced_post(&url).await; + + // Corrupt the AR balance cache grain (add 1 so no-negative check stays + // satisfied while creating a variance for the tie-out). + raw.execute(pg(format!( + "UPDATE bss.ledger_account_balance SET balance_minor = balance_minor + 1 \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.ar_account + ))) + .await + .unwrap(); + + let job = TieOutJob::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + + // `run()` must complete Ok even when a tenant is dirty — it logs/alarms, not + // errors. + job.run() + .await + .expect("run must succeed even for a drifted tenant"); + + // Confirm the report is NOT clean — the run's `emit()` branch was reached. + let report = TieOutJob::new(provider, Arc::new(LedgerEventPublisher::noop())) + .tie_out_tenant(f.tenant) + .await + .expect("tie_out_tenant must succeed"); + assert!( + !report.is_clean(), + "drifted books must NOT tie out: {}", + report.summary() + ); + assert_eq!( + report.account_balance_variances.len(), + 1, + "exactly one grain diverged" + ); +} + +// --------------------------------------------------------------------------- +// Group B unit tests — the new in-memory reconciles (no Docker). Mirror the +// existing pure-function tests (`negative_grains` / `entry_backstop`): build +// `Vec` fixtures + a normal_side map and assert the variance set. The +// fixtures are internally consistent — whatever `normal_side` is declared, +// `signed()` derives the delta and the "clean" cache is set to that same fold, +// so the assertions hold independent of the production chart's actual sides. +// --------------------------------------------------------------------------- + +const PAYER: u128 = 0xBEEF; +const ACCT: u128 = 0xACC7; + +/// A fully-specified journal line for the sub-grain / counter recomputes. +#[allow(clippy::too_many_arguments)] +fn jl( + entry_id: Uuid, + account_id: Uuid, + account_class: &str, + side: &str, + amount_minor: i64, + invoice_id: Option<&str>, + ar_status: Option<&str>, + credit_grant_event_type: Option<&str>, +) -> journal_line::Model { + journal_line::Model { + line_id: Uuid::now_v7(), + entry_id, + tenant_id: Uuid::from_u128(0xA1), + period_id: "2026-06".to_owned(), + payer_tenant_id: Uuid::from_u128(PAYER), + seller_tenant_id: None, + resource_tenant_id: None, + account_id, + account_class: account_class.to_owned(), + gl_code: None, + side: side.to_owned(), + amount_minor, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: invoice_id.map(ToOwned::to_owned), + due_date: None, + revenue_stream: None, + mapping_status: "RESOLVED".to_owned(), + functional_amount_minor: None, + functional_currency: None, + rate_snapshot_ref: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: credit_grant_event_type.map(ToOwned::to_owned), + ar_status: ar_status.map(ToOwned::to_owned), + } +} + +/// `account_id -> "DR"` for every account a fixture's lines touch (all +/// debit-normal — the recompute is self-consistent regardless; see the module +/// note above). +fn dr_sides(account_ids: &[Uuid]) -> std::collections::HashMap { + account_ids.iter().map(|a| (*a, "DR".to_owned())).collect() +} + +fn ar_invoice_row( + account_id: Uuid, + invoice_id: &str, + balance_minor: i64, + disputed_minor: i64, +) -> ar_invoice_balance::Model { + ar_invoice_balance::Model { + tenant_id: Uuid::from_u128(0xA1), + payer_tenant_id: Uuid::from_u128(PAYER), + account_id, + invoice_id: invoice_id.to_owned(), + currency: "USD".to_owned(), + balance_minor, + disputed_minor, + functional_balance_minor: None, + functional_currency: None, + original_posted_at: None, + due_date: None, + last_entry_seq: None, + version: 0, + } +} + +fn unallocated_row(account_id: Uuid, balance_minor: i64) -> unallocated_balance::Model { + unallocated_balance::Model { + tenant_id: Uuid::from_u128(0xA1), + payer_tenant_id: Uuid::from_u128(PAYER), + account_id, + currency: "USD".to_owned(), + balance_minor, + functional_balance_minor: None, + functional_currency: None, + last_entry_seq: None, + version: 0, + } +} + +fn reusable_row( + account_id: Uuid, + event_type: &str, + balance_minor: i64, +) -> reusable_credit_subbalance::Model { + reusable_credit_subbalance::Model { + tenant_id: Uuid::from_u128(0xA1), + payer_tenant_id: Uuid::from_u128(PAYER), + account_id, + currency: "USD".to_owned(), + credit_grant_event_type: event_type.to_owned(), + first_granted_at: None, + balance_minor, + functional_balance_minor: None, + functional_currency: None, + last_entry_seq: None, + version: 0, + } +} + +/// `recompute_sub_grain_variances` with the new arity but only the new caches +/// populated (AR-payer / AR-invoice-balance / tax left clean & empty). +fn sub_grain( + lines: &[journal_line::Model], + sides: &std::collections::HashMap, + ar_invoice_cache: &[ar_invoice_balance::Model], + unallocated_cache: &[unallocated_balance::Model], + reusable_credit_cache: &[reusable_credit_subbalance::Model], +) -> Vec { + recompute_sub_grain_variances( + lines, + sides, + &[] as &[ar_payer_balance::Model], + ar_invoice_cache, + &[] as &[tax_subbalance::Model], + unallocated_cache, + reusable_credit_cache, + ) +} + +#[test] +fn disputed_minor_clean_when_cache_matches_disputed_legs() { + // An AR-reclass open: DR AR DISPUTED 300 + CR AR ACTIVE 300 on invoice INV1. + // balance_minor nets 0 (DR +300, CR -300); disputed_minor folds only the + // DISPUTED leg (+300). Cache balance=0, disputed=300 ⇒ clean. + let acct = Uuid::from_u128(ACCT); + let entry = Uuid::now_v7(); + let lines = vec![ + jl( + entry, + acct, + "AR", + "DR", + 300, + Some("INV1"), + Some("DISPUTED"), + None, + ), + jl( + entry, + acct, + "AR", + "CR", + 300, + Some("INV1"), + Some("ACTIVE"), + None, + ), + ]; + let cache = vec![ar_invoice_row(acct, "INV1", 0, 300)]; + let v = sub_grain(&lines, &dr_sides(&[acct]), &cache, &[], &[]); + assert!( + v.is_empty(), + "balance nets 0 and disputed=+300 must tie out: {v:?}" + ); +} + +#[test] +fn disputed_minor_flags_seeded_divergence() { + // Same disputed legs (computed disputed=+300) but the cache says 250 ⇒ a + // `ar_invoice_disputed` variance (and balance_minor still ties out). + let acct = Uuid::from_u128(ACCT); + let entry = Uuid::now_v7(); + let lines = vec![ + jl( + entry, + acct, + "AR", + "DR", + 300, + Some("INV1"), + Some("DISPUTED"), + None, + ), + jl( + entry, + acct, + "AR", + "CR", + 300, + Some("INV1"), + Some("ACTIVE"), + None, + ), + ]; + let cache = vec![ar_invoice_row(acct, "INV1", 0, 250)]; + let v = sub_grain(&lines, &dr_sides(&[acct]), &cache, &[], &[]); + assert_eq!(v.len(), 1, "exactly the disputed grain diverges: {v:?}"); + assert_eq!(v[0].grain, "ar_invoice_disputed"); + assert_eq!(v[0].computed, 300); + assert_eq!(v[0].cached, 250); +} + +#[test] +fn unallocated_clean_then_flags_divergence() { + // CR UNALLOCATED 1000 (settle) then DR UNALLOCATED 400 (allocate) ⇒ + // signed fold with a DR-normal account: +400 - 1000 = -600. + let acct = Uuid::from_u128(ACCT); + let e1 = Uuid::now_v7(); + let e2 = Uuid::now_v7(); + let lines = vec![ + jl(e1, acct, "UNALLOCATED", "CR", 1000, None, None, None), + jl(e2, acct, "UNALLOCATED", "DR", 400, None, None, None), + ]; + let sides = dr_sides(&[acct]); + + let clean = sub_grain(&lines, &sides, &[], &[unallocated_row(acct, -600)], &[]); + assert!( + clean.is_empty(), + "unallocated fold (-600) must tie out: {clean:?}" + ); + + let dirty = sub_grain(&lines, &sides, &[], &[unallocated_row(acct, -500)], &[]); + assert_eq!(dirty.len(), 1, "seeded divergence flagged: {dirty:?}"); + assert_eq!(dirty[0].grain, "unallocated_balance"); + assert_eq!(dirty[0].computed, -600); + assert_eq!(dirty[0].cached, -500); +} + +#[test] +fn reusable_credit_keys_by_event_type_and_flags_divergence() { + // Two REUSABLE_CREDIT grants on the same account but different event types; + // a None event type keys as "". Each is its own grain. + let acct = Uuid::from_u128(ACCT); + let e = Uuid::now_v7(); + let lines = vec![ + jl( + e, + acct, + "REUSABLE_CREDIT", + "CR", + 500, + None, + None, + Some("PROMO"), + ), + jl(e, acct, "REUSABLE_CREDIT", "CR", 200, None, None, None), + ]; + let sides = dr_sides(&[acct]); + + // DR-normal account, CR leg ⇒ -amount. PROMO=-500, ""=-200. + let clean = sub_grain( + &lines, + &sides, + &[], + &[], + &[ + reusable_row(acct, "PROMO", -500), + reusable_row(acct, "", -200), + ], + ); + assert!( + clean.is_empty(), + "both event-type grains tie out: {clean:?}" + ); + + // Drop the empty-event-type cache row ⇒ that grain strays (computed -200 vs 0). + let dirty = sub_grain( + &lines, + &sides, + &[], + &[], + &[reusable_row(acct, "PROMO", -500)], + ); + assert_eq!(dirty.len(), 1, "the \"\" grain diverges: {dirty:?}"); + assert_eq!(dirty[0].grain, "reusable_credit_subbalance"); + assert_eq!(dirty[0].computed, -200); + assert_eq!(dirty[0].cached, 0); + assert!( + dirty[0].key.contains("event_type="), + "key names the event-type dim: {}", + dirty[0].key + ); +} + +// --- payment-counter reconcile (B2) --- + +fn settle_entry(entry_id: Uuid, payment_id: &str) -> journal_entry::Model { + journal_entry::Model { + entry_id, + tenant_id: Uuid::from_u128(0xA1), + legal_entity_id: Uuid::from_u128(0xA1), + period_id: "2026-06".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: "PAYMENT_SETTLE".to_owned(), + source_business_id: payment_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: Uuid::from_u128(0xA1), + correlation_id: Uuid::from_u128(0xA1), + rounding_evidence: serde_json::Value::Null, + created_seq: 1, + row_hash: None, + prev_hash: None, + prev_entry_id: None, + prev_period_id: None, + } +} + +fn return_entry(entry_id: Uuid, psp_return_id: &str) -> journal_entry::Model { + journal_entry::Model { + source_doc_type: "SETTLEMENT_RETURN".to_owned(), + source_business_id: psp_return_id.to_owned(), + ..settle_entry(entry_id, psp_return_id) + } +} + +#[allow(clippy::too_many_arguments)] +fn settlement_row( + payment_id: &str, + settled_minor: i64, + fee_minor: i64, + allocated_minor: i64, +) -> payment_settlement::Model { + payment_settlement::Model { + tenant_id: Uuid::from_u128(0xA1), + payment_id: payment_id.to_owned(), + currency: "USD".to_owned(), + settled_minor, + fee_minor, + allocated_minor, + refunded_minor: 0, + refunded_unallocated_minor: 0, + clawed_back_minor: 0, + version: 0, + } +} + +fn alloc_row(payment_id: &str, invoice_id: &str, amount_minor: i64) -> payment_allocation::Model { + payment_allocation::Model { + tenant_id: Uuid::from_u128(0xA1), + allocation_id: Uuid::now_v7(), + invoice_id: invoice_id.to_owned(), + payer_tenant_id: Uuid::from_u128(PAYER), + payment_id: payment_id.to_owned(), + amount_minor, + currency: "USD".to_owned(), + precedence_policy_ref: "p".to_owned(), + allocated_at_utc: Utc::now(), + } +} + +#[test] +fn payment_counters_clean_when_journal_and_rows_agree() { + // Settle gross 1000 / fee 30 ⇒ DR CASH_CLEARING 970 + DR PSP_FEE_EXPENSE 30 + // + CR UNALLOCATED 1000. Two allocations summing 600. Cache agrees. + let acct = Uuid::from_u128(ACCT); + let settle = Uuid::now_v7(); + let lines = vec![ + jl(settle, acct, "CASH_CLEARING", "DR", 970, None, None, None), + jl(settle, acct, "PSP_FEE_EXPENSE", "DR", 30, None, None, None), + jl(settle, acct, "UNALLOCATED", "CR", 1000, None, None, None), + ]; + let entries = vec![settle_entry(settle, "PAY1")]; + let allocs = vec![ + alloc_row("PAY1", "INV1", 400), + alloc_row("PAY1", "INV2", 200), + ]; + let cache = vec![settlement_row("PAY1", 1000, 30, 600)]; + + let v = recompute_payment_counter_variances(&entries, &lines, &allocs, &cache); + assert!(v.is_empty(), "all three reconciled counters tie out: {v:?}"); +} + +#[test] +fn payment_counters_flag_each_diverged_counter() { + // settled journal=1000 vs cache 900; fee journal=30 vs cache 30 (ok); + // allocated rows=600 vs cache 550. ⇒ settled_minor + allocated_minor flagged. + let acct = Uuid::from_u128(ACCT); + let settle = Uuid::now_v7(); + let lines = vec![ + jl(settle, acct, "CASH_CLEARING", "DR", 970, None, None, None), + jl(settle, acct, "PSP_FEE_EXPENSE", "DR", 30, None, None, None), + jl(settle, acct, "UNALLOCATED", "CR", 1000, None, None, None), + ]; + let entries = vec![settle_entry(settle, "PAY1")]; + let allocs = vec![alloc_row("PAY1", "INV1", 600)]; + let cache = vec![settlement_row("PAY1", 900, 30, 550)]; + + let mut v = recompute_payment_counter_variances(&entries, &lines, &allocs, &cache); + v.sort_by(|a, b| a.counter.cmp(b.counter)); + assert_eq!( + v.len(), + 2, + "settled + allocated diverge, fee ties out: {v:?}" + ); + let counters: Vec<&str> = v.iter().map(|x| x.counter).collect(); + assert_eq!(counters, vec!["allocated_minor", "settled_minor"]); + let settled = v.iter().find(|x| x.counter == "settled_minor").unwrap(); + assert_eq!((settled.computed, settled.cached), (1000, 900)); + let alloc = v.iter().find(|x| x.counter == "allocated_minor").unwrap(); + assert_eq!((alloc.computed, alloc.cached), (600, 550)); + assert!(v.iter().all(|x| x.payment_id == "PAY1")); +} + +#[test] +fn payment_settled_and_fee_reconcile_skipped_when_tenant_has_a_settlement_return() { + // A SETTLEMENT_RETURN in the tenant's journal makes BOTH `settled_minor` and + // `fee_minor` un-mappable (Model N D1: the return reverses both on the same + // psp_return_id-keyed entry, no payment_id), so BOTH reconciles are skipped + // tenant-wide even though the cache differs from the gross settle journal. + // `allocated_minor` still reconciles (the allocation rows are the truth). + let acct = Uuid::from_u128(ACCT); + let settle = Uuid::now_v7(); + let ret = Uuid::now_v7(); + let lines = vec![ + jl(settle, acct, "CASH_CLEARING", "DR", 970, None, None, None), + jl(settle, acct, "PSP_FEE_EXPENSE", "DR", 30, None, None, None), + jl(settle, acct, "UNALLOCATED", "CR", 1000, None, None, None), + // The return legs (Model N symmetric reverse of a partial return): DR + // UNALLOCATED 100 / CR CASH_CLEARING 97 / CR PSP_FEE_EXPENSE 3 (keyed by + // psp_return_id; no payment_id) — present only to trip the gate. + jl(ret, acct, "UNALLOCATED", "DR", 100, None, None, None), + jl(ret, acct, "CASH_CLEARING", "CR", 97, None, None, None), + jl(ret, acct, "PSP_FEE_EXPENSE", "CR", 3, None, None, None), + ]; + let entries = vec![settle_entry(settle, "PAY1"), return_entry(ret, "RET1")]; + let allocs = vec![alloc_row("PAY1", "INV1", 600)]; + // Cache: settled decremented to 900 by the return AND fee decremented to 27 + // (30 − 3) — BOTH differ from the gross settle journal (1000 / 30). If either + // reconcile ran, it would falsely flag; the gate must skip both. + let cache = vec![settlement_row("PAY1", 900, 27, 600)]; + + let v = recompute_payment_counter_variances(&entries, &lines, &allocs, &cache); + assert!( + v.iter().all(|x| x.counter != "settled_minor"), + "settled_minor must be skipped when a SETTLEMENT_RETURN exists: {v:?}" + ); + assert!( + v.iter().all(|x| x.counter != "fee_minor"), + "fee_minor must ALSO be skipped when a SETTLEMENT_RETURN exists (Model N): {v:?}" + ); + assert!( + v.is_empty(), + "allocated ties out; settled + fee skipped: {v:?}" + ); +} + +#[test] +fn payment_allocation_with_no_settlement_row_is_flagged() { + // An allocation row whose payment has NO settlement counter row is an orphan + // (the seed that should anchor it is missing) ⇒ allocated_minor variance with + // cached = 0. + let allocs = vec![alloc_row("GHOST", "INV1", 250)]; + let v = recompute_payment_counter_variances(&[], &[], &allocs, &[]); + assert_eq!(v.len(), 1, "orphan allocation flagged: {v:?}"); + assert_eq!(v[0].payment_id, "GHOST"); + assert_eq!(v[0].counter, "allocated_minor"); + assert_eq!((v[0].computed, v[0].cached), (250, 0)); +} + +// ──────────────────────────────────────────────────────────────────────────── +// VHP-1843 incremental tie-out — pure projection unit tests (no container). +// Exercise the `(grain, grain_key)` string-space helpers that the daily / recon +// incremental path shares with the close-time baseline snapshot. +// ──────────────────────────────────────────────────────────────────────────── + +/// A clean tenant verifies clean: `baseline (closed) + fold(open) == cache`. +#[test] +fn incremental_clean_when_baseline_plus_open_equals_cache() { + let acc = Uuid::from_u128(0xC1); + let e = Uuid::now_v7(); + // Open period: REVENUE net +100 (DR 300, CR 200) on the account grain. + let open = vec![ + jl(e, acc, "REVENUE", "DR", 300, None, None, None), + jl(e, acc, "REVENUE", "CR", 200, None, None, None), + ]; + let fold = fold_grains(&open, &dr_sides(&[acc])); + // Closed-period contribution carried by the baseline. + let mut baseline = std::collections::HashMap::new(); + baseline.insert((GRAIN_ACCOUNT, key_account(acc, "USD")), 500_i64); + // Cache (all-time) = 600 = baseline 500 + open 100. + let cache = cache_grains(&[bal(0xC1, "REVENUE", 600)], &[], &[], &[], &[], &[]); + assert!( + verify_incremental(&baseline, &fold, &cache).is_empty(), + "500 baseline + 100 open == 600 cache is clean" + ); +} + +/// Baseline drift surfaces as a variance with `computed = baseline + open`. +#[test] +fn incremental_flags_baseline_drift() { + let acc = Uuid::from_u128(0xC2); + let e = Uuid::now_v7(); + let open = vec![jl(e, acc, "REVENUE", "DR", 100, None, None, None)]; + let fold = fold_grains(&open, &dr_sides(&[acc])); + let mut baseline = std::collections::HashMap::new(); + baseline.insert((GRAIN_ACCOUNT, key_account(acc, "USD")), 500_i64); + // Cache claims 700 but baseline(500) + open(100) = 600 → a 100 divergence. + let cache = cache_grains(&[bal(0xC2, "REVENUE", 700)], &[], &[], &[], &[], &[]); + let v = verify_incremental(&baseline, &fold, &cache); + assert_eq!(v.len(), 1, "one grain diverges: {v:?}"); + assert_eq!((v[0].computed, v[0].cached), (600, 700)); +} + +/// A sub-grain (unallocated) folds + projects into the same key space and +/// verifies clean against a matching cache (empty baseline = an all-open tenant). +#[test] +fn incremental_subgrain_clean_with_matching_cache() { + let acc = Uuid::from_u128(0xC5); + let e = Uuid::now_v7(); + // An UNALLOCATED line touches the account grain AND the unallocated sub-grain. + let lines = vec![jl(e, acc, "UNALLOCATED", "DR", 250, None, None, None)]; + let fold = fold_grains(&lines, &dr_sides(&[acc])); + let cache = cache_grains( + &[bal(0xC5, "UNALLOCATED", 250)], + &[], + &[], + &[], + &[unallocated_row(acc, 250)], + &[], + ); + assert!( + verify_incremental(&std::collections::HashMap::new(), &fold, &cache).is_empty(), + "matching account + unallocated grains verify clean" + ); +} + +/// `cache_baseline_rows` round-trips a cache projection into baseline rows (the +/// close-time snapshot): same grain discriminator, key, and absolute balance. +#[test] +fn cache_baseline_rows_roundtrip() { + let acc = Uuid::from_u128(0xC4); + let cache = cache_grains(&[bal(0xC4, "REVENUE", 123)], &[], &[], &[], &[], &[]); + let rows = cache_baseline_rows(&cache); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].grain, GRAIN_ACCOUNT); + assert_eq!(rows[0].grain_key, key_account(acc, "USD")); + assert_eq!(rows[0].balance_minor, 123); +} + +/// VHP-1843 (PG) — the incremental tie-out equals the full fold across a CLOSED + +/// OPEN period split: post in period 1, close it + snapshot the baseline (what +/// period-close does), post in period 2; the incremental path (baseline[p1] + +/// open-fold[p2]) must reconcile clean and agree with the full all-time fold. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn incremental_tie_out_equals_full_across_periods() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, service, provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // Post in period 1 (the fixture's 202606). + let (e1, l1) = balanced_entry(&f, "biz-p1", 1000); + service.post(&ctx, &scope, e1, l1, None).await.unwrap(); + + // Close period 1 and snapshot its verified baseline (mirrors period-close). + raw.execute(pg(format!( + "UPDATE bss.ledger_fiscal_period SET status='CLOSED' \ + WHERE tenant_id='{}' AND period_id='{}'", + f.tenant, f.period_id + ))) + .await + .unwrap(); + let conn = provider.conn().unwrap(); + let job = TieOutJob::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + job.snapshot_baseline(&conn, f.tenant, &f.period_id) + .await + .expect("snapshot baseline at close"); + + // Open period 2 and post into it. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) \ + VALUES ('{}','{}','202607','UTC','OPEN')", + f.tenant, f.legal_entity + ))) + .await + .unwrap(); + let (mut e2, l2) = balanced_entry(&f, "biz-p2", 500); + e2.period_id = "202607".to_owned(); + e2.effective_at = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(); + service.post(&ctx, &scope, e2, l2, None).await.unwrap(); + + // Incremental reconciles clean: baseline[p1] + open-fold[p2] == cache[all]. + let inc = job + .tie_out_incremental(&conn, f.tenant) + .await + .unwrap() + .expect("a stored baseline ⇒ the incremental path runs"); + assert!( + inc.is_clean(), + "incremental clean on consistent books: {:?}", + inc.sub_grain_variances + ); + // The open fold covered only period 2's lines, not all-time. + assert!(inc.open_line_count >= 2, "period-2 lines folded: {inc:?}"); + + // And it agrees with the full all-time fold. + let full = job.tie_out_on(&conn, f.tenant).await.unwrap(); + assert!(full.is_clean(), "the full fold is clean too"); +} diff --git a/gears/bss/ledger/ledger/src/infra/jobs/verifier.rs b/gears/bss/ledger/ledger/src/infra/jobs/verifier.rs new file mode 100644 index 000000000..1ef5d6c08 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/jobs/verifier.rs @@ -0,0 +1,777 @@ +//! `ChainVerifierJob` — daily re-walk of every tenant's tamper-evidence hash +//! chain (Slice 6, design §4.2). +//! +//! For each tenant that has a `chain_state` tip, the job walks the journal from +//! the tip back to genesis following the `prev_entry_id` / `prev_period_id` +//! back-pointers. For each entry it (a) reconstructs the canonical +//! [`NewEntry`] + `Vec` from the stored row, recomputes its `row_hash` +//! over the SAME encoder the in-posting seal uses +//! ([`crate::domain::chain::chain_row_hash`]), and compares it to the STORED +//! `row_hash`; and (b) verifies the back-link — the entry's stored `prev_hash` +//! must equal the older (parent) entry's stored `row_hash`, and a genesis entry +//! (`prev_entry_id IS NULL`) must carry +//! [`crate::domain::chain::genesis_prev_hash`]. +//! +//! **On the first mismatch for a tenant** the job freezes the tenant tenant-wide +//! (`scope_freeze`, `period_id = 'ALL'`) on its own transaction, then emits a +//! Critical `TAMPER_VERIFY_FAILED` invariant alarm out-of-band — so the ledger +//! STOPS accepting writes for that tenant until an operator clears the freeze. +//! It then continues to the next tenant. A detected tamper is reported via the +//! freeze + alarm, NOT via an `Err` (mirrors how [`crate::infra::jobs::tieout`] +//! reports variances via alarms, not `Err`). A clean chain ⇒ no freeze, no +//! alarm. +//! +//! **Architecture:** like the tie-out job this is a **system-context, +//! cross-tenant** job. The all-tenants enumeration + the per-entry read-backs +//! run under the sanctioned all-tenants system scope +//! ([`AccessScope::allow_all`]); the freeze writes under +//! [`AccessScope::for_tenant`]. + +use std::collections::HashSet; +use std::sync::Arc; + +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use sea_orm::{ColumnTrait, Condition, EntityTrait, FromQueryResult, QuerySelect}; +use toolkit_db::secure::{AccessScope, SecureEntityExt, TxConfig}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::chain::{chain_row_hash, genesis_prev_hash}; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::audit::event_type::AuditEventType; +use crate::infra::audit::store::SecuredAuditStore; +use crate::infra::events::payloads::{AlarmCategory, AlarmSeverity, LedgerInvariantAlarm}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::posting::freeze::ScopeFreezeRepo; +use crate::infra::storage::entity::{chain_state, journal_entry, journal_line}; + +/// `scope_freeze.scope` kind for a tenant-wide freeze. +const SCOPE_KIND_TENANT: &str = "tenant"; +/// `scope_freeze.period_id` sentinel for a tenant-wide freeze. +const PERIOD_ALL: &str = "ALL"; +/// `scope_freeze.set_by` actor recorded for a Verifier-raised freeze. +const SET_BY_VERIFIER: &str = "Verifier"; + +/// Single-column projection of `DISTINCT journal_entry.tenant_id` for the +/// tip-less check — avoids materializing every sealed header just to derive the +/// set of tenants that have sealed entries. +#[derive(Debug, FromQueryResult)] +struct SealedTenantRow { + tenant_id: Uuid, +} + +/// Failure of a chain-verifier run. Mirrors the tie-out job's error shape: it is +/// raised ONLY on an infrastructure fault (DB unreachable / read failure) — a +/// detected tamper is NOT an error (it is reported via a freeze + alarm). +#[derive(Debug, thiserror::Error)] +pub enum VerifyError { + /// Storage / connection failure (driver text bounded by the caller). + #[error("chain-verify db error: {0}")] + Db(String), +} + +/// Why a tenant's chain failed verification (kept internal — ids only, no PII). +#[derive(Debug)] +struct ChainBreak { + /// The entry at which the break was detected. + entry_id: Uuid, + /// Period of that entry. + period_id: String, + /// Machine-readable break kind, for the freeze reason + alarm detail. + kind: BreakKind, +} + +/// The class of chain break the Verifier detected. +#[derive(Debug, Clone, Copy)] +enum BreakKind { + /// `chain_row_hash` recompute disagreed with the stored `row_hash`. + RowHashMismatch, + /// The stored `prev_hash` did not equal the parent entry's stored `row_hash`. + BrokenBackLink, + /// A genesis entry's stored `prev_hash` was not `genesis_prev_hash(tenant)`. + GenesisMismatch, + /// A chained entry had no stored `row_hash` / a non-32-byte hash, or the + /// back-pointer pair was half-NULL — a structurally corrupt row. + CorruptRow, + /// The walk revisited an entry — a cycle in the `prev_entry_id` pointers. + Cycle, + /// The tip-to-genesis walk verified clean, but the tenant has MORE sealed + /// `journal_entry` rows than the walk visited — sealed rows orphaned outside + /// the walked chain (a `chain_state` tip rolled back to an older entry, or a + /// forked branch). The walk alone can't see them; the count reconciliation does. + OrphanBranch, +} + +impl BreakKind { + /// Stable code for the freeze reason + alarm detail. + fn code(self) -> &'static str { + match self { + Self::RowHashMismatch => "ROW_HASH_MISMATCH", + Self::BrokenBackLink => "BROKEN_BACK_LINK", + Self::GenesisMismatch => "GENESIS_MISMATCH", + Self::CorruptRow => "CORRUPT_ROW", + Self::Cycle => "CYCLE", + Self::OrphanBranch => "ORPHAN_BRANCH", + } + } +} + +/// Daily chain-verifier job over every tenant that has a chain. +pub struct ChainVerifierJob { + db: DBProvider, + publisher: Arc, + metrics: Arc, +} + +impl ChainVerifierJob { + /// Build the job over one database provider, the event publisher (used + /// out-of-band to emit the tamper alarm on a separate connection), and the + /// metrics sink (the §9 tamper-verify / chain-length / freeze metrics). + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + Self { + db, + publisher, + metrics, + } + } + + /// Re-walk every tenant's chain; freeze + alarm on the first break per + /// tenant; continue to the next tenant. + /// + /// # Errors + /// [`VerifyError::Db`] only on the up-front tenant *enumeration* failure (DB + /// unreachable). A per-tenant read failure is logged and skipped — one flaky + /// tenant must not starve the rest — and a detected tamper is reported via a + /// freeze + alarm, never as `Err`. + pub async fn run(&self) -> Result<(), VerifyError> { + // Cross-tenant enumeration under the all-tenants system scope. Scoped to + // a block so the connection is released before the per-tenant walks + // (each walk opens its own connection). + let chain_tenants: HashSet = { + let conn = self + .db + .conn() + .map_err(|e| VerifyError::Db(format!("conn: {e}")))?; + let tips = chain_state::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .all(&conn) + .await + .map_err(|e| VerifyError::Db(format!("enumerate chain_state: {e}")))?; + tips.into_iter().map(|t| t.tenant_id).collect() + }; + + let mut failed = 0_usize; + for &tenant_id in &chain_tenants { + match self.verify_tenant(tenant_id).await { + Ok((walked, brk)) => { + // §9: one verifier run per tenant; `failed` splits the + // outcome (a detected break). `walked` is the number of + // entries the walk visited (the observed chain length). + self.metrics.tamper_verify_run(tenant_id, brk.is_some()); + self.metrics.chain_length(tenant_id, walked); + if let Some(brk) = brk { + self.freeze_and_alarm(tenant_id, &brk).await; + } + } + Err(e) => { + // Isolate per-tenant infra failures: log and continue so a + // single flaky tenant doesn't abort the whole tick. + failed += 1; + tracing::error!( + tenant_id = %tenant_id, + error = %e, + "bss-ledger: chain-verify failed for tenant; continuing" + ); + } + } + } + if failed > 0 { + tracing::warn!( + failed, + "bss-ledger: chain-verify tick completed with per-tenant failures" + ); + } + + // Defense-in-depth: the enumeration above is tip-keyed, so a + // tenant whose `chain_state` tip row is missing is never walked. A tenant + // that HAS sealed `journal_entry` rows but NO tip is therefore invisible — + // exactly the shape of a tip deleted to hide tampering. Cross-check the + // journal and alarm on any such tenant. + self.alarm_tipless_sealed_tenants(&chain_tenants).await; + + Ok(()) + } + + /// Alarm on tenants that have sealed `journal_entry` rows but no + /// `chain_state` tip. Alarm only — no freeze: without a tip the + /// chain cannot be walked, and a missing tip is a forensic signal for an + /// operator, not an automatic write-block. The journal chain is an unkeyed + /// SHA-256, so this is defense-in-depth, not a strong control. An + /// enumeration failure is logged and skipped (mirrors the per-tenant + /// isolation in `run`); it never fails the tick. + async fn alarm_tipless_sealed_tenants(&self, chain_tenants: &HashSet) { + let conn = match self.db.conn() { + Ok(c) => c, + Err(e) => { + tracing::error!(error = %e, "bss-ledger: tip-less check: conn failed; skipping"); + return; + } + }; + // Sealed rows only (`row_hash IS NOT NULL`): an unsealed row has no chain + // claim, so a missing tip for an all-unsealed tenant is not a break. + // Project to DISTINCT tenant_id rather than materializing every sealed + // header — this daily job only needs the set of tenants with sealed + // entries, and the journal is the largest table in the gear. The scoped + // `project_all` keeps the all-tenants system scope applied to the + // projection (avoids the full-table `.all()` scan). + let sealed_tenants = match journal_entry::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .filter(Condition::all().add(journal_entry::Column::RowHash.is_not_null())) + .project_all(&conn, |q| { + q.select_only() + .column(journal_entry::Column::TenantId) + .distinct() + .into_model::() + }) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::error!( + error = %e, + "bss-ledger: tip-less check: enumerate sealed journal failed; skipping" + ); + return; + } + }; + + let tipless: HashSet = sealed_tenants + .into_iter() + .map(|r| r.tenant_id) + .filter(|t| !chain_tenants.contains(t)) + .collect(); + + for tenant_id in tipless { + tracing::error!( + tenant_id = %tenant_id, + "bss-ledger: tenant has sealed journal entries but NO chain_state tip — \ + possible tampering (tip deleted); alarming" + ); + let category = AlarmCategory::TamperVerifyFailed; + self.metrics.invariant_alarm( + category.as_str(), + crate::infra::events::alarm_catalog::severity(category).as_str(), + ); + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::TamperVerifyFailed, + severity: AlarmSeverity::Critical, + tenant_id, + scope: format!("tenant:{tenant_id}"), + code: AlarmCategory::TamperVerifyFailed.as_str().to_owned(), + detail: format!( + "tenant {tenant_id} has sealed journal_entry rows but no chain_state \ + tip (tip missing/deleted) — chain unverifiable, manual investigation \ + required" + ), + affected: Vec::new(), + }; + self.publisher + .emit_invariant_alarm(&SecurityContext::anonymous(), alarm) + .await; + } + } + + /// Walk one tenant's chain from the tip to genesis. Returns + /// `Ok((walked, None))` for a clean chain and `Ok((walked, Some(break)))` on + /// the FIRST detected break, where `walked` is the number of entries the walk + /// visited (the observed chain length, backing the §9 `chain_length` gauge); + /// `Err` only on an infrastructure read failure. + #[allow( + clippy::too_many_lines, + reason = "one linear walk with inline fail-loud break-returns; flat is clearer than helpers" + )] + async fn verify_tenant( + &self, + tenant_id: Uuid, + ) -> Result<(i64, Option), VerifyError> { + let conn = self + .db + .conn() + .map_err(|e| VerifyError::Db(format!("conn: {e}")))?; + // Cross-tenant read scope — the job re-walks every tenant's chain (same + // sanctioned system scope the tie-out enumeration uses). + let scope = AccessScope::allow_all(); + + // Start at the tip. No tip row ⇒ the tenant has no chain (nothing to + // verify). The enumeration only yields tenants WITH a `chain_state` + // row, but re-read defensively (the row could vanish between passes). + let Some(tip) = chain_state::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(chain_state::Column::TenantId.eq(tenant_id))) + .one(&conn) + .await + .map_err(|e| VerifyError::Db(format!("read chain_state tip: {e}")))? + else { + return Ok((0, None)); + }; + + let genesis = genesis_prev_hash(tenant_id); + + // Walk back-pointers from the tip. `expected_child_link` carries the + // `prev_hash` claimed by the child just processed: the CURRENT entry's + // stored `row_hash` must equal it (the back-link check). `None` for the + // newest entry — there is no child above the tip. + let mut cursor: Option<(Uuid, String)> = + Some((tip.last_entry_id, tip.last_period_id.clone())); + let mut expected_child_link: Option> = None; + // Cycle guard: a corrupt `prev_entry_id` could otherwise loop forever. + let mut seen: HashSet<(Uuid, String)> = HashSet::new(); + + // The walk evaluates to the FIRST detected break (or `None` for a clean + // chain); `seen.len()` is then the observed chain length (§9 + // `chain_length`). A labeled block keeps the inline fail-loud returns + // while still letting us read the visited count out of `seen`. + let break_found: Option = 'walk: loop { + let Some((entry_id, period_id)) = cursor else { + break 'walk None; + }; + if !seen.insert((entry_id, period_id.clone())) { + break 'walk Some(ChainBreak { + entry_id, + period_id, + kind: BreakKind::Cycle, + }); + } + + // Load the header (carries the chain columns) + its lines. + let Some(header) = journal_entry::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(journal_entry::Column::EntryId.eq(entry_id)) + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::PeriodId.eq(period_id.clone())), + ) + .one(&conn) + .await + .map_err(|e| VerifyError::Db(format!("read journal_entry: {e}")))? + else { + // A back-pointer that names a missing entry is a broken chain. + break 'walk Some(ChainBreak { + entry_id, + period_id, + kind: BreakKind::CorruptRow, + }); + }; + + let line_rows = journal_line::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(journal_line::Column::EntryId.eq(entry_id)) + .add(journal_line::Column::TenantId.eq(tenant_id)) + .add(journal_line::Column::PeriodId.eq(period_id.clone())), + ) + .all(&conn) + .await + .map_err(|e| VerifyError::Db(format!("read journal_line: {e}")))?; + + // A chained entry must be sealed (non-NULL `row_hash`/`prev_hash`, + // each 32 bytes). A structurally corrupt seal is fail-loud. + let (Some(stored_row_hash), Some(stored_prev_hash)) = + (header.row_hash.clone(), header.prev_hash.clone()) + else { + break 'walk Some(ChainBreak { + entry_id, + period_id, + kind: BreakKind::CorruptRow, + }); + }; + let Ok(prev_hash_arr) = <[u8; 32]>::try_from(stored_prev_hash.as_slice()) else { + break 'walk Some(ChainBreak { + entry_id, + period_id, + kind: BreakKind::CorruptRow, + }); + }; + + // (a) Recompute the row_hash over the canonical encoder (DRY: the + // SAME `chain_row_hash` the in-posting seal uses) linked to the + // entry's own stored `prev_hash`, and compare to the stored value. + let Some((new_entry, new_lines)) = reconstruct(&header, &line_rows) else { + // A stored enum literal no longer parses — a corrupt row. + break 'walk Some(ChainBreak { + entry_id, + period_id, + kind: BreakKind::CorruptRow, + }); + }; + let recomputed = chain_row_hash(&new_entry, &new_lines, &prev_hash_arr); + if recomputed.as_slice() != stored_row_hash.as_slice() { + break 'walk Some(ChainBreak { + entry_id, + period_id, + kind: BreakKind::RowHashMismatch, + }); + } + + // (b) Back-link: this entry's stored `row_hash` must equal the + // `prev_hash` the child below it claimed for its parent. + if let Some(child_link) = &expected_child_link + && child_link.as_slice() != stored_row_hash.as_slice() + { + break 'walk Some(ChainBreak { + entry_id, + period_id, + kind: BreakKind::BrokenBackLink, + }); + } + + // Step to the parent. A non-genesis entry names its parent; a + // genesis entry (`prev_entry_id IS NULL`) must seed + // `genesis_prev_hash`. A half-NULL back-pointer pair is corrupt. + match (header.prev_entry_id, header.prev_period_id.clone()) { + (Some(parent_id), Some(parent_period)) => { + // The parent's stored `row_hash` must equal this entry's + // stored `prev_hash`. + expected_child_link = Some(stored_prev_hash); + cursor = Some((parent_id, parent_period)); + } + (None, None) => { + // (c) Genesis: stored prev_hash must be the tenant seed. + if prev_hash_arr != genesis { + break 'walk Some(ChainBreak { + entry_id, + period_id, + kind: BreakKind::GenesisMismatch, + }); + } + cursor = None; + } + _ => { + break 'walk Some(ChainBreak { + entry_id, + period_id, + kind: BreakKind::CorruptRow, + }); + } + } + }; + + // `seen` holds every entry the walk visited (the chain length). Saturate + // the `usize -> i64` narrowing rather than wrap (a chain longer than + // `i64::MAX` is impossible in practice). + let walked = i64::try_from(seen.len()).unwrap_or(i64::MAX); + + // Z3-1: orphan / tip-rollback reconciliation. The walk starts at the + // `chain_state` tip — a MUTABLE cache with no append-only guard — and + // only descends to genesis. A writer who redirects the tip to an OLDER + // entry (rollback) or forks a branch hides every sealed row above/outside + // the walked set: the tip-to-genesis walk still verifies clean. Reconcile + // the visited count against the total sealed rows for the tenant; a + // surplus means orphaned sealed rows the walk never reached, so freeze. + // Only meaningful on an otherwise-clean walk — a mid-walk break already + // freezes, and a broken walk's count is expected to be short. (Cheap + // COUNT, sibling of the tip-deletion check. The whole-chain + // re-sign attack is the separate, deferred D6 limitation.) + if break_found.is_none() { + let total_sealed = journal_entry::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::RowHash.is_not_null()), + ) + .count(&conn) + .await + .map_err(|e| VerifyError::Db(format!("count sealed journal_entry: {e}")))?; + let walked_count = u64::try_from(seen.len()).unwrap_or(u64::MAX); + if total_sealed != walked_count { + // The walk started from `tip`; `total_sealed` was counted by a + // later statement on the same (READ COMMITTED) connection — no + // single snapshot spans the two. A post committing in between + // advances the tip and inflates the count past the walked set: a + // false orphan that would tenant-wide-freeze a healthy tenant. + // The tip's `last_seq` is monotonic, so re-read it — if it + // advanced since the walk began, the surplus is a concurrent + // post, not an orphan; defer to the next pass (which re-walks + // from the new tip) instead of freezing. + let current_tip = chain_state::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(chain_state::Column::TenantId.eq(tenant_id))) + .one(&conn) + .await + .map_err(|e| VerifyError::Db(format!("re-read chain_state tip: {e}")))?; + let tip_advanced = current_tip.as_ref().is_none_or(|t| { + t.last_seq != tip.last_seq || t.last_entry_id != tip.last_entry_id + }); + if tip_advanced { + tracing::debug!( + tenant_id = %tenant_id, + walked = walked_count, + total_sealed, + "bss-ledger: chain-verify orphan check raced a concurrent post \ + (tip advanced mid-walk); deferring to the next pass" + ); + } else { + tracing::error!( + tenant_id = %tenant_id, + walked = walked_count, + total_sealed, + "bss-ledger: chain-verify orphan check — sealed rows exist outside the \ + tip-to-genesis walk (tip rollback or forked branch); freezing" + ); + return Ok(( + walked, + Some(ChainBreak { + entry_id: tip.last_entry_id, + period_id: tip.last_period_id.clone(), + kind: BreakKind::OrphanBranch, + }), + )); + } + } + } + + Ok((walked, break_found)) + } + + /// Freeze the tenant tenant-wide on its own transaction, then emit the + /// Critical `TAMPER_VERIFY_FAILED` alarm out-of-band. Fire-and-forget: a + /// freeze failure is logged (the alarm still fires so an operator is paged). + async fn freeze_and_alarm(&self, tenant_id: Uuid, brk: &ChainBreak) { + let reason = format!( + "chain verification failed ({}) at entry {} period {}", + brk.kind.code(), + brk.entry_id, + brk.period_id, + ); + tracing::error!( + tenant_id = %tenant_id, + entry_id = %brk.entry_id, + period_id = %brk.period_id, + kind = brk.kind.code(), + "bss-ledger: chain verification failed; freezing tenant tenant-wide" + ); + + // Freeze on a fresh transaction (the walk above used read-only + // connections), with a bounded retry: the freeze is the ACTUAL + // write-path protection (`TamperFreezeGuard` rejects posts to a frozen + // scope), so a transient DB fault must not silently leave the tenant + // writable. The alarm below is only notification. + let frozen = self.try_freeze_with_retry(tenant_id, &reason).await; + + // §9 metrics: make the tamper freeze observable. The alarm counter is the + // shared `ledger_alarm_total{category,severity}` rollup; the + // category token is the alarm's wire `as_str()` (same token the event + // carries); the severity is `AlarmCategory::severity()` mapped to its + // `&str` wire code. + let category = AlarmCategory::TamperVerifyFailed; + self.metrics.invariant_alarm( + category.as_str(), + crate::infra::events::alarm_catalog::severity(category).as_str(), + ); + // The freeze gauge marks one active tenant-wide freeze — but ONLY when the + // write actually committed. A failed freeze must NOT report the tenant as + // frozen: the gauge would lie while posts keep flowing. + if frozen { + self.metrics.scope_freeze_active(tenant_id, 1); + } + + // Out-of-band alarm on the publisher's own connection (mirrors the + // tie-out job; `SecurityContext::anonymous()` is the same system ctx). + // When the freeze write failed the tenant is NOT protected and still + // accepting writes, so escalate loudly and flag it in the (id-only, no + // PII) alarm detail — an operator must freeze it by hand. + let detail = if frozen { + reason + } else { + tracing::error!( + tenant_id = %tenant_id, + "bss-ledger: chain-verify freeze write FAILED after retries; tenant is \ + NOT frozen and still accepting writes — manual freeze required" + ); + format!( + "{reason}; FREEZE WRITE FAILED — tenant NOT frozen, manual intervention required" + ) + }; + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::TamperVerifyFailed, + severity: AlarmSeverity::Critical, + tenant_id, + scope: format!("tenant:{tenant_id}"), + code: AlarmCategory::TamperVerifyFailed.as_str().to_owned(), + // Internal diagnostic — ids + break-kind only, no PII. + detail, + affected: Vec::new(), + }; + self.publisher + .emit_invariant_alarm(&SecurityContext::anonymous(), alarm) + .await; + } + + /// Write the tenant-wide `scope_freeze` row, retrying a transient DB fault a + /// bounded number of times. Returns `true` once the freeze is committed and + /// `false` if every attempt failed (the caller then escalates — the tenant is + /// left writable). Re-setting an existing freeze is a no-op inside + /// [`ScopeFreezeRepo::set`], so a retry after a partial failure is safe. + async fn try_freeze_with_retry(&self, tenant_id: Uuid, reason: &str) -> bool { + /// Total freeze-write attempts before giving up and escalating. + const MAX_ATTEMPTS: u32 = 3; + + for attempt in 1..=MAX_ATTEMPTS { + // A fresh owned scope + reason per attempt move into the `'static` + // `move` closure (mirrors `period_open`). + let freeze_scope = AccessScope::for_tenant(tenant_id); + let reason_for_txn = reason.to_owned(); + // SERIALIZABLE: the §5.2 freeze-set-clear audit record is appended on + // the tenant's audit chain in this same txn, and the audit-chain + // append rests on SSI for its lockless tip read/advance (mirrors the + // erasure / cross-tenant audit writers). A bare default-isolation txn + // could fork the audit chain under a concurrent appender. + let result = self + .db + .transaction_with_config(TxConfig::serializable(), move |txn| { + Box::pin(async move { + let newly_frozen = ScopeFreezeRepo::new() + .set( + txn, + &freeze_scope, + tenant_id, + SCOPE_KIND_TENANT, + PERIOD_ALL, + &reason_for_txn, + SET_BY_VERIFIER, + ) + .await?; + // §5.2: a freeze writes a tamper-evident `freeze-set-clear` + // secured-audit record in the SAME txn — but only on a real + // transition, so the daily re-freeze of an already-frozen + // tenant (set = no-op) does not duplicate the record. + if newly_frozen { + let before_after = serde_json::json!({ + "action": "set", + "scope": SCOPE_KIND_TENANT, + "period_id": PERIOD_ALL, + "reason": reason_for_txn, + }); + SecuredAuditStore::new() + .append( + txn, + &freeze_scope, + tenant_id, + AuditEventType::FreezeSetClear, + Some(SET_BY_VERIFIER), + None, + &before_after, + None, + None, + ) + .await?; + } + Ok(()) + }) + }) + .await; + match result { + Ok(()) => return true, + Err(e) => tracing::error!( + tenant_id = %tenant_id, + attempt, + max_attempts = MAX_ATTEMPTS, + error = %e, + "bss-ledger: chain-verify freeze write attempt failed" + ), + } + } + false + } +} + +/// Rebuild the canonical [`NewEntry`] + `Vec` from a stored header + +/// its line rows, parsing the stored string enums back into their SDK types and +/// narrowing `currency_scale` from the persisted `i16` to the `u8` the encoder +/// expects. Returns `None` if any stored enum literal no longer parses (a +/// corrupt row — caught as a chain break by the caller). +/// +/// `correlation_id` and `rounding_evidence` are excluded from the chain hash +/// (see [`crate::domain::chain`]), so any value reproduces the same `row_hash`; +/// they are carried through verbatim for completeness. +fn reconstruct( + header: &journal_entry::Model, + line_rows: &[journal_line::Model], +) -> Option<(NewEntry, Vec)> { + let entry = NewEntry { + entry_id: header.entry_id, + tenant_id: header.tenant_id, + legal_entity_id: header.legal_entity_id, + period_id: header.period_id.clone(), + entry_currency: header.entry_currency.clone(), + source_doc_type: header.source_doc_type.parse::().ok()?, + source_business_id: header.source_business_id.clone(), + reverses_entry_id: header.reverses_entry_id, + reverses_period_id: header.reverses_period_id.clone(), + posted_at_utc: header.posted_at_utc, + effective_at: header.effective_at, + origin: header.origin.clone(), + posted_by_actor_id: header.posted_by_actor_id, + correlation_id: header.correlation_id, + rounding_evidence: header.rounding_evidence.clone(), + // Entry-level field (Slice 5) is not part of the chain hash and is not + // re-inserted by the verifier (it rebuilds for hash recomputation only); + // the per-line rate_snapshot_ref lives on the line rows. + rate_snapshot_ref: None, + }; + + let mut lines = Vec::with_capacity(line_rows.len()); + for row in line_rows { + lines.push(NewLine { + line_id: row.line_id, + payer_tenant_id: row.payer_tenant_id, + seller_tenant_id: row.seller_tenant_id, + resource_tenant_id: row.resource_tenant_id, + account_id: row.account_id, + account_class: row.account_class.parse::().ok()?, + gl_code: row.gl_code.clone(), + side: row.side.parse::().ok()?, + amount_minor: row.amount_minor, + currency: row.currency.clone(), + // `journal_line.currency_scale` is persisted as `i16`; the encoder + // takes `u8`. A scale outside `0..=255` is a corrupt row. + currency_scale: u8::try_from(row.currency_scale).ok()?, + invoice_id: row.invoice_id.clone(), + due_date: row.due_date, + revenue_stream: row.revenue_stream.clone(), + mapping_status: row.mapping_status.parse::().ok()?, + functional_amount_minor: row.functional_amount_minor, + functional_currency: row.functional_currency.clone(), + tax_jurisdiction: row.tax_jurisdiction.clone(), + tax_filing_period: row.tax_filing_period.clone(), + tax_rate_ref: row.tax_rate_ref.clone(), + legal_entity_id: row.legal_entity_id, + invoice_item_ref: row.invoice_item_ref.clone(), + sku_or_plan_ref: row.sku_or_plan_ref.clone(), + price_id: row.price_id.clone(), + pricing_snapshot_ref: row.pricing_snapshot_ref.clone(), + po_allocation_group: row.po_allocation_group.clone(), + credit_grant_event_type: row.credit_grant_event_type.clone(), + ar_status: row.ar_status.clone(), + }); + } + + Some((entry, lines)) +} diff --git a/gears/bss/ledger/ledger/src/infra/metrics.rs b/gears/bss/ledger/ledger/src/infra/metrics.rs new file mode 100644 index 000000000..b9a724df3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/metrics.rs @@ -0,0 +1,855 @@ +//! OpenTelemetry adapter implementing [`LedgerMetricsPort`]. +//! +//! Instruments are pulled from the process-global meter provider installed by +//! the host; a no-op until an exporter is wired, so emitting is always cheap +//! and safe. Instruments use full, literal Prometheus names: counters end in +//! `_total` and duration histograms in `_seconds`, with the suffix baked into +//! the instrument name (no `.with_unit()`); gauges carry the unit in the name +//! (`_seconds`) or stay bare. This matches the platform's +//! `add_metric_suffixes: false` collector posture, so the exporter renders the +//! names verbatim — consistent with RBAC / RMS / openbao. + +use opentelemetry::KeyValue; +use opentelemetry::metrics::{Counter, Gauge, Histogram, Meter}; + +use crate::domain::ports::metrics::{LedgerMetricsPort, NoteOutcome, PostFlow, PostResult}; + +/// Meter / instrumentation scope name (matches the gear / toolkit module name). +pub(crate) const METER_NAME: &str = "bss-ledger"; + +// ─── Metric names (literal Prometheus form; `add_metric_suffixes: false`) ───── +// Full names with suffixes baked in: counters end `_total`, duration +// histograms `_seconds`. No `.with_unit()` — the collector renders verbatim. +const LEDGER_INVOICE_POST: &str = "ledger_invoice_post_total"; +const LEDGER_INVOICE_POST_DURATION: &str = "ledger_invoice_post_duration_seconds"; +const LEDGER_PAYMENT_SETTLE: &str = "ledger_payment_settle_total"; +const LEDGER_SETTLEMENT_RETURN: &str = "ledger_settlement_return_total"; +const LEDGER_CHARGEBACK: &str = "ledger_chargeback_total"; +const LEDGER_ALLOCATION: &str = "ledger_allocation_total"; +// Deferred-apply queue depth (rows still `QUEUED` for `PAYMENT_ALLOCATE`), +// observed by the sweep job. A bare gauge (no unit suffix — it is a count). +const LEDGER_ALLOCATION_QUEUE_DEPTH: &str = "ledger_allocation_queue_depth"; +const LEDGER_CREDIT_APPLICATION: &str = "ledger_credit_application_total"; +const LEDGER_PAYMENT_POST_DURATION: &str = "ledger_payment_post_duration_seconds"; +// §9 catalog-wide alarm rollup `ledger_alarm_total{category,severity}` — the +// single alarm counter (the §4.7 tamper-failure count is this counter filtered +// to `category="TAMPER_VERIFY_FAILED"`). +const LEDGER_ALARM: &str = "ledger_alarm_total"; +// Suspense-backlog gauges: a bare line count and an age in seconds. +const LEDGER_SUSPENSE_PENDING_LINES: &str = "ledger_suspense_pending_lines"; +const LEDGER_SUSPENSE_PENDING_AGE: &str = "ledger_suspense_pending_age_seconds"; +// ASC 606 recognition (Slice 4, design §9): the release-pass duration histogram, +// the recognized-revenue counter (by stream), the over-recognition + +// double-credit counters (paired with their alarms), and the parked-segment +// queue-depth gauge. +const LEDGER_RECOGNITION_RUN_DURATION: &str = "ledger_recognition_run_duration_seconds"; +const LEDGER_REVENUE_RECOGNIZED_MINOR: &str = "ledger_revenue_recognized_minor"; +const LEDGER_OVER_RECOGNITION: &str = "ledger_over_recognition_total"; +const LEDGER_RECOGNITION_DOUBLE_CREDIT: &str = "ledger_recognition_double_credit_total"; +const LEDGER_RECOGNITION_PERIOD_QUEUE_DEPTH: &str = "ledger_recognition_period_queue_depth"; +// Dual-control governance (VHP-1852): pending created, decisions by outcome, and +// self-approval-denied attempts. +const LEDGER_DUAL_CONTROL_PENDING: &str = "ledger_dual_control_pending_total"; +const LEDGER_DUAL_CONTROL_DECIDED: &str = "ledger_dual_control_decided_total"; +const LEDGER_DUAL_CONTROL_SELF_APPROVAL_DENIED: &str = + "ledger_dual_control_self_approval_denied_total"; +// Count of `ledger_approval` rows currently in the transient `APPROVING` latch +// (Z8-1): a healthy approve clears it within one txn, so a value that stays > 0 +// across maintenance ticks is a crash-stranded approve (excluded from the TTL +// sweep, holding the active-uniqueness slot) needing a manual re-approve. +const LEDGER_DUAL_CONTROL_APPROVING: &str = "ledger_dual_control_approving"; +// ── §9 feature metrics ─────────────────────────────────────────────────────── +// Two counters per §9: total runs + the failures subset (failures/runs = the +// break rate), rather than one counter with an `outcome` label. +const LEDGER_TAMPER_VERIFY_RUNS: &str = "ledger_tamper_verify_runs_total"; +const LEDGER_TAMPER_VERIFY_FAILURES: &str = "ledger_tamper_verify_failures_total"; +const LEDGER_TAMPER_CHAIN_LENGTH: &str = "ledger_tamper_chain_length"; +const LEDGER_SCOPE_FREEZE_ACTIVE: &str = "ledger_scope_freeze_active"; +const LEDGER_CROSS_TENANT_ACCESS: &str = "ledger_cross_tenant_access_total"; +const LEDGER_REIDENTIFICATION: &str = "ledger_reidentification_total"; +const LEDGER_ERASURE_APPLIED: &str = "ledger_erasure_applied_total"; +const LEDGER_METADATA_CHANGE: &str = "ledger_metadata_change_total"; +const LEDGER_AUDIT_PACK_EXPORT_DURATION: &str = "ledger_audit_pack_export_duration_seconds"; +// Slice-3 adjustments (Group F): credit-note + debit-note attempts by outcome +// (posted / replayed / rejected, plus credit-note's blocked_split / +// blocked_headroom). One counter per note type, labelled by `outcome`. +const LEDGER_CREDIT_NOTE: &str = "ledger_credit_note_total"; +const LEDGER_DEBIT_NOTE: &str = "ledger_debit_note_total"; +// Slice-3 Phase-2 refunds (Group F, design §9): the `unknown_final` disposition +// counter, the open-`REFUND_CLEARING` balance + oldest-age gauges the aged-alarm +// job observes, and the stage-1-orphan counter. +// Group G adds the per-(phase, pattern) refund-post counter and the +// refund-quarantine queue-depth gauge. +const LEDGER_REFUND: &str = "ledger_refund_total"; +const LEDGER_REFUND_QUARANTINE_DEPTH: &str = "ledger_refund_quarantine_depth"; +const LEDGER_REFUND_UNKNOWN_FINAL: &str = "ledger_refund_unknown_final_total"; +const LEDGER_REFUND_CLEARING_BALANCE: &str = "ledger_refund_clearing_balance_minor"; +const LEDGER_REFUND_CLEARING_AGED: &str = "ledger_refund_clearing_aged_seconds"; +const LEDGER_STAGE1_REFUND_ORPHAN: &str = "ledger_stage1_refund_orphan_total"; +// FX & multi-currency (Slice 5 Phase 3, design §9): the unrealized-revaluation +// run-pass duration histogram + the provider-fallback counter. +const LEDGER_FX_REVALUATION_DURATION: &str = "ledger_fx_revaluation_duration_seconds"; +const LEDGER_FX_PROVIDER_FALLBACK: &str = "ledger_fx_provider_fallback_total"; +// Realized-FX amount counter (Slice 5 Phase 2, design §9): the functional +// magnitude moved through `FX_GAIN_LOSS` on a cross-currency allocation close, +// labelled by functional currency + gain/loss direction. +const LEDGER_FX_REALIZED_MINOR: &str = "ledger_fx_realized_minor"; +// ── Slice 7 Phase 3 reconciliation (design §9 / spec §3.5 J4) ───────────────── +// The per-check signed-variance gauge, the run + out-of-tolerance counters (both +// by check_type; out_of_tolerance/runs = the breach rate), the period-close- +// blocked counter (by reason), and the exception-queue depth gauge (by type). +const LEDGER_RECONCILIATION_VARIANCE_MINOR: &str = "ledger_reconciliation_variance_minor"; +const LEDGER_RECONCILIATION_RUNS: &str = "ledger_reconciliation_runs_total"; +const LEDGER_RECONCILIATION_OUT_OF_TOLERANCE: &str = "ledger_reconciliation_out_of_tolerance_total"; +const LEDGER_PERIOD_CLOSE_BLOCKED: &str = "ledger_period_close_blocked_total"; +const LEDGER_EXCEPTION_QUEUE_DEPTH: &str = "ledger_exception_queue_depth"; + +// ─── Bounded `reason_code` label ───────────────────────────────────────────── +// The cross-tenant `reason_code` is a caller-supplied header. Used verbatim as a +// Prometheus label it is an unbounded-cardinality DoS (a caller mints a new +// series per distinct value). The closed allow-list below bounds the +// `ledger_cross_tenant_access_total{reason_code}` label to a fixed size: a code +// is matched case-insensitively, anything else buckets to `"other"`. The raw, +// unbounded `reason_code` is still recorded verbatim in the `cross-tenant-access` +// forensic audit record — only the metric label is bounded. Extend the list as +// product ratifies new investigation reason codes (it stays a closed set). +const KNOWN_REASON_CODES: &[&str] = &[ + "DISPUTE", + "DISPUTE_INVESTIGATION", + "FRAUD", + "FRAUD_INVESTIGATION", + "LEGAL_HOLD", + "GDPR_REQUEST", + "SECURITY_INCIDENT", + "RECONCILIATION", + "AUDIT", + "SUPPORT", +]; + +/// Bucket a caller-supplied `reason_code` to a bounded metric label: a known +/// code (case-insensitive) maps to its canonical token, everything else to +/// `"other"`. Keeps `ledger_cross_tenant_access_total{reason_code}` cardinality +/// bounded to `KNOWN_REASON_CODES.len() + 1`. +fn bounded_reason_code(reason_code: &str) -> &'static str { + let upper = reason_code.trim().to_ascii_uppercase(); + KNOWN_REASON_CODES + .iter() + .copied() + .find(|known| *known == upper) + .unwrap_or("other") +} + +/// OpenTelemetry-backed metrics handle for the ledger invoice-posting domain. +pub struct LedgerMetricsMeter { + invoice_post: Counter, + invoice_post_duration: Histogram, + payment_settle: Counter, + settlement_return: Counter, + chargeback: Counter, + allocation: Counter, + allocation_queue_depth: Gauge, + credit_application: Counter, + payment_post_duration: Histogram, + suspense_pending_lines: Gauge, + suspense_pending_age: Gauge, + invariant_alarm: Counter, + tamper_verify_runs: Counter, + tamper_verify_failures: Counter, + tamper_chain_length: Gauge, + scope_freeze_active: Gauge, + cross_tenant_access: Counter, + reidentification: Counter, + erasure_applied: Counter, + metadata_change: Counter, + audit_pack_export_duration: Histogram, + recognition_run_duration: Histogram, + revenue_recognized_minor: Counter, + over_recognition: Counter, + recognition_double_credit: Counter, + recognition_period_queue_depth: Gauge, + dual_control_pending: Counter, + dual_control_decided: Counter, + dual_control_self_approval_denied: Counter, + dual_control_approving: Gauge, + credit_note: Counter, + debit_note: Counter, + refund: Counter, + refund_quarantine_depth: Gauge, + refund_unknown_final: Counter, + refund_clearing_balance: Gauge, + refund_clearing_aged: Gauge, + stage1_refund_orphan: Counter, + fx_revaluation_duration: Histogram, + fx_provider_fallback: Counter, + fx_realized_minor: Counter, + reconciliation_variance_minor: Gauge, + reconciliation_runs: Counter, + reconciliation_out_of_tolerance: Counter, + period_close_blocked: Counter, + exception_queue_depth: Gauge, +} + +impl std::fmt::Debug for LedgerMetricsMeter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LedgerMetricsMeter").finish_non_exhaustive() + } +} + +impl LedgerMetricsMeter { + /// Build the instrument set from the supplied meter. + #[must_use] + #[allow(clippy::too_many_lines)] // a flat instrument-builder list, one block per metric; no branching to factor out + pub fn new(meter: &Meter) -> Self { + Self { + invoice_post: meter + .u64_counter(LEDGER_INVOICE_POST) + .with_description("Invoice-post attempts by outcome (posted/replayed/rejected)") + .build(), + invoice_post_duration: meter + .f64_histogram(LEDGER_INVOICE_POST_DURATION) + .with_description("End-to-end invoice-post latency, seconds") + .with_boundaries(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5]) + .build(), + payment_settle: meter + .u64_counter(LEDGER_PAYMENT_SETTLE) + .with_description("Payment-settle attempts by outcome (posted/replayed/rejected)") + .build(), + settlement_return: meter + .u64_counter(LEDGER_SETTLEMENT_RETURN) + .with_description( + "Settlement-return attempts by outcome (posted/replayed/rejected)", + ) + .build(), + chargeback: meter + .u64_counter(LEDGER_CHARGEBACK) + .with_description( + "Chargeback dispute-phase attempts by outcome (posted/replayed/rejected)", + ) + .build(), + allocation: meter + .u64_counter(LEDGER_ALLOCATION) + .with_description( + "Payment-allocation attempts by outcome (posted/replayed/rejected)", + ) + .build(), + allocation_queue_depth: meter + .i64_gauge(LEDGER_ALLOCATION_QUEUE_DEPTH) + .with_description( + "Deferred-apply queue depth: allocations still QUEUED awaiting drain", + ) + .build(), + credit_application: meter + .u64_counter(LEDGER_CREDIT_APPLICATION) + .with_description( + "Reusable-credit grant/apply attempts by outcome (posted/replayed/rejected)", + ) + .build(), + payment_post_duration: meter + .f64_histogram(LEDGER_PAYMENT_POST_DURATION) + .with_description("End-to-end payment-post latency, seconds") + .with_boundaries(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5]) + .build(), + suspense_pending_lines: meter + .i64_gauge(LEDGER_SUSPENSE_PENDING_LINES) + .with_description("Live count of pending (suspense) lines, by tenant") + .build(), + suspense_pending_age: meter + .f64_gauge(LEDGER_SUSPENSE_PENDING_AGE) + .with_description("Age of the oldest pending (suspense) line in seconds, by tenant") + .build(), + invariant_alarm: meter + .u64_counter(LEDGER_ALARM) + .with_description("Ledger invariant/catalog alarms by category + severity") + .build(), + tamper_verify_runs: meter + .u64_counter(LEDGER_TAMPER_VERIFY_RUNS) + .with_description("Chain-Verifier runs (total)") + .build(), + tamper_verify_failures: meter + .u64_counter(LEDGER_TAMPER_VERIFY_FAILURES) + .with_description("Chain-Verifier runs that found a break") + .build(), + tamper_chain_length: meter + .i64_gauge(LEDGER_TAMPER_CHAIN_LENGTH) + .with_description("Observed tamper-evidence chain length, by tenant") + .build(), + scope_freeze_active: meter + .i64_gauge(LEDGER_SCOPE_FREEZE_ACTIVE) + .with_description("Count of active scope freezes, by tenant") + .build(), + cross_tenant_access: meter + .u64_counter(LEDGER_CROSS_TENANT_ACCESS) + .with_description("Cross-tenant audit-access elevations by reason_code") + .build(), + reidentification: meter + .u64_counter(LEDGER_REIDENTIFICATION) + .with_description("Forensic payer re-identification events") + .build(), + erasure_applied: meter + .u64_counter(LEDGER_ERASURE_APPLIED) + .with_description("GDPR right-to-erasure events applied") + .build(), + metadata_change: meter + .u64_counter(LEDGER_METADATA_CHANGE) + .with_description("Controlled-metadata changes by attribute") + .build(), + audit_pack_export_duration: meter + .f64_histogram(LEDGER_AUDIT_PACK_EXPORT_DURATION) + .with_description("Audit-pack CSV export latency, seconds") + .with_boundaries(vec![0.05, 0.1, 0.5, 1.0, 5.0, 15.0, 60.0]) + .build(), + recognition_run_duration: meter + .f64_histogram(LEDGER_RECOGNITION_RUN_DURATION) + .with_description("End-to-end recognition-run pass latency, seconds") + .with_boundaries(vec![ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, + ]) + .build(), + revenue_recognized_minor: meter + .u64_counter(LEDGER_REVENUE_RECOGNIZED_MINOR) + .with_description("Revenue recognized (minor units) on release, by stream") + .build(), + over_recognition: meter + .u64_counter(LEDGER_OVER_RECOGNITION) + .with_description("Releases rejected by the per-schedule over-recognition cap") + .build(), + recognition_double_credit: meter + .u64_counter(LEDGER_RECOGNITION_DOUBLE_CREDIT) + .with_description("Detected attempts to re-credit an already-released segment") + .build(), + recognition_period_queue_depth: meter + .i64_gauge(LEDGER_RECOGNITION_PERIOD_QUEUE_DEPTH) + .with_description( + "Recognition segments parked QUEUED (a predecessor period not yet DONE)", + ) + .build(), + dual_control_pending: meter + .u64_counter(LEDGER_DUAL_CONTROL_PENDING) + .with_description("Dual-control approvals created (over-threshold), by kind") + .build(), + dual_control_decided: meter + .u64_counter(LEDGER_DUAL_CONTROL_DECIDED) + .with_description("Dual-control approval decisions, by kind + decision") + .build(), + dual_control_self_approval_denied: meter + .u64_counter(LEDGER_DUAL_CONTROL_SELF_APPROVAL_DENIED) + .with_description("Dual-control self-approval attempts denied, by kind") + .build(), + dual_control_approving: meter + .i64_gauge(LEDGER_DUAL_CONTROL_APPROVING) + .with_description( + "Approvals in the transient APPROVING latch; sustained > 0 = a \ + crash-stranded approve needing manual re-approve (Z8-1)", + ) + .build(), + credit_note: meter + .u64_counter(LEDGER_CREDIT_NOTE) + .with_description( + "Credit-note attempts by outcome \ + (posted/replayed/rejected/blocked_split/blocked_headroom)", + ) + .build(), + debit_note: meter + .u64_counter(LEDGER_DEBIT_NOTE) + .with_description("Debit-note attempts by outcome (posted/replayed/rejected)") + .build(), + refund: meter + .u64_counter(LEDGER_REFUND) + .with_description("Refund phases posted, by phase + pattern (fresh posts only)") + .build(), + refund_quarantine_depth: meter + .i64_gauge(LEDGER_REFUND_QUARANTINE_DEPTH) + .with_description( + "Refund-before-payment rows still QUEUED on the REFUND_QUARANTINE flow", + ) + .build(), + refund_unknown_final: meter + .u64_counter(LEDGER_REFUND_UNKNOWN_FINAL) + .with_description( + "Refund unknown_final dispositions (REFUND_CLEARING cleared to a loss line + \ + secured-audit record)", + ) + .build(), + refund_clearing_balance: meter + .i64_gauge(LEDGER_REFUND_CLEARING_BALANCE) + .with_description("Open (unsettled) REFUND_CLEARING balance, minor units, by tenant") + .build(), + refund_clearing_aged: meter + .f64_gauge(LEDGER_REFUND_CLEARING_AGED) + .with_description( + "Age of the oldest open REFUND_CLEARING balance in seconds, by tenant", + ) + .build(), + stage1_refund_orphan: meter + .u64_counter(LEDGER_STAGE1_REFUND_ORPHAN) + .with_description( + "Stage-1 refunds with no matching stage-2 / reversal beyond the aging threshold", + ) + .build(), + fx_revaluation_duration: meter + .f64_histogram(LEDGER_FX_REVALUATION_DURATION) + .with_description("Unrealized-revaluation run-pass latency, seconds") + .with_boundaries(vec![0.05, 0.1, 0.5, 1.0, 5.0, 15.0, 60.0, 300.0, 1800.0]) + .build(), + fx_provider_fallback: meter + .u64_counter(LEDGER_FX_PROVIDER_FALLBACK) + .with_description( + "FX rate resolves that fell to a lower-priority provider, by provider", + ) + .build(), + fx_realized_minor: meter + .u64_counter(LEDGER_FX_REALIZED_MINOR) + .with_description( + "Realized FX magnitude (minor units) on a cross-currency close, by \ + functional currency + gain/loss direction", + ) + .build(), + reconciliation_variance_minor: meter + .i64_gauge(LEDGER_RECONCILIATION_VARIANCE_MINOR) + .with_description( + "Signed variance (minor units) observed by a reconciliation check, by \ + check_type", + ) + .build(), + reconciliation_runs: meter + .u64_counter(LEDGER_RECONCILIATION_RUNS) + .with_description("Reconciliation check runs (total), by check_type") + .build(), + reconciliation_out_of_tolerance: meter + .u64_counter(LEDGER_RECONCILIATION_OUT_OF_TOLERANCE) + .with_description( + "Reconciliation checks whose variance breached tolerance, by check_type", + ) + .build(), + period_close_blocked: meter + .u64_counter(LEDGER_PERIOD_CLOSE_BLOCKED) + .with_description("Period-close attempts rejected by a pre-close gate, by reason") + .build(), + exception_queue_depth: meter + .i64_gauge(LEDGER_EXCEPTION_QUEUE_DEPTH) + .with_description("Open exception-queue depth, by exception type") + .build(), + } + } + + /// Build a handle bound to the process-global meter provider. + #[must_use] + pub fn from_global() -> Self { + Self::new(&opentelemetry::global::meter(METER_NAME)) + } +} + +impl LedgerMetricsPort for LedgerMetricsMeter { + fn invoice_post(&self, result: PostResult, flow: PostFlow) { + self.invoice_post.add( + 1, + &[ + KeyValue::new("result", result.as_str()), + KeyValue::new("flow", flow.as_str()), + ], + ); + } + + fn invoice_post_duration(&self, secs: f64, flow: PostFlow) { + self.invoice_post_duration + .record(secs, &[KeyValue::new("flow", flow.as_str())]); + } + + fn payment_settle(&self, result: PostResult) { + self.payment_settle + .add(1, &[KeyValue::new("result", result.as_str())]); + } + + fn settlement_return(&self, result: PostResult) { + self.settlement_return + .add(1, &[KeyValue::new("result", result.as_str())]); + } + + fn chargeback(&self, result: PostResult) { + self.chargeback + .add(1, &[KeyValue::new("result", result.as_str())]); + } + + fn allocation(&self, result: PostResult) { + self.allocation + .add(1, &[KeyValue::new("result", result.as_str())]); + } + + fn allocation_queue_depth(&self, depth: i64) { + // Unlabelled: the sweep reads the cross-tenant total per tick. A + // per-tenant breakdown would need a `tenant` label (bounded cardinality + // concern) — deferred until a tenant-scoped depth is actually needed. + self.allocation_queue_depth.record(depth, &[]); + } + + fn credit_application(&self, result: PostResult) { + self.credit_application + .add(1, &[KeyValue::new("result", result.as_str())]); + } + + fn payment_post_duration(&self, secs: f64, flow: PostFlow) { + self.payment_post_duration + .record(secs, &[KeyValue::new("flow", flow.as_str())]); + } + + fn suspense_pending(&self, tenant: uuid::Uuid, lines: i64, oldest_age_secs: f64) { + let tenant_label = KeyValue::new("tenant", tenant.to_string()); + self.suspense_pending_lines + .record(lines, std::slice::from_ref(&tenant_label)); + self.suspense_pending_age + .record(oldest_age_secs, std::slice::from_ref(&tenant_label)); + } + + fn invariant_alarm(&self, category: &str, severity: &str) { + self.invariant_alarm.add( + 1, + &[ + KeyValue::new("category", category.to_owned()), + KeyValue::new("severity", severity.to_owned()), + ], + ); + } + + fn recognition_run_duration(&self, secs: f64) { + self.recognition_run_duration.record(secs, &[]); + } + + fn revenue_recognized_minor(&self, amount_minor: i64, revenue_stream: &str) { + // A released segment amount is `>= 0` (the `recognition_segment` + // `amount_minor >= 0` CHECK), so the conversion never clamps in practice; + // a defensively-negative value contributes 0 rather than panicking. + let amount = u64::try_from(amount_minor).unwrap_or(0); + self.revenue_recognized_minor.add( + amount, + &[KeyValue::new("stream", revenue_stream.to_owned())], + ); + } + + fn over_recognition(&self) { + self.over_recognition.add(1, &[]); + } + + fn recognition_double_credit(&self) { + self.recognition_double_credit.add(1, &[]); + } + + fn recognition_period_queue_depth(&self, depth: i64) { + // Unlabelled: a run observes the count it parked this pass (a per-tenant + // breakdown would need a `tenant` label — bounded-cardinality concern, + // deferred, mirroring `allocation_queue_depth`). + self.recognition_period_queue_depth.record(depth, &[]); + } + + fn dual_control_pending(&self, kind: &str) { + self.dual_control_pending + .add(1, &[KeyValue::new("kind", kind.to_owned())]); + } + + fn dual_control_decided(&self, kind: &str, decision: &str) { + self.dual_control_decided.add( + 1, + &[ + KeyValue::new("kind", kind.to_owned()), + KeyValue::new("decision", decision.to_owned()), + ], + ); + } + + fn dual_control_self_approval_denied(&self, kind: &str) { + self.dual_control_self_approval_denied + .add(1, &[KeyValue::new("kind", kind.to_owned())]); + } + + fn dual_control_approving(&self, count: i64) { + self.dual_control_approving.record(count, &[]); + } + + fn tamper_verify_run(&self, _tenant: uuid::Uuid, failed: bool) { + self.tamper_verify_runs.add(1, &[]); + if failed { + self.tamper_verify_failures.add(1, &[]); + } + } + + fn chain_length(&self, tenant: uuid::Uuid, length: i64) { + self.tamper_chain_length + .record(length, &[KeyValue::new("tenant", tenant.to_string())]); + } + + fn scope_freeze_active(&self, tenant: uuid::Uuid, active: i64) { + self.scope_freeze_active + .record(active, &[KeyValue::new("tenant", tenant.to_string())]); + } + + fn cross_tenant_access(&self, reason_code: &str) { + // Bound the label cardinality: unknown codes bucket to + // "other". The raw value is kept in the forensic audit record, not here. + self.cross_tenant_access.add( + 1, + &[KeyValue::new( + "reason_code", + bounded_reason_code(reason_code), + )], + ); + } + + fn reidentification(&self) { + self.reidentification.add(1, &[]); + } + + fn erasure_applied(&self) { + self.erasure_applied.add(1, &[]); + } + + fn metadata_change(&self, attribute: &str) { + self.metadata_change + .add(1, &[KeyValue::new("attribute", attribute.to_owned())]); + } + + fn audit_pack_export_duration(&self, secs: f64) { + self.audit_pack_export_duration.record(secs, &[]); + } + + fn credit_note(&self, outcome: NoteOutcome) { + self.credit_note + .add(1, &[KeyValue::new("outcome", outcome.as_str())]); + } + + fn debit_note(&self, outcome: NoteOutcome) { + self.debit_note + .add(1, &[KeyValue::new("outcome", outcome.as_str())]); + } + + fn refund(&self, phase: &str, pattern: &str) { + self.refund.add( + 1, + &[ + KeyValue::new("phase", phase.to_owned()), + KeyValue::new("pattern", pattern.to_owned()), + ], + ); + } + + fn refund_quarantine_depth(&self, depth: i64) { + // Unlabelled: the sweep reads the cross-tenant total per tick (a per-tenant + // breakdown would need a `tenant` label — bounded-cardinality concern, + // deferred, mirroring `allocation_queue_depth`). + self.refund_quarantine_depth.record(depth, &[]); + } + + fn refund_unknown_final(&self) { + self.refund_unknown_final.add(1, &[]); + } + + fn refund_clearing_balance_minor(&self, tenant: uuid::Uuid, balance_minor: i64) { + self.refund_clearing_balance.record( + balance_minor, + &[KeyValue::new("tenant", tenant.to_string())], + ); + } + + fn refund_clearing_aged_seconds(&self, tenant: uuid::Uuid, age_secs: f64) { + self.refund_clearing_aged + .record(age_secs, &[KeyValue::new("tenant", tenant.to_string())]); + } + + fn stage1_refund_orphan(&self) { + self.stage1_refund_orphan.add(1, &[]); + } + fn fx_revaluation_duration(&self, secs: f64) { + self.fx_revaluation_duration.record(secs, &[]); + } + fn fx_provider_fallback(&self, provider: &str) { + self.fx_provider_fallback + .add(1, &[KeyValue::new("provider", provider.to_owned())]); + } + fn fx_realized_minor(&self, amount_minor: i64, functional_currency: &str, direction: &str) { + // The magnitude is the non-negative functional amount on the FX_GAIN_LOSS + // line (the domain guarantees it `>= 0`); a defensively-negative value + // contributes 0 rather than panicking (mirrors `revenue_recognized_minor`). + let amount = u64::try_from(amount_minor).unwrap_or(0); + self.fx_realized_minor.add( + amount, + &[ + KeyValue::new("functional_currency", functional_currency.to_owned()), + KeyValue::new("direction", direction.to_owned()), + ], + ); + } + + fn reconciliation_variance_minor(&self, check_type: &str, variance_minor: i64) { + self.reconciliation_variance_minor.record( + variance_minor, + &[KeyValue::new("check_type", check_type.to_owned())], + ); + } + + fn reconciliation_run(&self, check_type: &str) { + self.reconciliation_runs + .add(1, &[KeyValue::new("check_type", check_type.to_owned())]); + } + + fn reconciliation_out_of_tolerance(&self, check_type: &str) { + self.reconciliation_out_of_tolerance + .add(1, &[KeyValue::new("check_type", check_type.to_owned())]); + } + + fn period_close_blocked(&self, reason: &str) { + self.period_close_blocked + .add(1, &[KeyValue::new("reason", reason.to_owned())]); + } + + fn exception_queue_depth(&self, exception_type: &str, depth: i64) { + self.exception_queue_depth + .record(depth, &[KeyValue::new("type", exception_type.to_owned())]); + } +} + +#[cfg(feature = "test-support")] +pub mod test_harness { + //! In-memory OpenTelemetry harness for asserting emitted ledger metrics. + #![allow(clippy::expect_used, clippy::missing_panics_doc, dead_code)] + + use opentelemetry::metrics::{Meter, MeterProvider}; + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + use opentelemetry_sdk::metrics::{InMemoryMetricExporter, PeriodicReader, SdkMeterProvider}; + + use super::{LedgerMetricsMeter, METER_NAME}; + + /// In-memory meter provider + exporter for unit and integration tests. + pub struct MetricsHarness { + provider: SdkMeterProvider, + exporter: InMemoryMetricExporter, + } + + impl MetricsHarness { + #[must_use] + pub fn new() -> Self { + let exporter = InMemoryMetricExporter::default(); + let provider = SdkMeterProvider::builder() + .with_reader(PeriodicReader::builder(exporter.clone()).build()) + .build(); + Self { provider, exporter } + } + + #[must_use] + pub fn meter(&self) -> Meter { + self.provider.meter(METER_NAME) + } + + /// A metrics handle bound to this harness's provider. + #[must_use] + pub fn metrics(&self) -> LedgerMetricsMeter { + LedgerMetricsMeter::new(&self.meter()) + } + + /// Flush aggregated data into the in-memory exporter. + pub fn force_flush(&self) { + self.provider + .force_flush() + .expect("test meter provider should flush"); + } + + /// Sum all matching `u64` counter data points. + #[must_use] + pub fn counter_value(&self, name: &str, expected_attrs: &[(&str, &str)]) -> u64 { + let metrics = self + .exporter + .get_finished_metrics() + .expect("in-memory exporter should be readable"); + let mut total = 0u64; + for rm in &metrics { + for sm in rm.scope_metrics() { + for metric in sm.metrics() { + if metric.name() == name + && let AggregatedMetrics::U64(MetricData::Sum(sum)) = metric.data() + { + for dp in sum.data_points() { + if attributes_match(dp.attributes(), expected_attrs) { + total += dp.value(); + } + } + } + } + } + } + total + } + + /// Read the latest matching `i64` gauge value (last write wins per + /// attribute set). Returns `0` when no matching data point exists. + #[must_use] + pub fn gauge_value(&self, name: &str, expected_attrs: &[(&str, &str)]) -> i64 { + let metrics = self + .exporter + .get_finished_metrics() + .expect("in-memory exporter should be readable"); + let mut value = 0i64; + for rm in &metrics { + for sm in rm.scope_metrics() { + for metric in sm.metrics() { + if metric.name() == name + && let AggregatedMetrics::I64(MetricData::Gauge(gauge)) = metric.data() + { + for dp in gauge.data_points() { + if attributes_match(dp.attributes(), expected_attrs) { + value = dp.value(); + } + } + } + } + } + } + value + } + + /// Sum matching histogram sample counts. + #[must_use] + pub fn histogram_count(&self, name: &str, expected_attrs: &[(&str, &str)]) -> u64 { + let metrics = self + .exporter + .get_finished_metrics() + .expect("in-memory exporter should be readable"); + let mut total = 0u64; + for rm in &metrics { + for sm in rm.scope_metrics() { + for metric in sm.metrics() { + if metric.name() == name + && let AggregatedMetrics::F64(MetricData::Histogram(hist)) = + metric.data() + { + for dp in hist.data_points() { + if attributes_match(dp.attributes(), expected_attrs) { + total += dp.count(); + } + } + } + } + } + } + total + } + } + + impl Default for MetricsHarness { + fn default() -> Self { + Self::new() + } + } + + fn attributes_match<'a>( + actual_attrs: impl Iterator, + expected: &[(&str, &str)], + ) -> bool { + let actual = actual_attrs.collect::>(); + expected.iter().all(|(k, v)| { + actual + .iter() + .any(|kv| kv.key.as_str() == *k && kv.value.as_str() == *v) + }) && actual.len() == expected.len() + } +} + +#[cfg(test)] +#[path = "metrics_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/metrics_tests.rs b/gears/bss/ledger/ledger/src/infra/metrics_tests.rs new file mode 100644 index 000000000..e714348c1 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/metrics_tests.rs @@ -0,0 +1,319 @@ +use super::test_harness::MetricsHarness; +use super::*; + +#[test] +fn invoice_post_increments_counter_and_records_duration() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.invoice_post(PostResult::Posted, PostFlow::InvoicePost); + m.invoice_post_duration(0.01, PostFlow::InvoicePost); + h.force_flush(); + assert_eq!( + h.counter_value( + "ledger_invoice_post_total", + &[("result", "posted"), ("flow", "invoice_post")] + ), + 1 + ); + assert_eq!( + h.histogram_count( + "ledger_invoice_post_duration_seconds", + &[("flow", "invoice_post")] + ), + 1 + ); +} + +#[test] +fn reversal_flow_is_counted_separately_from_invoice_post() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.invoice_post(PostResult::Posted, PostFlow::Reversal); + m.invoice_post_duration(0.01, PostFlow::Reversal); + h.force_flush(); + // The reversal does NOT count under the invoice_post flow. + assert_eq!( + h.counter_value( + "ledger_invoice_post_total", + &[("result", "posted"), ("flow", "invoice_post")] + ), + 0 + ); + assert_eq!( + h.counter_value( + "ledger_invoice_post_total", + &[("result", "posted"), ("flow", "reversal")] + ), + 1 + ); +} + +#[test] +fn payment_settle_and_allocation_counters_and_duration_are_observable() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.payment_settle(PostResult::Posted); + m.allocation(PostResult::Posted); + m.credit_application(PostResult::Posted); + m.payment_post_duration(0.01, PostFlow::Settle); + m.payment_post_duration(0.02, PostFlow::Allocate); + m.payment_post_duration(0.03, PostFlow::CreditApply); + h.force_flush(); + assert_eq!( + h.counter_value("ledger_payment_settle_total", &[("result", "posted")]), + 1 + ); + assert_eq!( + h.counter_value("ledger_allocation_total", &[("result", "posted")]), + 1 + ); + assert_eq!( + h.counter_value("ledger_credit_application_total", &[("result", "posted")]), + 1 + ); + assert_eq!( + h.histogram_count( + "ledger_payment_post_duration_seconds", + &[("flow", "settle")] + ), + 1 + ); + assert_eq!( + h.histogram_count( + "ledger_payment_post_duration_seconds", + &[("flow", "allocate")] + ), + 1 + ); + assert_eq!( + h.histogram_count( + "ledger_payment_post_duration_seconds", + &[("flow", "credit_apply")] + ), + 1 + ); +} + +#[test] +fn invariant_alarm_counter_carries_category_and_severity() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.invariant_alarm("TIE_OUT_VARIANCE", "CRITICAL"); + h.force_flush(); + assert_eq!( + h.counter_value( + "ledger_alarm_total", + &[("category", "TIE_OUT_VARIANCE"), ("severity", "CRITICAL")] + ), + 1 + ); +} + +#[test] +fn recognition_run_metrics_are_observable() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.recognition_run_duration(0.02); + m.revenue_recognized_minor(1_200, "subscription"); + m.over_recognition(); + m.recognition_double_credit(); + h.force_flush(); + assert_eq!( + h.histogram_count("ledger_recognition_run_duration_seconds", &[]), + 1 + ); + // The recognized-minor counter sums the released amount under the stream label. + assert_eq!( + h.counter_value( + "ledger_revenue_recognized_minor", + &[("stream", "subscription")] + ), + 1_200 + ); + assert_eq!(h.counter_value("ledger_over_recognition_total", &[]), 1); + assert_eq!( + h.counter_value("ledger_recognition_double_credit_total", &[]), + 1 + ); +} + +#[test] +fn bounded_reason_code_buckets_unknown_to_other() { + // Known codes pass through (case-insensitive, canonicalized). + assert_eq!( + bounded_reason_code("DISPUTE_INVESTIGATION"), + "DISPUTE_INVESTIGATION" + ); + assert_eq!( + bounded_reason_code("dispute_investigation"), + "DISPUTE_INVESTIGATION" + ); + assert_eq!(bounded_reason_code(" fraud "), "FRAUD"); + // Anything else (incl. a caller's high-cardinality junk) buckets to "other". + assert_eq!(bounded_reason_code("attacker-supplied-uuid-1"), "other"); + assert_eq!(bounded_reason_code(""), "other"); +} + +#[test] +fn cross_tenant_access_label_is_bounded() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.cross_tenant_access("totally-unbounded-12345"); + m.cross_tenant_access("DISPUTE"); + h.force_flush(); + // The junk code is bucketed; only "other" + the known "DISPUTE" appear. + assert_eq!( + h.counter_value( + "ledger_cross_tenant_access_total", + &[("reason_code", "other")] + ), + 1 + ); + assert_eq!( + h.counter_value( + "ledger_cross_tenant_access_total", + &[("reason_code", "DISPUTE")] + ), + 1 + ); + assert_eq!( + h.counter_value( + "ledger_cross_tenant_access_total", + &[("reason_code", "totally-unbounded-12345")] + ), + 0 + ); +} + +#[test] +fn credit_and_debit_note_counters_are_observable_by_outcome() { + use crate::domain::ports::metrics::NoteOutcome; + let h = MetricsHarness::new(); + let m = h.metrics(); + // Credit-note: a posted + the two block reasons each get their own outcome. + m.credit_note(NoteOutcome::Posted); + m.credit_note(NoteOutcome::BlockedSplit); + m.credit_note(NoteOutcome::BlockedHeadroom); + // Debit-note: a posted + a rejected (a debit note has no block reasons). + m.debit_note(NoteOutcome::Posted); + m.debit_note(NoteOutcome::Rejected); + h.force_flush(); + assert_eq!( + h.counter_value("ledger_credit_note_total", &[("outcome", "posted")]), + 1 + ); + assert_eq!( + h.counter_value("ledger_credit_note_total", &[("outcome", "blocked_split")]), + 1 + ); + assert_eq!( + h.counter_value( + "ledger_credit_note_total", + &[("outcome", "blocked_headroom")] + ), + 1 + ); + assert_eq!( + h.counter_value("ledger_debit_note_total", &[("outcome", "posted")]), + 1 + ); + assert_eq!( + h.counter_value("ledger_debit_note_total", &[("outcome", "rejected")]), + 1 + ); +} + +#[test] +fn refund_group_f_counters_are_observable() { + let h = MetricsHarness::new(); + let m = h.metrics(); + let tenant = uuid::Uuid::now_v7(); + // The unknown_final disposition + a stage-1 orphan are bare counters; the + // clearing balance/age are per-tenant gauges (recorded, not summed here). + m.refund_unknown_final(); + m.refund_unknown_final(); + m.stage1_refund_orphan(); + m.refund_clearing_balance_minor(tenant, 500); + m.refund_clearing_aged_seconds(tenant, 700_000.0); + h.force_flush(); + assert_eq!(h.counter_value("ledger_refund_unknown_final_total", &[]), 2); + assert_eq!(h.counter_value("ledger_stage1_refund_orphan_total", &[]), 1); +} + +#[test] +fn refund_group_g_counter_is_labelled_by_phase_and_pattern() { + let h = MetricsHarness::new(); + let m = h.metrics(); + // `ledger_refund_total{phase,pattern}` — one increment per fresh refund post. + m.refund("initiated", "A_UNALLOCATED"); + m.refund("initiated", "A_UNALLOCATED"); + m.refund("confirmed", "B_RESTORE_AR"); + // The quarantine-depth gauge (recorded, not summed). + m.refund_quarantine_depth(3); + h.force_flush(); + assert_eq!( + h.counter_value( + "ledger_refund_total", + &[("phase", "initiated"), ("pattern", "A_UNALLOCATED")], + ), + 2, + ); + assert_eq!( + h.counter_value( + "ledger_refund_total", + &[("phase", "confirmed"), ("pattern", "B_RESTORE_AR")], + ), + 1, + ); +} + +#[test] +fn reconciliation_slice7_metrics_are_observable() { + let h = MetricsHarness::new(); + let m = h.metrics(); + // Two runs of the same check type; one breaches tolerance. The variance gauge + // records the latest signed observed value (last write wins per attribute set). + m.reconciliation_run("ar_subledger_vs_gl"); + m.reconciliation_run("ar_subledger_vs_gl"); + m.reconciliation_out_of_tolerance("ar_subledger_vs_gl"); + m.reconciliation_variance_minor("ar_subledger_vs_gl", -1_500); + // A blocked close (by reason) + an exception-queue depth (by type) gauge. + m.period_close_blocked("open_exceptions"); + m.exception_queue_depth("unmatched_settlement", 4); + h.force_flush(); + assert_eq!( + h.counter_value( + "ledger_reconciliation_runs_total", + &[("check_type", "ar_subledger_vs_gl")] + ), + 2 + ); + assert_eq!( + h.counter_value( + "ledger_reconciliation_out_of_tolerance_total", + &[("check_type", "ar_subledger_vs_gl")] + ), + 1 + ); + assert_eq!( + h.gauge_value( + "ledger_reconciliation_variance_minor", + &[("check_type", "ar_subledger_vs_gl")] + ), + -1_500 + ); + assert_eq!( + h.counter_value( + "ledger_period_close_blocked_total", + &[("reason", "open_exceptions")] + ), + 1 + ); + assert_eq!( + h.gauge_value( + "ledger_exception_queue_depth", + &[("type", "unmatched_settlement")] + ), + 4 + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/payment.rs b/gears/bss/ledger/ledger/src/infra/payment.rs new file mode 100644 index 000000000..3b611d5e2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/payment.rs @@ -0,0 +1,26 @@ +//! Payment post sidecars (DB-touching, so under `infra`, never `domain` — +//! dylint DE0301). The settle / allocate orchestrators thread these as +//! `Arc` into [`crate::infra::posting::service::PostingService`]: +//! each runs inside the SAME serializable posting transaction, AFTER balance +//! projection and BEFORE the dedup row finalizes, so the payment counter writes +//! (`payment_settlement`, `payment_allocation`, `payment_allocation_refund`) +//! commit atomically with the journal entry or roll back with it. +//! +//! [`credit`] is the reusable-credit (wallet) orchestrator — grant parks pool +//! cash into the wallet, apply spends the wallet against open receivables. It is +//! sidecar-less: the wallet balance is a projector grain (the reusable-credit +//! sub-balance cache), not a payment counter table, and idempotency is the +//! engine's `(tenant, CREDIT_APPLY, credit_application_id)` dedup — so there is +//! no in-txn counter write to thread. + +pub mod allocate; +pub mod chargeback; +pub mod credit; +/// [`queue_apply`] is the deferred-apply driver (Group D): it drains queued +/// allocations (allocate-before-settlement, §4.7), re-deriving each split against +/// then-current state and posting it through the engine's queued-apply path while +/// flipping the queue row `→APPLIED`. +pub mod queue_apply; +pub mod settle; +pub mod settlement_return; +pub mod sidecar; diff --git a/gears/bss/ledger/ledger/src/infra/payment/allocate.rs b/gears/bss/ledger/ledger/src/infra/payment/allocate.rs new file mode 100644 index 000000000..47859fb7e --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/payment/allocate.rs @@ -0,0 +1,1629 @@ +//! `AllocationService` — the orchestrator that drives the pure allocation +//! domain (`crate::domain::payment::{precedence, allocation}`) through the +//! foundation engine (Pattern A apply). It records the **money-out** side of a +//! payment: a lump from the payer's unallocated pool is applied to their open +//! receivables oldest-first, draining the pool (`DR UNALLOCATED`) into AR +//! (`CR AR` per invoice). +//! +//! Sequence for one allocate: +//! 1. **gate on settlement** — the payment must be settled (the +//! `payment_settlement` row exists) and its currency must match the request. +//! 2. **read candidates** — the payer's open AR invoices for the currency +//! (oldest-first, read-only and uncapped; the size bound is enforced below on +//! the invoices the split actually touches, default [`MAX_INVOICES_PER_ALLOCATION`]). +//! 3. **decide** — resolve the tenant's effective-dated precedence policy +//! (`PaymentRepo::read_effective_policy`; oldest-first when none) and +//! `precedence::select_split` splits the lump across them under it. +//! 4. **build** the balanced Pattern-A-apply entry +//! (`allocation::build_allocation_entry`), **overwrite** its placeholder +//! header, **bind** chart `account_id`s, and **post** with the +//! [`AllocationSidecar`] (bumps `allocated_minor` under the per-payment cap +//! CHECK, inserts the `payment_allocation` rows, bumps the refund counters) — +//! all in one serializable transaction. +//! 5. **emit metrics** — `allocation` (outcome) + the payment-post duration. +//! +//! The per-payment money-out cap (`allocated_minor <= settled_minor`) is the +//! sidecar's serializable backstop — an over-cap surfaces as +//! [`DomainError::MoneyOutCapExceeded`]. Idempotent on +//! `(tenant, PAYMENT_ALLOCATE, allocation_id)`: a replay of the same +//! `allocation_id` returns the prior entry with no duplicate rows. Lives in +//! `infra` (not `domain`) because it needs repo + posting access; the domain +//! modules it calls stay pure (dylint DE0301). + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +use bss_ledger_sdk::{ + AccountClass, MappingStatus, PostEntry, PostLine, PostingRef, Side, SourceDocType, +}; +use chrono::{DateTime, Datelike, Duration, Utc}; +use sea_orm::DbErr; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::fx::realized::{ClosingLeg, realize}; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::payment::allocation::{ + AllocationInput, build_allocation_entry, validate_caller_split, +}; +use crate::domain::payment::precedence::{ + Allocated, Candidate, DEFAULT_PRECEDENCE_POLICY, PrecedenceStrategy, select_split, +}; +use crate::domain::ports::metrics::{LedgerMetricsPort, PostFlow, PostResult}; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::payment::sidecar::AllocationSidecar; +use crate::infra::posting::chart::{ChartIndex, load_chart}; +use crate::infra::posting::idempotency::{ + ClaimOutcome, IdempotencyGate, STATUS_POSTED, STATUS_QUEUED, +}; +use crate::infra::posting::service::{PostSidecar, PostedFacts, PostingService}; +use crate::infra::storage::entity::pending_event_queue; +use crate::infra::storage::repo::{NewQueueRow, PaymentRepo, PendingQueueRepo, ReferenceRepo}; + +/// Origin literal stamped on posts made through this service. +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// The deferred-apply queue flow + idempotency-dedup `flow` for a payment +/// allocation — the `PAYMENT_ALLOCATE` source-doc literal. The same literal the +/// inline post stamps on its entry header (so a queued allocation and the post +/// it later becomes share one dedup key), reused here for the `claim_queued` +/// seed and the queue row's `flow`. The early dedup lookup passes the +/// [`SourceDocType::PaymentAllocate`] enum directly (the repo maps it to this +/// same literal); a `const_eq` debug assert in `claim_queued`'s caller would be +/// redundant since `SourceDocType::as_str` is the single source of truth. +/// `as_str` is not `const`, so this can't be derived from the enum in a `const` +/// initializer — it is the literal, kept in lockstep with the enum by the +/// round-trip test in `enums.rs`. +const FLOW_PAYMENT_ALLOCATE: &str = "PAYMENT_ALLOCATE"; + +/// Upper bound on the number of invoices a single allocation may **touch** (the +/// AR legs it posts). The bound guards the WRITE, not the read: each touched +/// invoice is one CR `AR` line, so an allocation posting more than this would +/// risk the engine's per-entry line ceiling and an unbounded transaction. It is +/// therefore enforced on the computed split (the invoices that receive a +/// positive amount), NOT on the candidate set: a payer with a large open-invoice +/// backlog whose payment only reaches a handful of them allocates fine. A split +/// exceeding this — a lump large enough to pay > 500 invoices at once — is +/// rejected with [`DomainError::AllocationTooLarge`] (§Bounds — chunked +/// continuations for the genuinely-> `MAX` case are a tracked follow-up). +pub const MAX_INVOICES_PER_ALLOCATION: usize = 500; + +/// Audit `precedence_policy_ref` stamped on a Mode B (caller-computed) split +/// (§4.4 F-5). A distinct sentinel — NOT one of the +/// [`PrecedenceStrategy::policy_ref`](crate::domain::payment::precedence::PrecedenceStrategy::policy_ref) +/// ids — so the allocation's audit trail records that the split was supplied by +/// the caller and validated, not decided by a precedence policy. +const CALLER_SPLIT_POLICY_REF: &str = "caller-split.v1"; + +/// One allocate request: apply `lump_minor` of the settled payment's +/// unallocated pool to the payer's open receivables. +pub struct AllocateRequest { + /// The seller tenant whose ledger this posts into. + pub tenant_id: Uuid, + /// The tenant whose receivables are paid (the pool owner / single payer). + pub payer_tenant_id: Uuid, + /// External payment identity — the settled payment whose pool this drains. + pub payment_id: String, + /// Allocation identity — the `PAYMENT_ALLOCATE` idempotency business id. + pub allocation_id: Uuid, + /// Amount to apply from the pool, in minor units. + pub lump_minor: i64, + /// ISO currency of the allocation (must match the settlement currency). + pub currency: String, + /// Optional invoice to pay FIRST (jumps to the front of the oldest-first + /// order); ignored when it names no open candidate. Only consulted on the + /// precedence path — a caller-computed split (`caller_splits`) bypasses the + /// decision entirely, so the hint is moot there. + pub hint_invoice_id: Option, + /// Mode B escape hatch (§4.4 F-5): an explicit caller-computed per-invoice + /// split. `Some` ⇒ the precedence decision is SKIPPED and these shares are + /// validated against the open candidates instead (same caps / no-negative / + /// presence the decided path is subject to); `None` ⇒ the unchanged + /// precedence path decides the split. + pub caller_splits: Option>, +} + +/// The result of an allocate: either it posted inline (the payment was settled) +/// or it was durably queued for a later drain (the payment was not yet settled — +/// §4.7 allocation-before-settlement). The two arms drive the SDK/REST 201-vs-202 +/// split (the local client maps them onto `bss_ledger_sdk::AllocateOutcome`). +#[derive(Debug)] +pub enum AllocationOutcome { + /// The payment was settled: the allocation posted inline. + Applied(AppliedAllocation), + /// The payment was NOT yet settled: the request was enqueued (HTTP 202). + Queued(QueuedAllocation), +} + +/// An allocation that posted inline: the posting handle, the per-invoice splits +/// applied (so the caller can surface what was applied where), and the +/// `precedence_policy_ref` stamped on the persisted rows — a precedence policy +/// id for the decided path, or `caller-split.v1` for a Mode B caller-computed +/// split. (The fields of the pre-Group-C `AllocationOutcome` struct.) +#[derive(Debug)] +pub struct AppliedAllocation { + pub posting: PostingRef, + pub splits: Vec, + pub policy_ref: String, +} + +/// An allocation that was deferred because the payment was not yet settled: the +/// request is durably on `ledger_pending_event_queue` and the drain (Group D) +/// will apply it once the settlement lands. Carries the queue key (`flow` + +/// `business_id`) and the `queued_at` instant — the surface for the REST 202 +/// `allocation-queued` body. No `PostingRef`: nothing has posted yet. +#[derive(Debug)] +pub struct QueuedAllocation { + /// The deferred-apply queue flow (the `PAYMENT_ALLOCATE` literal). + pub flow: String, + /// The queue/dedup business id — the allocation's `allocation_id` (string). + pub business_id: String, + /// When the intake durably enqueued the request. + pub queued_at: DateTime, +} + +/// The financial-key snapshot of an allocate request, persisted as the queue +/// row's `payload` jsonb at intake and re-read by the drain (Group D) to decide +/// the split + post. PII-free by construction: it carries only the ledger +/// identities and money fields the apply needs — tenant + payer ids, the payment +/// / allocation ids, the lump + currency, the optional precedence hint, and the +/// optional Mode B caller split — never names, addresses, or free-text. +/// +/// It is ALSO the basis for the queued-allocation dedup `payload_hash`: the +/// per-invoice split is only decided at apply, so the inline post's entry-based +/// [`IdempotencyGate::payload_hash`] cannot be computed at intake (the entry +/// doesn't exist yet). Instead the request is hashed via +/// [`IdempotencyGate::content_hash`] over this struct's canonical JSON +/// (`serde_json::to_string`), so a conflicting reuse of the same `allocation_id` +/// with a *different* request still flips the hash. Lives here (not `domain`) +/// next to the service that writes it; Group D's apply reads the same type. +/// +/// The Mode B caller split is carried as [`QueuedSplit`] (a local serde mirror of +/// the domain [`Allocated`]) rather than `Allocated` itself, so the domain stays +/// serde-free (pure); Group D converts it back to `Allocated` for +/// `validate_caller_split` at apply. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct QueuedAllocationPayload { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub payment_id: String, + pub allocation_id: Uuid, + pub lump_minor: i64, + pub currency: String, + pub hint_invoice_id: Option, + pub caller_splits: Option>, +} + +/// A serde mirror of the domain [`Allocated`] (`invoice_id` + `amount_minor`), +/// carried in [`QueuedAllocationPayload::caller_splits`]. Kept local to `infra` +/// so the domain `Allocated` need not derive serde — Group D maps it back to +/// `Allocated` at apply. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct QueuedSplit { + pub invoice_id: String, + pub amount_minor: i64, +} + +impl AllocateRequest { + /// Reconstruct the request from a queued payload at apply time (Group D), + /// mapping each Mode B [`QueuedSplit`] back to the domain [`Allocated`]. The + /// inverse of [`QueuedAllocationPayload::from_request`]; the round-trip + /// preserves every field the decide/build path consults. + fn from_payload(payload: QueuedAllocationPayload) -> Self { + Self { + tenant_id: payload.tenant_id, + payer_tenant_id: payload.payer_tenant_id, + payment_id: payload.payment_id, + allocation_id: payload.allocation_id, + lump_minor: payload.lump_minor, + currency: payload.currency, + hint_invoice_id: payload.hint_invoice_id, + caller_splits: payload.caller_splits.map(|splits| { + splits + .into_iter() + .map(|s| Allocated { + invoice_id: s.invoice_id, + amount_minor: s.amount_minor, + }) + .collect() + }), + } + } +} + +impl QueuedAllocationPayload { + /// Snapshot an [`AllocateRequest`] into the PII-free queue payload (by + /// reference — the request is still needed to build the `Queued` handle). + fn from_request(req: &AllocateRequest) -> Self { + Self { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id.clone(), + allocation_id: req.allocation_id, + lump_minor: req.lump_minor, + currency: req.currency.clone(), + hint_invoice_id: req.hint_invoice_id.clone(), + caller_splits: req.caller_splits.as_ref().map(|splits| { + splits + .iter() + .map(|s| QueuedSplit { + invoice_id: s.invoice_id.clone(), + amount_minor: s.amount_minor, + }) + .collect() + }), + } + } +} + +/// What the intake transaction committed — carried out of the `db.transaction` +/// closure (which must return `Send + 'static`) so the post-txn code can build +/// the `Queued` handle. `Enqueued` is the first intake (it owns the `queued_at` +/// it just wrote); `AlreadyQueued` is the race-lost replay (the prior intake's +/// queue row holds the authoritative `queued_at`, read out-of-txn afterwards). +enum IntakeOutcome { + Enqueued { + queued_at: DateTime, + }, + AlreadyQueued, + /// A concurrent/retried intake reused the same `allocation_id` with a + /// DIFFERENT request payload (the dedup row's `payload_hash` differs) — an + /// idempotency-key conflict, surfaced after the txn as + /// [`DomainError::IdempotencyConflict`]. + Conflict, +} + +/// The decided + chart-bound allocation entry produced by +/// [`AllocationService::decide_and_build_entry`], shared by the inline post path +/// and the deferred-apply path. Carries the bound [`PostEntry`], the per-invoice +/// `splits`, the audit `policy_ref`, and the `total` (the sum the sidecar bumps +/// `allocated_minor` by under the per-payment cap CHECK). +struct BuiltAllocation { + entry: PostEntry, + splits: Vec, + policy_ref: String, + total: i64, +} + +/// The result of applying ONE queued allocation row ([`AllocationService::apply_queued_row`]). +/// Distinct from [`AllocationOutcome`] because the apply has a third terminal +/// shape the intake-time allocate cannot: the payment may still be unsettled. +#[derive(Debug)] +pub enum ApplyOutcome { + /// The settlement landed and the queued allocation posted — the queue row was + /// flipped `→APPLIED` atomically in the post txn. + Applied(PostingRef), + /// The payment is STILL not settled: leave the row `QUEUED`, do NOT bump + /// attempts (this is not a failure — a settle/sweep will retry once the + /// settlement exists). + NotReady, + /// A cap / precondition rejected the apply at apply-time (a re-evaluated cap: + /// `MoneyOutCapExceeded` / `NegativeBalance` / `PeriodClosed` / + /// `AllocationSplitInvalid`). The caller bumps `attempts` and leaves the row + /// `QUEUED` for a later retry (alarm/quarantine is Phase 5). Carries the + /// rejection for logging. + Blocked(DomainError), +} + +/// Summary of one [`AllocationService::drain`] pass over a tenant's queued +/// allocations. +#[derive(Debug, Default)] +pub struct DrainReport { + /// Rows that posted + flipped `→APPLIED` this pass. + pub applied: u64, + /// Rows left `QUEUED` because the payment is not yet settled (no attempt bump). + pub not_ready: u64, + /// Rows left `QUEUED` after an apply-time cap/precondition rejection + /// (attempts bumped). + pub blocked: u64, +} + +/// Orchestrates the allocation domain (Pattern A apply) over the foundation +/// engine. +pub struct AllocationService { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + repo: PaymentRepo, + // The deferred-apply queue (work-state SoT): an allocate of a not-yet-settled + // payment is enqueued here at intake (§4.7) and drained later (Group D). + pending_queue: PendingQueueRepo, + // One database provider, retained so the intake enqueue can open its own + // `db.transaction` (the dedup claim + queue insert in one txn). The other + // repos are out-of-txn readers; this is the only writer that needs a txn. + db: DBProvider, + metrics: Arc, + /// Max invoices one allocation may touch (the posted AR legs). Defaults to + /// [`MAX_INVOICES_PER_ALLOCATION`]; overridden from `payments` config via + /// [`Self::with_max_invoices_per_allocation`]. + max_invoices: usize, +} + +impl AllocationService { + /// Build the service over one database provider, the event publisher + /// (threaded into the posting engine), and the metrics sink. Same deps as + /// [`crate::infra::payment::settle::SettlementService`]. The touched-invoice + /// cap defaults to [`MAX_INVOICES_PER_ALLOCATION`]; override it from config + /// with [`Self::with_max_invoices_per_allocation`]. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + let posting = PostingService::new(db.clone(), publisher); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + let repo = PaymentRepo::new(db.clone()); + let pending_queue = PendingQueueRepo::new(db.clone()); + Self { + posting, + reference, + resolver, + repo, + pending_queue, + db, + metrics, + max_invoices: MAX_INVOICES_PER_ALLOCATION, + } + } + + /// Override the per-allocation touched-invoice cap (from `payments` config). + /// Builder form; defaults to [`MAX_INVOICES_PER_ALLOCATION`]. The value is + /// bounded at config-validation time (`1..=MAX_INVOICES_PER_ALLOCATION_CEILING`). + #[must_use] + pub fn with_max_invoices_per_allocation(mut self, max_invoices: usize) -> Self { + self.max_invoices = max_invoices; + self + } + + /// Allocate `lump_minor` of a payment's pool to the payer's open AR. When the + /// payment is already settled this posts inline and returns + /// [`AllocationOutcome::Applied`] (the posting handle + the decided splits); + /// when it is NOT yet settled the request is durably queued (§4.7 + /// allocation-before-settlement) and returns [`AllocationOutcome::Queued`] + /// (the queue key + `queued_at`). + /// + /// On an inline post emits `allocation(Posted | Replayed)` + the payment-post + /// duration; every rejection emits `allocation(Rejected)` + the duration. A + /// queue (enqueue) is NOT a post, so it records only the duration — no + /// `allocation()` outcome (see [`Self::record`]). + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when there is no open AR to allocate (only + /// reachable on the settled path — an unsettled payment queues, it does not + /// reject); [`DomainError::AllocationCurrencyMismatch`] when the request + /// currency differs from the settlement currency; + /// [`DomainError::AllocationTooLarge`] when the computed split touches more + /// invoices than the configured cap (default [`MAX_INVOICES_PER_ALLOCATION`]); + /// [`DomainError::AllocationSplitInvalid`] + /// when a caller-computed split (Mode B) names an unknown/closed invoice, + /// over-allocates an invoice or the lump, repeats an invoice, or is + /// non-positive; [`DomainError::MoneyOutCapExceeded`] when the allocation + /// would push `allocated_minor` past `settled_minor`; any foundation + /// rejection or [`DomainError::Internal`] on an infra fault. + pub async fn allocate( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + input: AllocateRequest, + ) -> Result { + let started = Instant::now(); + let result = self.allocate_inner(ctx, scope, input).await; + self.record(&result, started); + result + } + + /// Run the allocate sequence (no metrics — the public wrapper records them). + async fn allocate_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + input: AllocateRequest, + ) -> Result { + // 0. Early dedup short-circuit (BEFORE the settlement gate), mirroring the + // credit short-circuit `CreditApplicationService::replay_if_posted`. A + // still-`QUEUED` dedup row means a prior intake already enqueued THIS + // allocation: return the same `Queued` handle (idempotent replay during + // the queued window) — regardless of whether the payment has since + // settled, because the drain (Group D) owns applying it. A `POSTED` row + // means the allocation already applied (inline, OR queued-then-drained + // by Group D): return an `Applied` replay with EMPTY splits (the splits + // were returned on the first call; this mirrors the credit + // short-circuit's empty-vec replay) so a queued-then-applied allocation + // replays cleanly here instead of conflicting in `post()`. A `CLAIMED` + // (in-flight inline) / absent row falls through to the settlement gate. + if let Some(outcome) = self.replay_short_circuit(scope, &input).await? { + return Ok(outcome); + } + + // 1. Read the settlement. ABSENT ⇒ the payment is not yet settled: the + // allocate is durably queued for a later drain (§4.7), not rejected. + // PRESENT ⇒ the inline post path (its currency must match the request). + let Some(settlement) = self + .repo + .read_settlement(scope, input.tenant_id, &input.payment_id) + .await + .map_err(|e| DomainError::Internal(format!("read settlement: {e}")))? + else { + let queued = self.enqueue_allocation(scope, &input).await?; + return Ok(AllocationOutcome::Queued(queued)); + }; + if settlement.currency != input.currency { + return Err(DomainError::AllocationCurrencyMismatch(format!( + "allocation currency {} != settlement currency {} for payment {}", + input.currency, settlement.currency, input.payment_id + ))); + } + + // 2.–4. Decide the split + build the bound entry (caps re-read against + // current state). Shared with the deferred-apply path. + let built = self.decide_and_build_entry(ctx, scope, &input).await?; + let BuiltAllocation { + entry, + splits, + policy_ref, + total, + } = built; + + // 5. Post inline with the allocation sidecar (bumps allocated_minor under + // the cap CHECK + inserts rows). + let sidecar: Arc = Arc::new(AllocationSidecar { + tenant: input.tenant_id, + payer: input.payer_tenant_id, + payment_id: input.payment_id.clone(), + allocation_id: input.allocation_id, + currency: input.currency.clone(), + splits: splits.clone(), + total_minor: total, + policy_ref: policy_ref.clone(), + }); + let request_hash = allocation_request_hash(&input)?; + let posting = self + .post_bound(ctx, scope, entry, sidecar, request_hash) + .await?; + Ok(AllocationOutcome::Applied(AppliedAllocation { + posting, + splits, + policy_ref, + })) + } + + /// Decide the per-invoice split and build the chart-bound, header-overwritten + /// allocation [`PostEntry`] — steps 2–4 of [`Self::allocate_inner`], factored + /// out so the deferred-apply path ([`Self::apply_queued_row`]) re-runs the + /// EXACT same decide/validate/build sequence. Reading the open AR + cap, + /// resolving precedence (or validating a caller split), building the entry, + /// overwriting its placeholder header, and binding chart accounts all happen + /// here — so the caps are re-evaluated against THEN-CURRENT state every time + /// this runs (intake-time state is never trusted; §4.7). + /// + /// # Errors + /// [`DomainError::AllocationTooLarge`] / [`DomainError::AllocationSplitInvalid`] + /// / [`DomainError::InvalidRequest`] (empty split) / [`DomainError::AccountClosed`] + /// (unprovisioned chart) — the same rejections the inline path raises. + async fn decide_and_build_entry( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + input: &AllocateRequest, + ) -> Result { + // 2. Read the open AR candidate set (oldest-first). Read-only and + // uncapped — the size bound is enforced below on the touched split. + let rows = self + .repo + .list_open_ar_invoices( + scope, + input.tenant_id, + input.payer_tenant_id, + &input.currency, + ) + .await + .map_err(|e| DomainError::Internal(format!("list open ar invoices: {e}")))?; + // NOTE: the candidate read is intentionally NOT capped — it is a read-only + // SELECT, cheap even for a large backlog. The size bound is enforced below + // on the computed split (the invoices actually touched), which is what + // drives the posted entry's AR-leg count. + // Slice 5 (F1): snapshot each candidate's carried `(transaction, functional)` + // balance by invoice_id BEFORE `rows` is consumed into precedence + // `Candidate`s. The realized-FX poster (`apply_realized_fx`) reads it to + // value each AR leg's close at the grain's WAC carried rate. Empty / all- + // `None`-functional ⇒ a single-currency close (the poster no-ops). + let ar_carried: HashMap)> = rows + .iter() + .map(|r| { + ( + r.invoice_id.clone(), + (r.balance_minor, r.functional_balance_minor), + ) + }) + .collect(); + + // 3. Resolve the precedence policy in effect now (latest + // effective_from <= now) — used by the precedence path to decide the + // per-invoice splits (the hint still jumps the front). No + // effective-dated row ⇒ the oldest-first default, and the audit ref + // stays `DEFAULT_PRECEDENCE_POLICY` (byte-stable with the 2a + // behaviour). Mode B (a caller-computed split) ignores this and + // validates the caller's shares instead (see the branch below). + let effective = self + .repo + .read_effective_policy(scope, input.tenant_id, Utc::now()) + .await + .map_err(|e| DomainError::Internal(format!("read precedence policy: {e}")))?; + let (strategy, policy_ref) = match effective { + Some((strategy, version)) => (strategy, format!("{}#{version}", strategy.policy_ref())), + None => ( + PrecedenceStrategy::OldestFirst, + DEFAULT_PRECEDENCE_POLICY.to_owned(), + ), + }; + let candidates: Vec = rows + .into_iter() + .map(|r| Candidate { + invoice_id: r.invoice_id, + open_minor: r.balance_minor, + original_posted_at: r.original_posted_at, + }) + .collect(); + // Mode B (§4.4 F-5): a caller-computed split SKIPS the precedence + // decision and is validated against the open candidates instead — same + // caps / no-negative / presence the decided path is bound by. The audit + // ref then records `caller-split.v1` (not the resolved policy) so the + // trail shows the split was caller-provided, not policy-decided. The + // resolved `policy_ref` only governs the unchanged precedence path. + let (splits, policy_ref) = match &input.caller_splits { + Some(caller) => ( + validate_caller_split(&candidates, caller, input.lump_minor)?, + CALLER_SPLIT_POLICY_REF.to_owned(), + ), + None => ( + select_split( + &candidates, + input.lump_minor, + input.hint_invoice_id.as_deref(), + strategy, + ), + policy_ref, + ), + }; + if splits.is_empty() { + return Err(DomainError::InvalidRequest( + "no open AR to allocate".to_owned(), + )); + } + // Size bound on the WRITE: reject only when the split itself touches more + // than `self.max_invoices` invoices (each is a posted AR leg), so a payer + // with a large open backlog whose payment reaches only a few of them + // allocates fine. A lump large enough to pay > cap invoices at once still + // rejects here (chunked continuations are a tracked follow-up). The cap is + // `payments.max_invoices_per_allocation` (default MAX_INVOICES_PER_ALLOCATION). + if splits.len() > self.max_invoices { + return Err(DomainError::AllocationTooLarge(format!( + "allocation touches {} invoices (max {})", + splits.len(), + self.max_invoices + ))); + } + let total: i64 = splits.iter().map(|s| s.amount_minor).sum(); + + // 4. Build the balanced Pattern-A-apply entry, overwrite the placeholder + // header, and bind chart account_ids. + let mut entry = build_allocation_entry(&AllocationInput { + tenant_id: input.tenant_id, + payer_tenant_id: input.payer_tenant_id, + payment_id: input.payment_id.clone(), + allocation_id: input.allocation_id, + currency: input.currency.clone(), + splits: splits.clone(), + // 2a: allocation posts effective-now; thread a request field here if + // a back-dated allocation is ever needed. + effective_at: None, + })?; + overwrite_header(&mut entry, ctx); + + let chart = load_chart(&self.reference, scope, entry.tenant_id).await?; + for line in &mut entry.lines { + line.account_id = resolve_line(&chart, line).ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + line.account_class.as_str(), + line.revenue_stream, + line.currency + )) + })?; + } + + // 5. Slice 5 (F1): realized FX on a cross-currency close. When the relieved + // grains carry a functional balance, stamp each line's functional relief + // at its grain's carried value (WAC pro-rata for a partial close) and + // append the net FX_GAIN_LOSS line so the functional column balances. + // A single-currency close (functional NULL on the pool grain) is a no-op + // — functional stays NULL, byte-green. Runs on BOTH the inline and the + // deferred-apply paths (this method is shared), so a queued allocation + // drained after a rate move also posts the correct realized FX. + self.apply_realized_fx(scope, input, &mut entry, &ar_carried, total, &chart) + .await?; + + Ok(BuiltAllocation { + entry, + splits, + policy_ref, + total, + }) + } + + /// Post realized FX onto a cross-currency allocation close (Slice 5 F1, design + /// §3.5 / §4.4). The allocation relieves the payer's unallocated pool + /// (DR UNALLOCATED) into their open receivables (CR AR per split); when those + /// grains were posted at a rate ≠ today's, the functional value relieved on the + /// DR leg differs from the sum relieved on the CR legs, and that net imbalance + /// is the realized gain/loss. + /// + /// Reads each relieved grain's carried functional value (the pool via + /// [`PaymentRepo::read_unallocated_carried`], each AR invoice via the + /// `ar_carried` snapshot) and feeds the closing legs — IN entry-line order, so + /// the per-leg relief maps 1:1 back onto the lines — to the pure + /// [`realize`](crate::domain::fx::realized::realize). It then stamps each + /// existing line's `functional_amount_minor` / `functional_currency` and + /// appends the single net `FX_GAIN_LOSS` functional-only line (`amount_minor = + /// 0`) bound via `ChartIndex::resolve(FxGainLoss, functional_ccy, None)`. The + /// projector closes each grain's functional column from these stamped lines and + /// the dual-column commit trigger validates the functional balance (fail-loud). + /// + /// **No realized FX** (returns leaving functional NULL) when: + /// - the pool grain carries no functional balance — a single-currency close + /// (the cross-currency detect, design decision 8: functional is stamped ⟺ + /// the position is cross-currency); or + /// - the allocation would drain more than the pool holds — an invalid allocate + /// the projector rejects with `NegativeBalance`; skipping realized FX lets + /// that cleaner rejection surface rather than a `realize` range misuse. + /// + /// No new base rate is locked — an allocation close relieves at the **carried** + /// rate (design §4.3); `rate_snapshot_ref` stays `None` on the allocate entry + /// (provenance is the carried grains). `MAX_INVOICES_PER_ALLOCATION` bounds the + /// AR leg count, so the FX entry never overruns the engine's per-entry ceiling. + /// + /// # Errors + /// [`DomainError::AccountClosed`] when no `FX_GAIN_LOSS` account is provisioned + /// for the functional currency; [`DomainError::Internal`] on a carried-read + /// fault or a `realize` misuse (a malformed closing leg — an internal + /// invariant breach, not a business condition). + async fn apply_realized_fx( + &self, + scope: &AccessScope, + input: &AllocateRequest, + entry: &mut PostEntry, + ar_carried: &HashMap)>, + total: i64, + chart: &ChartIndex, + ) -> Result<(), DomainError> { + // Read the pool's carried functional value (the DR UNALLOCATED leg's grain). + let pool = self + .repo + .read_unallocated_carried( + scope, + input.tenant_id, + input.payer_tenant_id, + &input.currency, + ) + .await + .map_err(|e| DomainError::Internal(format!("read unallocated carried: {e}")))?; + + // Cross-currency detect (design decision 8): the relieved pool grain carries + // a functional balance (S2 settle stamped it). NULL ⇒ a single-currency + // close: leave every functional column NULL (byte-green) and post no FX line. + let (Some(pool_functional), Some(functional_ccy)) = ( + pool.functional_balance_minor, + pool.functional_currency.clone(), + ) else { + return Ok(()); + }; + + // Pool-underflow guard: an allocate draining more than the pool holds is + // invalid — the projector rejects it with `NegativeBalance`. Skip realized + // FX so that cleaner rejection surfaces rather than a `realize` + // `RelievedOutOfRange` mapped to a 500 (the close never posts either way). + if total > pool.balance_minor { + return Ok(()); + } + + // Build the closing legs IN entry-line order (DR UNALLOCATED first, then one + // CR AR per split — `build_allocation_entry`'s order) so `realize`'s per-leg + // relief maps 1:1 back onto `entry.lines`. Each AR leg is valued at its + // invoice's carried functional, with an identity fallback (`functional ≡ + // transaction`) for a single-currency AR grain so an all-cross entry never + // leaves a line functional-NULL (the trigger's all-or-nothing rule). + let mut legs: Vec = Vec::with_capacity(entry.lines.len()); + for line in &entry.lines { + let (carried_transaction, carried_functional) = match line.account_class { + AccountClass::Unallocated => (pool.balance_minor, pool_functional), + AccountClass::Ar => { + let invoice_id = line.invoice_id.as_deref().ok_or_else(|| { + DomainError::Internal( + "realized FX: AR allocation line carries no invoice_id".to_owned(), + ) + })?; + let (bal, func) = ar_carried.get(invoice_id).copied().ok_or_else(|| { + DomainError::Internal(format!( + "realized FX: no carried AR grain for invoice {invoice_id}" + )) + })?; + (bal, func.unwrap_or(bal)) + } + other => { + return Err(DomainError::Internal(format!( + "realized FX: unexpected allocation line class {}", + other.as_str() + ))); + } + }; + legs.push(ClosingLeg { + side: line.side, + carried_functional_minor: carried_functional, + carried_transaction_minor: carried_transaction, + relieved_transaction_minor: line.amount_minor, + }); + } + + // Compute the per-leg functional relief + the net FX_GAIN_LOSS line. + let realized = + realize(&legs).map_err(|e| DomainError::Internal(format!("realized FX: {e}")))?; + + // Stamp the functional relief onto each existing line (same order as legs). + for (line, func) in entry.lines.iter_mut().zip(&realized.leg_functional_minor) { + line.functional_amount_minor = Some(*func); + line.functional_currency = Some(functional_ccy.clone()); + } + + // Append the net FX_GAIN_LOSS functional-only line so the functional column + // balances. `None` ⇒ closed at the carried rate: the relief legs above + // already net to zero in the functional column, so no FX line is needed + // (but the legs were still stamped — all-or-nothing for a cross-currency + // entry). + if let Some(fx_line) = realized.fx_line { + let account_id = chart + .resolve(AccountClass::FxGainLoss, &functional_ccy, None) + .ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned FX_GAIN_LOSS account for functional currency {functional_ccy}" + )) + })?; + entry.lines.push(fx_gain_loss_line( + input, + account_id, + &functional_ccy, + fx_line.side, + fx_line.functional_minor, + )); + // Realized-FX amount metric (§9): the sign-by-role direction is the + // FX_GAIN_LOSS side — a CREDIT is a gain, a DEBIT a loss (the same + // convention `RealizedFxLine` documents); the magnitude is the + // non-negative functional amount. Emitted only on a real FX line (a + // carried-rate close posts none). This is the fresh-build path (the + // replay short-circuit returned earlier), so it counts once per + // cross-currency close; a subsequent post rollback is rare and would + // over-count this volume signal by one (acceptable for an observability + // counter — the money truth is the posted FX_GAIN_LOSS line, not this). + let direction = match fx_line.side { + Side::Credit => "gain", + Side::Debit => "loss", + }; + self.metrics + .fx_realized_minor(fx_line.functional_minor, &functional_ccy, direction); + } + + Ok(()) + } + + /// Early dedup short-circuit for an allocation replay (the §4.7 counterpart + /// to `CreditApplicationService::replay_if_posted`), reading the + /// `(tenant, PAYMENT_ALLOCATE, allocation_id)` dedup status ONCE: + /// + /// - `QUEUED` ⇒ a prior intake already enqueued this allocation: read the + /// queue row's `queued_at` (the dedup row carries none) and return the + /// [`AllocationOutcome::Queued`] handle (idempotent replay during the + /// queued window). + /// - `POSTED` ⇒ the allocation already applied — inline, OR queued-then- + /// drained by the apply path. Return an [`AllocationOutcome::Applied`] + /// *replay* with EMPTY splits and an empty `policy_ref`: the splits/ref were + /// returned on the first (posting) call, and a replay only needs to confirm + /// the prior posting handle. This mirrors the credit short-circuit's + /// empty-vec replay, and crucially lets a queued-then-applied allocation + /// replay cleanly here instead of falling through and tripping the engine's + /// replay path inside `post()`. + /// - `CLAIMED` (an in-flight inline post) / absent ⇒ `None`: fall through to + /// the settlement gate. + /// + /// Runs out-of-txn (racy by nature, like `lookup_dedup_status`); the + /// authoritative dedup is the intake txn's `claim_queued` (or the apply txn's + /// `read`), so a `None` that races a concurrent first intake still serializes + /// there. + async fn replay_short_circuit( + &self, + scope: &AccessScope, + input: &AllocateRequest, + ) -> Result, DomainError> { + let business_id = input.allocation_id.to_string(); + let dedup = self + .repo + .lookup_dedup_status( + scope, + input.tenant_id, + SourceDocType::PaymentAllocate, + &business_id, + ) + .await + .map_err(|e| DomainError::Internal(format!("allocate dedup lookup: {e}")))?; + let Some((status, result_entry_id, stored_hash)) = dedup else { + return Ok(None); + }; + // A replay must carry the SAME request payload. A reuse of `allocation_id` + // with a different lump / currency / payer / hint / splits is an + // idempotency-key conflict, not a replay — reject it rather than silently + // returning the prior result. (A `CLAIMED` in-flight inline post falls + // through below; the engine's in-txn claim makes the same comparison.) + if (status == STATUS_POSTED || status == STATUS_QUEUED) + && stored_hash != allocation_request_hash(input)? + { + return Err(DomainError::IdempotencyConflict(format!( + "allocation_id {} reused with a different payload", + input.allocation_id + ))); + } + if status == STATUS_POSTED { + // Applied already (inline or queued-then-drained). A POSTED row always + // carries a finalized `result_entry_id` (the finalize stamps it in the + // same txn that flips the status); guard the invariant rather than + // fabricate a nil id. + let entry_id = result_entry_id.ok_or_else(|| { + DomainError::Internal(format!( + "dedup POSTED but no result_entry_id for \ + ({}, {FLOW_PAYMENT_ALLOCATE}, {business_id})", + input.tenant_id + )) + })?; + return Ok(Some(AllocationOutcome::Applied(AppliedAllocation { + posting: PostingRef { + entry_id, + // Replay: the sequence is not re-read (callers key on the id — + // mirrors the engine's own replay `PostingRef`). + created_seq: 0, + replayed: true, + }, + // Empty on replay: the splits + policy ref were returned on the + // first (posting) call (mirrors the credit short-circuit). + splits: vec![], + policy_ref: String::new(), + }))); + } + if status != STATUS_QUEUED { + // CLAIMED (an in-flight inline post): not a replay — fall through. + return Ok(None); + } + // The dedup says QUEUED; surface `queued_at` from the work-state queue + // row. A missing row here would be an invariant breach (both rows are + // written in the same intake txn) — surface it rather than fabricate. + let row = self + .pending_queue + .get(scope, input.tenant_id, FLOW_PAYMENT_ALLOCATE, &business_id) + .await + .map_err(|e| DomainError::Internal(format!("allocate queue read: {e}")))? + .ok_or_else(|| { + DomainError::Internal(format!( + "dedup QUEUED but no queue row for ({}, {FLOW_PAYMENT_ALLOCATE}, {business_id})", + input.tenant_id + )) + })?; + Ok(Some(AllocationOutcome::Queued(QueuedAllocation { + flow: FLOW_PAYMENT_ALLOCATE.to_owned(), + business_id, + queued_at: row.queued_at, + }))) + } + + /// Intake for an allocate of a not-yet-settled payment (§4.7): claim the + /// dedup row as `QUEUED` and insert the work-state queue row, in ONE + /// `db.transaction` (mirrors `infra/jobs/period_open.rs`'s txn shape + its + /// `DbError::Sea(DbErr::Custom(...))` error encoding — the closure error type + /// is fixed to `DbError`, so a `RepoError` is encoded as `DbErr::Custom` and + /// surfaced after the transaction). + /// + /// The dedup `payload_hash` is **request-based** (`content_hash` over the + /// canonical JSON of the [`QueuedAllocationPayload`]) — NOT the inline post's + /// entry-based `payload_hash` — because the per-invoice split is only decided + /// at apply, so the entry doesn't exist at intake. `claim_queued`'s `Replay` + /// makes the intake idempotent: a concurrent / retried intake that loses the + /// claim race returns the existing `Queued` handle instead of double-enqueuing + /// (the early `replay_short_circuit` already caught the common retry; this + /// guards the race that slips past it). + async fn enqueue_allocation( + &self, + scope: &AccessScope, + input: &AllocateRequest, + ) -> Result { + let now = Utc::now(); + let business_id = input.allocation_id.to_string(); + let payload = QueuedAllocationPayload::from_request(input); + // Canonical JSON of the PII-free payload: the queue row's `payload` jsonb + // AND (hashed) the request-based dedup key. `to_value` cannot fail for a + // plain derive-Serialize struct of scalars/strings; map defensively. + let payload_json = serde_json::to_value(&payload) + .map_err(|e| DomainError::Internal(format!("serialize queue payload: {e}")))?; + let payload_hash = allocation_request_hash(input)?; + + let tenant = input.tenant_id; + let gate = IdempotencyGate::new(); + // Own everything the closure needs (the closure is `FnOnce`, so the + // captures move straight into the async future — no inner re-clone). The + // intake-txn shape + the `DbError::Sea(DbErr::Custom(...))` error encoding + // mirror `infra/jobs/period_open.rs`. + let scope_owned = scope.clone(); + let outcome = self + .db + .transaction(move |txn| { + Box::pin(async move { + // Claim the dedup row as QUEUED. A first claim ⇒ insert the + // queue row; a Replay ⇒ the row already exists (idempotent). + let claim = gate + .claim_queued( + txn, + tenant, + FLOW_PAYMENT_ALLOCATE, + &business_id, + &payload_hash, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + match claim { + ClaimOutcome::Claimed => { + PendingQueueRepo::insert_queued( + txn, + &scope_owned, + &NewQueueRow { + tenant_id: tenant, + flow: FLOW_PAYMENT_ALLOCATE.to_owned(), + business_id: business_id.clone(), + payload: payload_json, + queued_at: now, + // Immediately eligible for the drain once the + // settlement lands (no apply delay in v1). + apply_after: None, + }, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + // First intake: `now` is the authoritative queued_at. + Ok::(IntakeOutcome::Enqueued { queued_at: now }) + } + ClaimOutcome::Replay(row) => { + // A concurrent/retried intake won the claim first. + // FIRST guard the payload: a reuse of `allocation_id` + // with a DIFFERENT request is an idempotency conflict, + // not a replay. The early `replay_short_circuit` makes + // the same comparison; this closes the race that slips + // past it (a row that appeared between the early check + // and this in-txn claim). + if row.payload_hash != payload_hash { + Ok(IntakeOutcome::Conflict) + } else if row.status == STATUS_QUEUED { + // Same payload, already queued ⇒ idempotent. + Ok(IntakeOutcome::AlreadyQueued) + } else { + // Same payload but a POSTED race (the settlement + // landed AND Group D's apply drained it between the + // early check and this claim) — transient: the + // caller retries and the early check then returns + // the POSTED replay cleanly. + Err(DbError::Sea(DbErr::Custom(format!( + "allocate intake: unexpected dedup status {:?} for \ + ({tenant}, {FLOW_PAYMENT_ALLOCATE}, {business_id})", + row.status + )))) + } + } + } + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("allocate intake: {e}")))?; + + let business_id = input.allocation_id.to_string(); + match outcome { + IntakeOutcome::Enqueued { queued_at } => Ok(QueuedAllocation { + flow: FLOW_PAYMENT_ALLOCATE.to_owned(), + business_id, + queued_at, + }), + // A racing intake reused this `allocation_id` with a different payload. + IntakeOutcome::Conflict => Err(DomainError::IdempotencyConflict(format!( + "allocation_id {} reused with a different payload", + input.allocation_id + ))), + // The claim raced and lost: the prior intake's queue row holds the + // authoritative `queued_at` — read it out-of-txn for the handle. + IntakeOutcome::AlreadyQueued => { + let row = self + .pending_queue + .get(scope, input.tenant_id, FLOW_PAYMENT_ALLOCATE, &business_id) + .await + .map_err(|e| DomainError::Internal(format!("allocate queue read: {e}")))? + .ok_or_else(|| { + DomainError::Internal(format!( + "intake replay but no queue row for \ + ({}, {FLOW_PAYMENT_ALLOCATE}, {business_id})", + input.tenant_id + )) + })?; + Ok(QueuedAllocation { + flow: FLOW_PAYMENT_ALLOCATE.to_owned(), + business_id, + queued_at: row.queued_at, + }) + } + } + } + + /// Apply ONE queued allocation row (Group D): deserialize the queued payload, + /// re-gate on the settlement, RE-RUN the decide/validate/build path (so caps + /// are re-evaluated against then-current state, §4.7), and post via + /// [`PostingService::post_queued_apply`] with a COMPOSITE sidecar that does + /// the allocation counter writes AND flips the queue row `→APPLIED` — both in + /// the post txn. Returns: + /// - [`ApplyOutcome::NotReady`] when the settlement is STILL absent (leave the + /// row `QUEUED`, no attempt bump — a settle/sweep retries), + /// - [`ApplyOutcome::Applied`] on a successful post (row flipped `→APPLIED`), + /// - [`ApplyOutcome::Blocked`] on an apply-time cap/precondition rejection + /// (`MoneyOutCapExceeded` / `NegativeBalance` / `PeriodClosed` / + /// `AllocationSplitInvalid`) — the caller bumps attempts + leaves `QUEUED`. + /// + /// `pub(crate)` so the [`crate::infra::payment::queue_apply::QueueApplier`] + /// (and the sweep job, via it) can drive a single row; the public surface is + /// [`Self::drain`]. + /// + /// # Errors + /// [`DomainError::Internal`] on an infra fault (bad payload, settlement read, + /// or an engine `Internal`) — propagated so the caller can isolate the row and + /// continue the pass. + pub(crate) async fn apply_queued_row( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + row: &pending_event_queue::Model, + ) -> Result { + // 1. Deserialize the PII-free financial-key snapshot + reconstruct the + // request (mapping the Mode B `QueuedSplit` back to the domain + // `Allocated`). + let payload: QueuedAllocationPayload = serde_json::from_value(row.payload.clone()) + .map_err(|e| DomainError::Internal(format!("deserialize queued payload: {e}")))?; + let input = AllocateRequest::from_payload(payload); + + // 2. Re-gate on the settlement. ABSENT ⇒ NotReady (the payment still isn't + // settled — leave the row QUEUED, don't bump attempts; a settle/sweep + // retries). PRESENT ⇒ proceed (its currency must match the request). + let Some(settlement) = self + .repo + .read_settlement(scope, input.tenant_id, &input.payment_id) + .await + .map_err(|e| DomainError::Internal(format!("read settlement: {e}")))? + else { + return Ok(ApplyOutcome::NotReady); + }; + if settlement.currency != input.currency { + // A currency mismatch is a permanent precondition failure, not infra — + // treat as Blocked (the caller bumps attempts; Phase 5 alarms it). + return Ok(ApplyOutcome::Blocked( + DomainError::AllocationCurrencyMismatch(format!( + "allocation currency {} != settlement currency {} for payment {}", + input.currency, settlement.currency, input.payment_id + )), + )); + } + + // 3. Re-run the EXACT decide/validate/build path — caps thus re-evaluated + // against THEN-CURRENT open AR + wallet state. A decide/build rejection + // is an apply-time precondition failure ⇒ Blocked (not infra). + let built = match self.decide_and_build_entry(ctx, scope, &input).await { + Ok(built) => built, + Err(e) if is_apply_blocked(&e) => return Ok(ApplyOutcome::Blocked(e)), + Err(e) => return Err(e), + }; + let BuiltAllocation { + entry, + splits, + policy_ref, + total, + } = built; + + // 4. Composite sidecar: the allocation counter writes (reused via the + // wrapped `AllocationSidecar`) THEN the queue `→APPLIED` flip — both in + // the post txn, so the apply effect and the work-state transition + // commit atomically (or roll back together). + let sidecar: Arc = Arc::new(QueuedAllocationApplySidecar { + alloc: AllocationSidecar { + tenant: input.tenant_id, + payer: input.payer_tenant_id, + payment_id: input.payment_id.clone(), + allocation_id: input.allocation_id, + currency: input.currency.clone(), + // Moved (not cloned): an apply does not return the splits to the + // caller (the queue row already recorded them), so the sidecar is + // their sole consumer here. + splits, + total_minor: total, + policy_ref, + }, + flow: row.flow.clone(), + business_id: row.business_id.clone(), + tenant: input.tenant_id, + }); + + // 5. Post via the queued-apply engine path. A cap/precondition surfaces as + // a `DomainError` ⇒ Blocked; an `Internal` propagates. + match self + .post_bound_queued_apply(ctx, scope, entry, sidecar) + .await + { + Ok(posting) => Ok(ApplyOutcome::Applied(posting)), + Err(e) if is_apply_blocked(&e) => Ok(ApplyOutcome::Blocked(e)), + Err(e) => Err(e), + } + } + + /// Drain up to `limit` due queued allocations for one tenant: claim them under + /// `SKIP LOCKED` in a short claim txn, then apply EACH in its OWN txn (the + /// "apply is a second txn" shape, §4.7). `Blocked` ⇒ bump attempts + back off + /// via `apply_after` (own txn); + /// `NotReady` ⇒ skip (no bump). The claim and each apply are SEPARATE + /// transactions: claiming only reserves the rows for this pass under the row + /// lock; the authoritative work-state flip (`→APPLIED`) rides each apply's post + /// txn. (Chosen over claim+apply-in-one-txn so a long apply doesn't hold the + /// claim lock across the whole batch, and so a per-row failure is isolated.) + /// + /// Per-row infra errors are isolated (logged, counted as neither applied nor + /// blocked) so one bad row doesn't abort the batch — mirrors the tie-out job's + /// per-tenant isolation. + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn itself fails (the + /// batch cannot start); per-row faults are swallowed within the pass. + pub async fn drain( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + let now = Utc::now(); + // Claim txn: reserve up to `limit` due rows under SKIP LOCKED. Returned + // still `QUEUED` — the apply flips each. Its own short txn so the lock is + // released before the (potentially slow) per-row applies run. + let pending_queue = self.pending_queue.clone(); + let scope_owned = scope.clone(); + let claimed: Vec = self + .db + .transaction(move |txn| { + Box::pin(async move { + pending_queue + .claim_due(txn, &scope_owned, tenant, FLOW_PAYMENT_ALLOCATE, now, limit) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("drain claim: {e}")))?; + + let mut report = DrainReport::default(); + for row in claimed { + match self.apply_queued_row(ctx, scope, &row).await { + Ok(ApplyOutcome::Applied(_)) => report.applied += 1, + Ok(ApplyOutcome::NotReady) => report.not_ready += 1, + Ok(ApplyOutcome::Blocked(err)) => { + report.blocked += 1; + // Bump attempts in its OWN txn (the apply already rolled back). + // A bump failure is logged but does not abort the batch. + if let Err(bump_err) = self + .bump_attempts_own_txn( + scope, + tenant, + &row.business_id, + i64::from(row.attempts), + ) + .await + { + tracing::error!( + tenant_id = %tenant, + business_id = %row.business_id, + blocked_by = %err, + error = %bump_err, + "bss-ledger: drain failed to bump attempts for blocked allocation" + ); + } else { + tracing::warn!( + tenant_id = %tenant, + business_id = %row.business_id, + error = %err, + "bss-ledger: queued allocation blocked at apply (attempts bumped, left QUEUED)" + ); + } + } + Err(e) => { + // Isolate per-row infra faults: log and continue (the row stays + // QUEUED, a later sweep retries) — one bad row must not abort + // the whole pass. + tracing::error!( + tenant_id = %tenant, + business_id = %row.business_id, + error = %e, + "bss-ledger: queued allocation apply failed (infra); continuing" + ); + } + } + } + Ok(report) + } + + /// Bump one queue row's `attempts` AND defer its next eligibility by an + /// exponential backoff, in its own short transaction (the apply that produced + /// the `Blocked` already rolled back, so this is a standalone write). + /// `prior_attempts` is the claimed row's attempt count BEFORE this pass; the + /// backoff is sized off the new count (`prior_attempts + 1`). Deferring is what + /// stops a durably-`Blocked` ("poison") row — a currency mismatch, a chronic + /// over-cap, a closed period — from being re-claimed on every drain pass and + /// hot-looping CPU + DB until Phase 5 adds quarantine. Used by [`Self::drain`]. + async fn bump_attempts_own_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + business_id: &str, + prior_attempts: i64, + ) -> Result<(), DomainError> { + let scope_owned = scope.clone(); + let business_id = business_id.to_owned(); + let defer_until = Utc::now() + blocked_backoff(prior_attempts + 1); + self.db + .transaction(move |txn| { + Box::pin(async move { + PendingQueueRepo::bump_attempts_and_defer( + txn, + &scope_owned, + tenant, + FLOW_PAYMENT_ALLOCATE, + &business_id, + defer_until, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("drain bump attempts: {e}"))) + } + + /// Map an already-account-bound [`PostEntry`] to the engine's + /// `NewEntry`/`NewLine`, resolving each line's scale, and post INLINE + /// (`PostingService::post_with_request_hash`, `ClaimMode::Fresh`) with the + /// allocation sidecar, binding the request-based idempotency hash so a replay + /// short-circuit can reject a same-key / different-payload reuse (the inline + /// path thus stores the SAME request hash the queued-intake path does). + async fn post_bound( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: PostEntry, + sidecar: Arc, + request_hash: String, + ) -> Result { + let (new_entry, new_lines) = self.to_engine_inputs(scope, entry).await?; + self.posting + .post_with_request_hash( + ctx, + scope, + new_entry, + new_lines, + Some(sidecar), + request_hash, + ) + .await + } + + /// The deferred-apply twin of [`Self::post_bound`]: same mapping, but posts + /// via [`PostingService::post_queued_apply`] (`ClaimMode::QueuedApply`) so the + /// dedup row already claimed `QUEUED` at intake is read (not re-claimed) and + /// finalized `QUEUED → POSTED`. The `sidecar` here is the COMPOSITE one that + /// also flips the queue row `→APPLIED` in the same txn. + async fn post_bound_queued_apply( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: PostEntry, + sidecar: Arc, + ) -> Result { + let (new_entry, new_lines) = self.to_engine_inputs(scope, entry).await?; + self.posting + .post_queued_apply(ctx, scope, new_entry, new_lines, Some(sidecar)) + .await + } + + /// Map an already-account-bound [`PostEntry`] to the engine's + /// `NewEntry` + `Vec`, resolving each line's currency scale. Shared + /// by the inline ([`Self::post_bound`]) and deferred-apply + /// ([`Self::post_bound_queued_apply`]) post paths. + async fn to_engine_inputs( + &self, + scope: &AccessScope, + entry: PostEntry, + ) -> Result<(NewEntry, Vec), DomainError> { + let new_entry = NewEntry { + entry_id: entry.entry_id, + tenant_id: entry.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: entry.tenant_id, + period_id: entry.period_id.clone(), + entry_currency: entry.entry_currency.clone(), + source_doc_type: entry.source_doc_type, + source_business_id: entry.source_business_id.clone(), + reverses_entry_id: entry.reverses_entry_id, + reverses_period_id: entry.reverses_period_id.clone(), + posted_at_utc: Utc::now(), + effective_at: entry.effective_at, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: entry.posted_by_actor_id, + correlation_id: entry.correlation_id, + rounding_evidence: serde_json::Value::Null, + // Slice 5: the S2 allocate FX lock lands next; None until then. + rate_snapshot_ref: None, + }; + let mut new_lines: Vec = Vec::with_capacity(entry.lines.len()); + for line in entry.lines { + let scale = self + .resolver + .resolve(scope, entry.tenant_id, &line.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + new_lines.push(new_line(line, scale)); + } + Ok((new_entry, new_lines)) + } + + /// Emit the `Allocate`-labelled payment-post duration for one attempt, and — + /// for an inline post or a rejection — the `allocation(outcome)` counter + /// (mirrors `invoice_post::record`). A `Queued` outcome is an ENQUEUE, not a + /// post: it records ONLY the duration and emits no `allocation()` outcome + /// (the post — and thus the Posted/Replayed/Rejected outcome — happens later + /// on the drain, Group D). + fn record(&self, result: &Result, started: Instant) { + match result { + Ok(AllocationOutcome::Applied(applied)) => { + let outcome = if applied.posting.replayed { + PostResult::Replayed + } else { + PostResult::Posted + }; + self.metrics.allocation(outcome); + } + Err(_) => self.metrics.allocation(PostResult::Rejected), + // Enqueue: duration only, no outcome counter (see the doc above). + Ok(AllocationOutcome::Queued(_)) => {} + } + self.metrics + .payment_post_duration(started.elapsed().as_secs_f64(), PostFlow::Allocate); + } +} + +/// Overwrite the placeholder header fields the pure builder emits: an allocation +/// posts effective-now, so derive the `period_id` (YYYYMM) and `effective_at` +/// from the wall clock, stamp the actor from the security context, and mint a +/// fresh correlation id. If `period_id` stayed `""` the post would fail the +/// fiscal-period gate, so this overwrite is mandatory. +fn overwrite_header(entry: &mut PostEntry, ctx: &SecurityContext) { + let eff_date = Utc::now().date_naive(); + entry.effective_at = eff_date; + entry.period_id = format!("{:04}{:02}", eff_date.year(), eff_date.month()); + entry.posted_by_actor_id = ctx.subject_id(); + entry.correlation_id = Uuid::now_v7(); +} + +/// Thin `PostLine` adapter over [`ChartIndex::resolve`]: the allocation classes +/// (UNALLOCATED / AR) are stream-less, so this resolves on `stream = None`. +fn resolve_line(chart: &ChartIndex, line: &PostLine) -> Option { + chart.resolve( + line.account_class, + &line.currency, + line.revenue_stream.as_deref(), + ) +} + +/// Map one SDK [`PostLine`] + its resolved scale to the engine's [`NewLine`] +/// (mirrors `invoice_post::new_line`). +fn new_line(line: PostLine, scale: u8) -> NewLine { + NewLine { + line_id: line.line_id, + payer_tenant_id: line.payer_tenant_id, + seller_tenant_id: line.seller_tenant_id, + resource_tenant_id: line.resource_tenant_id, + account_id: line.account_id, + account_class: line.account_class, + gl_code: line.gl_code, + side: line.side, + amount_minor: line.amount_minor, + currency: line.currency, + currency_scale: scale, + invoice_id: line.invoice_id, + due_date: line.due_date, + revenue_stream: line.revenue_stream, + mapping_status: line.mapping_status, + functional_amount_minor: line.functional_amount_minor, + functional_currency: line.functional_currency, + tax_jurisdiction: line.tax_jurisdiction, + tax_filing_period: line.tax_filing_period, + tax_rate_ref: line.tax_rate_ref, + legal_entity_id: None, + invoice_item_ref: line.invoice_item_ref, + sku_or_plan_ref: line.sku_or_plan_ref, + price_id: line.price_id, + pricing_snapshot_ref: line.pricing_snapshot_ref, + po_allocation_group: line.po_allocation_group, + credit_grant_event_type: line.credit_grant_event_type, + ar_status: line.ar_status, + } +} + +/// Build the single net `FX_GAIN_LOSS` line that balances a realized-FX +/// allocation close (Slice 5 F1, design §3.5). It is **functional-only**: +/// `amount_minor = 0` (so it sits outside the transaction-column zero-sum and the +/// dual-column trigger exempts an `amount_minor = 0` functional-NOT-NULL line +/// from the entry-currency match), while `functional_amount_minor = fx_minor > 0` +/// carries the realized gain/loss — passing the tightened `amount_minor > 0 OR +/// (amount_minor = 0 AND functional_amount_minor > 0)` CHECK on the functional +/// arm. `currency = functional_ccy` matches the FX account's currency (bound via +/// `ChartIndex::resolve(FxGainLoss, functional_ccy, None)`); `side` is the +/// sign-by-role from [`realize`](crate::domain::fx::realized::realize) — a +/// realized **loss** is a debit, a **gain** a credit. Mirrors the per-line shape +/// the allocation [`build_allocation_entry`](crate::domain::payment::allocation::build_allocation_entry) +/// emits (payer + seller stamped, the rest `None`). +fn fx_gain_loss_line( + input: &AllocateRequest, + account_id: Uuid, + functional_ccy: &str, + side: Side, + fx_minor: i64, +) -> PostLine { + PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: input.payer_tenant_id, + seller_tenant_id: Some(input.tenant_id), + resource_tenant_id: None, + account_id, + account_class: AccountClass::FxGainLoss, + gl_code: None, + side, + amount_minor: 0, + currency: functional_ccy.to_owned(), + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: Some(fx_minor), + functional_currency: Some(functional_ccy.to_owned()), + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Is this rejection an apply-time cap / precondition failure (so the drain +/// leaves the row `QUEUED` + bumps attempts), as opposed to an infra fault (which +/// propagates)? The blocked set is the cap/precondition family the apply can hit +/// at apply-time against then-current state (§4.7): the per-payment money-out cap, +/// the projector's no-negative guard, a closed fiscal period, and an invalid +/// caller split. `AllocationTooLarge` / `AccountClosed` are also precondition +/// failures the decide/build path can raise — included so a transiently +/// unprovisioned chart or an oversized candidate set is retried, not surfaced as +/// infra. Everything else (notably [`DomainError::Internal`]) is infra. +fn is_apply_blocked(err: &DomainError) -> bool { + matches!( + err, + DomainError::MoneyOutCapExceeded(_) + | DomainError::NegativeBalance(_) + | DomainError::PeriodClosed(_) + | DomainError::AllocationSplitInvalid(_) + | DomainError::AllocationCurrencyMismatch(_) + | DomainError::AllocationTooLarge(_) + | DomainError::AccountClosed(_) + | DomainError::InvalidRequest(_) + ) +} + +/// Exponential backoff (wall-clock) before a `Blocked` queued allocation may be +/// re-claimed, sized off the post-bump attempt count: ~`2^(attempts-1)` seconds, +/// capped at 5 minutes. This keeps a durably-`Blocked` ("poison") row — one whose +/// block is NOT transient (a currency mismatch, a chronic over-cap) — from being +/// re-applied on every drain / sweep pass (a CPU + DB hot-loop) before Phase 5 +/// adds attempt-based quarantine. A transiently-blocked row (a period that later +/// reopens, an AR that reopens) simply waits out the backoff before its next try. +fn blocked_backoff(attempts: i64) -> Duration { + const BASE_SECS: i64 = 2; + const MAX_SECS: i64 = 300; + // Clamp the shift so `1 << shift` cannot overflow; the cap dominates long + // before then (2^9 · 2s already exceeds the 300s ceiling). + let shift = attempts.clamp(1, 16) - 1; + let secs = BASE_SECS.saturating_mul(1_i64 << shift).min(MAX_SECS); + Duration::seconds(secs) +} + +/// The request-based idempotency hash for an allocate — the `content_hash` of the +/// canonical [`QueuedAllocationPayload`]. It is STABLE across the apply's +/// state-dependent entry rebuild, so it (not the entry-based hash) is what the +/// dedup row stores on BOTH the queued-intake and the inline-post paths, and what +/// [`AllocationService::replay_short_circuit`] compares to reject a same +/// `allocation_id` reused with a different payload. +fn allocation_request_hash(input: &AllocateRequest) -> Result { + let mut payload = QueuedAllocationPayload::from_request(input); + // Order-independent: a Mode-B caller resending the SAME splits in a different + // order must hash identically (mirrors credit's sorted `apply_request_hash` + // targets), else an order-varying retry would spuriously trip + // `IdempotencyConflict`. Sorting here affects ONLY the dedup fingerprint — the + // queue row stores the caller's original order separately for apply. + if let Some(splits) = payload.caller_splits.as_mut() { + splits + .sort_by(|a, b| (&a.invoice_id, a.amount_minor).cmp(&(&b.invoice_id, b.amount_minor))); + } + let canonical = serde_json::to_string(&payload) + .map_err(|e| DomainError::Internal(format!("canonicalize allocate payload: {e}")))?; + Ok(IdempotencyGate::content_hash(&canonical)) +} + +/// Composite in-transaction sidecar for the deferred apply of a queued +/// allocation (Group D): it runs the SAME allocation counter writes as the inline +/// path (by delegating to the wrapped [`AllocationSidecar`]) and THEN flips the +/// work-state queue row `→APPLIED` — both inside the post txn opened by +/// [`PostingService::post_queued_apply`]. So the allocation effect and the +/// queue-row transition commit atomically, or roll back together: a cap rejection +/// in `add_allocated` rolls back the `→APPLIED` flip too, leaving the row +/// claimable again on a later pass. +struct QueuedAllocationApplySidecar { + /// The inline allocation sidecar — reused verbatim so the counter-write logic + /// (bump `allocated_minor` under the cap CHECK, insert rows, bump refund + /// counters) is not duplicated. + alloc: AllocationSidecar, + /// The queue row's `flow` (the `PAYMENT_ALLOCATE` literal) — its PK part. + flow: String, + /// The queue row's `business_id` (the allocation id string) — its PK part. + business_id: String, + /// The queue row's tenant — its PK part (and the scope tenant). + tenant: Uuid, +} + +#[async_trait::async_trait] +impl PostSidecar for QueuedAllocationApplySidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + // 1. The allocation counter writes (delegated — the per-payment cap CHECK + // is re-evaluated here against then-current `allocated_minor`). + self.alloc.run(txn, scope, posted).await?; + // 2. Flip the work-state queue row `→APPLIED` in the SAME txn. A zero-row + // update (the row vanished / was already terminal) surfaces as a repo + // error → DomainError::Internal, rolling the whole apply back. + PendingQueueRepo::mark_applied(txn, scope, self.tenant, &self.flow, &self.business_id) + .await + .map_err(|e| DomainError::Internal(format!("queue-apply mark_applied: {e}")))?; + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/payment/chargeback.rs b/gears/bss/ledger/ledger/src/infra/payment/chargeback.rs new file mode 100644 index 000000000..28abe9e86 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/payment/chargeback.rs @@ -0,0 +1,1582 @@ +//! `ChargebackService` — orchestrates the chargeback dispute domain +//! (`crate::domain::payment::chargeback`) over the foundation engine. Records +//! `opened` (Group B) and the `won`/`lost` outcomes (Group C) in both variants: +//! it seeds / advances the `ledger_dispute` current-state row and posts the +//! variant's legs in one serializable transaction (via [`ChargebackSidecar`]). +//! +//! Sequence for one phase (mirrors +//! [`crate::infra::payment::settlement_return::SettlementReturnService`] plus a +//! dispute pre-read + the net cash-leg read): +//! 1. **dedup short-circuit** — a replayed phase returns the prior entry BEFORE +//! the state-dependent transition guard (which would spuriously reject a +//! replayed `opened`). +//! 2. **read the dispute** — out-of-txn, scoped. Drives the variant selection, +//! the transition guard, and the out-of-order decision. +//! 3. **out-of-order** (`won`/`lost` with NO prior dispute row, §4.7) — durably +//! ENQUEUE the request on `ledger_pending_event_queue` (`flow = CHARGEBACK`, +//! `business_id = dispute_id:cycle:phase`) and return [`ChargebackOutcome::Queued`] +//! (HTTP 202), NEVER a partial outcome and NEVER a synthesised `opened`. +//! 4. **variant + transition guard** — `opened` derives the variant from +//! `funds_at_open`; `won`/`lost` read the recorded variant back from the row. +//! 5. **net cash-leg read** (`CASH_HOLD` only, Model N) — read the settlement and +//! size the cash legs at `net = settled_minor − fee_minor` (the cash that +//! actually entered `CASH_CLEARING`; the PSP fee went to `PSP_FEE_EXPENSE`). +//! An `AR_RECLASS` dispute has no PSP fee / no cash leg, so `net` is moot (`0`). +//! 6. **`CHARGEBACK_ON_REFUNDED` pre-check** — a `lost` whose clawback cannot fit +//! under the total money-out cap because the payment was already refunded +//! routes to a minimal exception stub (logged + a distinct `DomainError`; +//! the full exception queue is Slice 7 / VHP-1859). +//! 7. **build → overwrite header → bind chart → post** with [`ChargebackSidecar`] +//! (the dispute-row write + the clawback counter bump + the in-txn +//! `dispute.recorded` outbox publish, all atomic with the entry). +//! 8. **drain-on-`opened`** — when a FRESH `opened` commits, [`Self::record_phase`] +//! drains the tenant's CHARGEBACK queue inline (mirrors settle's drain-on-settle) +//! so a queued `won`/`lost` whose `opened` just landed posts immediately; the +//! periodic [`crate::infra::jobs::queue_applier`] sweep is the backstop. +//! +//! There is NO payer gate (a dispute records a card-network / bank event and must +//! land even for a closed payer). Idempotent on +//! `(tenant, CHARGEBACK, "dispute_id:cycle:phase")`. Lives in `infra` (needs repo +//! + posting access); the domain builder it calls stays pure (dylint DE0301). + +use std::sync::Arc; +use std::time::Instant; + +use bss_ledger_sdk::{AccountClass, PostEntry, PostLine, PostingRef, SourceDocType}; +use chrono::{DateTime, Datelike, Duration, Utc}; +use sea_orm::DbErr; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::fx::realized::carried_relief; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::payment::chargeback::{ + ChargebackInput, DisputePhase, DisputeVariant, FundsAtOpen, build_chargeback_entry, + clawed_back_on_post, +}; +use crate::domain::ports::metrics::{LedgerMetricsPort, PostFlow, PostResult}; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::exception::ExceptionRouter; +use crate::infra::payment::sidecar::{ChargebackDisputeOp, ChargebackSidecar}; +use crate::infra::posting::chart::{ChartIndex, load_chart}; +use crate::infra::posting::idempotency::{ + ClaimOutcome, IdempotencyGate, STATUS_POSTED, STATUS_QUEUED, +}; +use crate::infra::posting::service::{PostSidecar, PostingService}; +use crate::infra::storage::entity::pending_event_queue; +use crate::infra::storage::repo::{ + DisputeRepo, NewQueueRow, PaymentRepo, PendingQueueRepo, ReferenceRepo, +}; + +/// Origin literal stamped on posts made through this service. +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// The deferred-apply queue flow + idempotency-dedup `flow` for a chargeback +/// phase — the `CHARGEBACK` source-doc literal (kept in lockstep with +/// [`SourceDocType::Chargeback`] by the round-trip test in `enums.rs`; `as_str` +/// is not `const`, so it can't be derived in a `const` initializer). The same +/// literal the inline post stamps on its entry header, so a queued phase and the +/// post it later becomes share one dedup key. Reuses the unconstrained +/// `flow varchar(64)` (G-B2) — no new flow literal, no DDL. +const FLOW_CHARGEBACK: &str = "CHARGEBACK"; + +/// Per-tenant cap on the drain-on-`opened` pass (mirrors settle's +/// `DRAIN_ON_SETTLE_CAP`): a sane batch ceiling so an `opened` that unblocks a +/// large backlog of out-of-order `won`/`lost` phases doesn't post an unbounded +/// number of outcomes inline on the record path. The periodic +/// [`crate::infra::jobs::queue_applier`] sweep drains the remainder. +const DRAIN_ON_OPENED_CAP: u64 = 100; + +/// A chargeback phase to record (the infra request the local client / REST +/// surface lowers their DTOs into). The variant is NOT supplied — the service +/// selects it at `opened` from `funds_at_open` and reads it back from the dispute +/// row for the outcomes. +pub struct ChargebackRequest { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub payment_id: String, + pub dispute_id: String, + /// The disputed `(payer, invoice)` AR grain — required for an AR-reclass + /// `opened` / `won` / `lost`; ignored for cash-hold. + pub invoice_id: Option, + /// Re-entrancy counter (`>= 1`). + pub cycle: i32, + pub phase: DisputePhase, + /// The funds-movement fact (card rails withheld vs invoice/ACH not moved) — + /// the LEDGER reads it at `opened` to choose the variant. + pub funds_at_open: FundsAtOpen, + pub disputed_amount_minor: i64, + pub currency: String, + pub effective_at: Option>, +} + +impl ChargebackRequest { + /// The `source_business_id` / idempotency composite for this phase: + /// `dispute_id:cycle:phase` (matches the domain builder's `business_id`). + fn business_id(&self) -> String { + format!("{}:{}:{}", self.dispute_id, self.cycle, self.phase.as_str()) + } +} + +/// The result of recording one phase: either it posted inline (the dispute had +/// its `opened`, or this IS the `opened`) or it was durably queued because its +/// `opened` has not landed yet (§4.7 out-of-order). The two arms drive the +/// SDK/REST 201/200-vs-202 split (mirrors +/// [`crate::infra::payment::allocate::AllocationOutcome`]). +#[derive(Debug)] +pub enum ChargebackOutcome { + /// The phase posted inline: the posting handle. + Recorded(PostingRef), + /// The phase was an out-of-order `won`/`lost`: the request was enqueued + /// (HTTP 202). + Queued(QueuedDispute), +} + +/// A dispute phase deferred because its `opened` has not landed: the request is +/// durably on `ledger_pending_event_queue` and the drain will apply it once the +/// `opened` lands. Carries the queue key (`flow` + `business_id`) and the +/// `queued_at` instant — the surface for the REST 202 `dispute-phase-queued` +/// body. No `PostingRef`: nothing has posted yet (mirrors +/// [`crate::infra::payment::allocate::QueuedAllocation`]). +#[derive(Debug)] +pub struct QueuedDispute { + /// The deferred-apply queue flow (the `CHARGEBACK` literal). + pub flow: String, + /// The queue/dedup business id — `dispute_id:cycle:phase`. + pub business_id: String, + /// When the intake durably enqueued the request. + pub queued_at: DateTime, +} + +/// The financial-key snapshot of a chargeback phase request, persisted as the +/// queue row's `payload` jsonb at intake and re-read by the drain. PII-free by +/// construction (ids + money + enum literals only — no names / free-text). The +/// enums are carried as their stable wire literals so the domain stays serde-free +/// (pure); the drain parses them back. Lives here (not `domain`) next to the +/// service that writes it. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct QueuedDisputePayload { + pub tenant_id: Uuid, + pub payer_tenant_id: Uuid, + pub payment_id: String, + pub dispute_id: String, + pub invoice_id: Option, + pub cycle: i32, + /// The phase wire literal (`WON` / `LOST` — an out-of-order intake only ever + /// queues these; `OPENED` posts inline, never queues). + pub phase: String, + /// The funds-fact wire literal (`withheld` / `not_moved`). Recorded for + /// completeness; on apply the variant is read from the now-present dispute row. + pub funds_at_open: String, + pub disputed_amount_minor: i64, + pub currency: String, +} + +impl QueuedDisputePayload { + /// Snapshot a [`ChargebackRequest`] into the PII-free queue payload (by + /// reference — the request is still needed to build the `Queued` handle). + fn from_request(req: &ChargebackRequest) -> Self { + Self { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id.clone(), + dispute_id: req.dispute_id.clone(), + invoice_id: req.invoice_id.clone(), + cycle: req.cycle, + phase: req.phase.as_str().to_owned(), + funds_at_open: req.funds_at_open.as_str().to_owned(), + disputed_amount_minor: req.disputed_amount_minor, + currency: req.currency.clone(), + } + } + + /// Reconstruct the request from a queued payload at apply time, parsing the + /// wire enum literals. The inverse of [`Self::from_request`]. + /// + /// # Errors + /// [`DomainError::Internal`] when a stored enum literal is unknown (data + /// corruption — the column is only ever written from `as_str`). + fn into_request(self) -> Result { + let phase = DisputePhase::parse(&self.phase).ok_or_else(|| { + DomainError::Internal(format!( + "queued dispute carries unknown phase {:?}", + self.phase + )) + })?; + let funds_at_open = FundsAtOpen::parse(&self.funds_at_open).ok_or_else(|| { + DomainError::Internal(format!( + "queued dispute carries unknown funds_at_open {:?}", + self.funds_at_open + )) + })?; + Ok(ChargebackRequest { + tenant_id: self.tenant_id, + payer_tenant_id: self.payer_tenant_id, + payment_id: self.payment_id, + dispute_id: self.dispute_id, + invoice_id: self.invoice_id, + cycle: self.cycle, + phase, + funds_at_open, + disputed_amount_minor: self.disputed_amount_minor, + currency: self.currency, + // A queued phase is applied at drain time; the period is stamped then + // (no original instant is carried on the queue payload), mirroring how + // a queued allocation re-derives at apply time. + effective_at: None, + }) + } +} + +/// What the intake transaction committed — carried out of the `db.transaction` +/// closure so the post-txn code can build the `Queued` handle. Mirrors +/// [`crate::infra::payment::allocate`]'s `IntakeOutcome`. +enum IntakeOutcome { + Enqueued { + queued_at: DateTime, + }, + AlreadyQueued, + /// A concurrent/retried intake reused the same key with a DIFFERENT payload + /// (the dedup row's `payload_hash` differs) — surfaced after the txn as + /// [`DomainError::IdempotencyConflict`]. + Conflict, +} + +/// The result of applying ONE queued dispute row ([`ChargebackService::apply_queued_row`]). +/// Distinct from [`ChargebackOutcome`] because the apply has a terminal shape the +/// intake cannot: the `opened` may still be absent. +#[derive(Debug)] +pub enum ApplyOutcome { + /// The `opened` landed and the queued outcome posted — the queue row was + /// flipped `→APPLIED` atomically in the post txn. + Applied(PostingRef), + /// The dispute's `opened` is STILL absent: leave the row `QUEUED`, do NOT + /// bump attempts (an `opened` post will retry the drain). + NotReady, + /// A guard / cap rejected the apply at apply-time (re-evaluated against + /// then-current state). The caller bumps `attempts` and leaves the row + /// `QUEUED`. Carries the rejection for logging. + Blocked(DomainError), +} + +/// Summary of one [`ChargebackService::drain`] pass over a tenant's queued +/// dispute phases (mirrors [`crate::infra::payment::allocate::DrainReport`]). +#[derive(Debug, Default)] +pub struct DrainReport { + pub applied: u64, + pub not_ready: u64, + pub blocked: u64, +} + +/// Orchestrates the chargeback dispute domain over the foundation engine. +pub struct ChargebackService { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + dispute_repo: DisputeRepo, + // The payment counter repo: the out-of-txn dedup short-circuit + // (`lookup_finalized_post`) AND the net cash-leg read (`settled − fee`, Model + // N) + the `CHARGEBACK_ON_REFUNDED` settlement pre-read. + payment_repo: PaymentRepo, + // The deferred-apply queue (work-state SoT): an out-of-order `won`/`lost` + // (its `opened` not yet landed) is enqueued here at intake (§4.7) and drained + // later (on an `opened` / by the periodic sweep). + pending_queue: PendingQueueRepo, + // One database provider, retained so the intake enqueue + the drain claim can + // open their own `db.transaction` (mirrors `AllocationService`). + db: DBProvider, + // The event publisher — threaded into the posting engine AND held so the + // sidecar can publish `dispute.recorded` in-txn. + publisher: Arc, + metrics: Arc, + // Slice 7 Phase 2: routes the `CHARGEBACK_ON_REFUNDED` stub to a durable + // close-blocking exception row (ADDITIVE beside the rejection). `None` until + // `with_exceptions` wires it (so existing constructions are unchanged). + exceptions: Option>, +} + +impl ChargebackService { + /// Build the service over one database provider, the event publisher + /// (threaded into the posting engine + the sidecar's in-txn publish), and the + /// metrics sink. Mirrors + /// [`crate::infra::payment::settlement_return::SettlementReturnService::new`] + /// plus the queue repo + the retained `db`/`publisher` the out-of-order path + /// needs. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + let dispute_repo = DisputeRepo::new(db.clone()); + let payment_repo = PaymentRepo::new(db.clone()); + let pending_queue = PendingQueueRepo::new(db.clone()); + Self { + posting, + reference, + resolver, + dispute_repo, + payment_repo, + pending_queue, + db, + publisher, + metrics, + exceptions: None, + } + } + + /// Attach the exception router (Slice 7 Phase 2) so a `CHARGEBACK_ON_REFUNDED` + /// rejection also opens a durable close-blocking exception row. Additive — the + /// rejection + the log are unchanged. + #[must_use] + pub fn with_exceptions(mut self, exceptions: Arc) -> Self { + self.exceptions = Some(exceptions); + self + } + + /// Record one dispute phase. `opened` selects the variant from + /// `funds_at_open`, guards the transition, builds the variant's `opened` + /// legs, and posts them while seeding the `ledger_dispute` row. `won`/`lost` + /// read the recorded variant back, read the settlement's `net` (`CASH_HOLD`, + /// for the net-sized cash legs — Model N), build the outcome legs, and post + /// them while advancing the row (and, on a `lost` cash-hold, bumping + /// `clawed_back_minor` by `net`). A `won`/`lost` with NO prior `opened` is + /// durably QUEUED (§4.7), never rejected. Idempotent on + /// `(tenant, CHARGEBACK, "dispute_id:cycle:phase")`. + /// + /// On an inline post emits `chargeback(Posted | Replayed)` + the payment-post + /// duration; every rejection emits `chargeback(Rejected)` + the duration. A + /// queue (enqueue) records ONLY the duration (the post — and its outcome — + /// happens later on the drain). + /// + /// # Errors + /// [`DomainError::InvalidDisputeTransition`] when the phase is not a legal + /// transition (`opened` on a still-open dispute; `partial`); + /// [`DomainError::InvalidRequest`] for a non-positive amount or a missing + /// AR-reclass `invoice_id`; [`DomainError::ChargebackExceedsSettled`] when a + /// `lost` clawback exceeds the settled amount; [`DomainError::ChargebackOnRefunded`] + /// when a `lost` lands on an already-refunded payment whose clawback can't + /// fit; [`DomainError::AccountClosed`] when a required class is not + /// provisioned; any foundation rejection or [`DomainError::Internal`]. + pub async fn record_phase( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: ChargebackRequest, + ) -> Result { + let started = Instant::now(); + // Captured before `req` moves into `record_inner` — the post-commit drain + // hook below gates on the phase (only an `opened` unblocks queued outcomes) + // and needs the tenant to scope the drain. + let tenant = req.tenant_id; + let phase = req.phase; + let result = self.record_inner(ctx, scope, req).await; + self.record(&result, started); + + // Drain-on-`opened` (mirrors settle's drain-on-settle, D3). An out-of-order + // `won`/`lost` whose `opened` had not landed was durably QUEUED at intake + // (§4.7); a freshly-recorded `opened` is exactly the event that unblocks it. + // Once this `opened` COMMITS, drain the tenant's CHARGEBACK queue inline so a + // previously-`NotReady` outcome posts immediately rather than waiting for the + // periodic sweep. Gated on a FRESH inline `opened`: + // - `Opened` only — a `won`/`lost` resolves a dispute, it never unblocks a + // queued phase, so re-draining on those is pure waste. + // - `Recorded` and not `replayed` — a `Queued`/rejected outcome posted + // nothing (nothing to unblock), and an idempotent replay's ORIGINAL post + // already ran this drain (re-draining on every retried `opened` re-drives + // any apply-blocked rows on each retry). + // A drain error MUST NOT fail the record — log + swallow; the periodic + // `QueueApplierJob` is the backstop. The drain re-reads the dispute per row, + // so it is safe even though the just-committed `opened` is now visible + // out-of-txn. + if matches!(phase, DisputePhase::Opened) + && matches!(&result, Ok(ChargebackOutcome::Recorded(posting)) if !posting.replayed) + { + match self.drain(ctx, scope, tenant, DRAIN_ON_OPENED_CAP).await { + Ok(report) => { + if report.applied > 0 || report.blocked > 0 { + tracing::info!( + tenant_id = %tenant, + applied = report.applied, + not_ready = report.not_ready, + blocked = report.blocked, + "bss-ledger: drain-on-opened applied queued dispute phases" + ); + } + } + Err(e) => tracing::error!( + tenant_id = %tenant, + error = %e, + "bss-ledger: drain-on-opened failed (swallowed; sweep will retry)" + ), + } + } + + result + } + + /// Build + post the chargeback entry (no metrics — the public wrapper records + /// them). + async fn record_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: ChargebackRequest, + ) -> Result { + // 0. Early dedup short-circuit (BEFORE the dispute read + transition + // guard), mirroring the allocate/credit short-circuit. The transition + // guard below is state-dependent, so a replayed `opened` would + // otherwise be spuriously rejected (its row is already `OPENED`). A + // `POSTED` row returns the prior entry as a `Recorded` replay; a + // `QUEUED` row (an out-of-order phase already enqueued) returns the + // same `Queued` handle. Racy by nature (the authoritative dedup is the + // engine's in-txn claim / the intake's `claim_queued`). + let business_id = req.business_id(); + if let Some(outcome) = self.replay_short_circuit(scope, &req, &business_id).await? { + return Ok(outcome); + } + + // 1. Read the dispute's current state (out-of-txn, scoped). Drives the + // variant selection + transition guard + the out-of-order decision. + let existing = self + .dispute_repo + .read_dispute(scope, req.tenant_id, &req.dispute_id) + .await?; + + // 2. Out-of-order (§4.7): a `won`/`lost` whose dispute has NO prior row + // (no `opened` landed yet) MUST NOT post a partial outcome — enqueue it + // and return 202. `opened` (and `partial`) never take this path. + if existing.is_none() && matches!(req.phase, DisputePhase::Won | DisputePhase::Lost) { + let queued = self.enqueue_phase(scope, &req, &business_id).await?; + return Ok(ChargebackOutcome::Queued(queued)); + } + + // 3.–8. Decide the variant, read cash, build, and post inline. + let posting = self + .post_phase_inline(ctx, scope, &req, existing.as_ref()) + .await?; + Ok(ChargebackOutcome::Recorded(posting)) + } + + /// The inline post path (steps 3–7): variant selection + transition guard, + /// the net cash-leg read (`CASH_HOLD`, Model N), the `CHARGEBACK_ON_REFUNDED` + /// pre-check, build, bind, and post with the sidecar. Shared by `record_inner` + /// and the deferred-apply path ([`Self::apply_queued_row`]) — so the guard + + /// caps are re-evaluated against THEN-CURRENT state every time this runs. + async fn post_phase_inline( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &ChargebackRequest, + existing: Option<&crate::infra::storage::entity::dispute::Model>, + ) -> Result { + // 3. Variant selection + transition guard. + let variant = match req.phase { + DisputePhase::Opened => { + guard_open_transition(existing, &req.dispute_id)?; + req.funds_at_open.variant() + } + DisputePhase::Won | DisputePhase::Lost => { + let row = existing.ok_or_else(|| { + DomainError::InvalidDisputeTransition(format!( + "dispute {} has no opened cycle to resolve", + req.dispute_id + )) + })?; + guard_outcome_transition(row, &req.dispute_id)?; + DisputeVariant::parse(&row.variant).ok_or_else(|| { + DomainError::Internal(format!( + "ledger_dispute {} carries an unknown variant {:?}", + req.dispute_id, row.variant + )) + })? + } + DisputePhase::Partial => { + return Err(DomainError::InvalidDisputeTransition(format!( + "dispute phase {} is behind a flag (split chargeback) and not implemented", + req.phase.as_str() + ))); + } + }; + + let input = ChargebackInput { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id.clone(), + dispute_id: req.dispute_id.clone(), + cycle: req.cycle, + phase: req.phase, + variant, + disputed_amount_minor: req.disputed_amount_minor, + invoice_id: req.invoice_id.clone(), + currency: req.currency.clone(), + effective_at: req.effective_at, + }; + + // 4. Net cash-leg size (Model N). A `CASH_HOLD` dispute's cash legs are + // sized at `net = settled_minor − fee_minor` (`CASH_CLEARING` only ever + // held net — the PSP fee never entered it), but WHICH net depends on + // the phase: + // - `opened` reads the CURRENT net and parks `min(disputed, net)` in + // the hold (the sidecar records that amount on the dispute row); + // - `won`/`lost` size their release / forfeit off the amount STORED at + // open (`existing.cash_hold_minor`), NOT a re-read `settled − fee`. + // A settlement-return that lowers the payment's net between `opened` + // and the outcome must not change what the hold gives back — else the + // outcome would release less than was held and strand `DISPUTE_HOLD` + // non-zero (the held cash is a fact fixed at open, not re-derived). + // An `AR_RECLASS` dispute has no PSP fee and no cash leg, so `net` is + // irrelevant — pass `0`. + let chart = load_chart(&self.reference, scope, input.tenant_id).await?; + let net = match (input.variant, req.phase) { + (DisputeVariant::CashHold, DisputePhase::Opened) => { + self.read_net(scope, &input).await? + } + (DisputeVariant::CashHold, DisputePhase::Won | DisputePhase::Lost) => { + // `existing` is `Some` on any outcome (the transition guard above + // rejects a missing opened cycle); fall back to `0` defensively. + existing.map_or(0, |row| row.cash_hold_minor) + } + // AR_RECLASS (any phase) has no cash leg; `partial` is rejected above. + (DisputeVariant::ArReclass, _) | (_, DisputePhase::Partial) => 0, + }; + // The cash parked in `DISPUTE_HOLD` at open (`min(disputed, net)`, Model + // N) — persisted by the sidecar's `Open` write so the outcome branch can + // size off it. On an outcome `net` already IS the stored hold, so the + // `min` is a no-op there; only the `opened` write records a fresh value. + let cash_hold_minor = net.min(input.disputed_amount_minor); + + // 5. `CHARGEBACK_ON_REFUNDED` pre-check: a `lost` cash-out whose clawback + // cannot fit under the total money-out cap because the payment was + // already refunded routes to a minimal exception stub (logged + a + // distinct error; the full exception queue is Slice 7 / VHP-1859). The + // cap CHECK is the authoritative backstop; this pre-check turns the + // specific already-refunded case into its own signal rather than a + // generic `ChargebackExceedsSettled`. + let clawed_back = clawed_back_on_post(&input, net); + if clawed_back > 0 { + self.guard_not_on_refunded(scope, &input, clawed_back) + .await?; + } + + // 6. Build the balanced entry (validates amount > 0 + the AR-reclass + // invoice_id; rejects partial), then overwrite the placeholder header. + let mut entry = build_chargeback_entry(&input, net)?; + overwrite_header(&mut entry, ctx, req.effective_at); + + // 7. Bind each line's real chart account_id. + for line in &mut entry.lines { + line.account_id = resolve_line(&chart, line).ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + line.account_class.as_str(), + line.revenue_stream, + line.currency + )) + })?; + } + + // 7b. Slice 5 (F3): functional carry-forward on a cross-currency dispute + // close. A chargeback reclassifies a position at the CARRIED rate — it + // locks no new rate, so the functional cost basis carries forward and + // the entry's functional column nets to zero (NO realized FX; that is + // recognised at the cash in/out points — settle S2 / refund S3). The + // stamp keeps the closing grain's functional column in lockstep with + // balance_minor; a single-currency close is a no-op (functional NULL). + self.apply_fx_carry_forward(scope, &input, &mut entry) + .await?; + + // 8. Post, threading the chargeback sidecar (the dispute-row write — open + // vs advance — + the clawback bump + the in-txn `dispute.recorded` + // publish, all atomic with the entry). + let op = match req.phase { + DisputePhase::Won | DisputePhase::Lost => ChargebackDisputeOp::Advance { + last_phase: req.phase, + clawed_back_minor: clawed_back, + }, + // `opened` seeds/re-opens the row; `partial` is rejected earlier in the + // builder and is mapped defensively to Open so a future flag-flip can't + // silently mis-advance. + DisputePhase::Opened | DisputePhase::Partial => ChargebackDisputeOp::Open, + }; + let sidecar: Arc = Arc::new(ChargebackSidecar { + tenant: req.tenant_id, + dispute_id: req.dispute_id.clone(), + payment_id: req.payment_id.clone(), + currency: req.currency.clone(), + variant, + cycle: req.cycle, + disputed_amount_minor: req.disputed_amount_minor, + cash_hold_minor, + op, + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }); + let posting = self.post_bound(ctx, scope, entry, sidecar).await?; + Ok(posting) + } + + /// Early dedup short-circuit for a dispute-phase replay, reading the + /// `(tenant, CHARGEBACK, business_id)` dedup status ONCE: + /// - `POSTED` ⇒ the phase already posted (inline OR queued-then-drained): + /// return a [`ChargebackOutcome::Recorded`] replay. + /// - `QUEUED` ⇒ an out-of-order phase already enqueued: read the queue row's + /// `queued_at` and return the same [`ChargebackOutcome::Queued`] handle. + /// - `CLAIMED` / absent ⇒ `None`: fall through to the dispute read. + /// + /// Runs out-of-txn (racy by nature); the authoritative dedup is the engine's + /// in-txn claim (inline) or the intake's `claim_queued`. + async fn replay_short_circuit( + &self, + scope: &AccessScope, + req: &ChargebackRequest, + business_id: &str, + ) -> Result, DomainError> { + let dedup = self + .payment_repo + .lookup_dedup_status(scope, req.tenant_id, SourceDocType::Chargeback, business_id) + .await + .map_err(|e| DomainError::Internal(format!("chargeback dedup lookup: {e}")))?; + let Some((status, result_entry_id, _hash)) = dedup else { + return Ok(None); + }; + if status == STATUS_POSTED { + let entry_id = result_entry_id.ok_or_else(|| { + DomainError::Internal(format!( + "dedup POSTED but no result_entry_id for \ + ({}, {FLOW_CHARGEBACK}, {business_id})", + req.tenant_id + )) + })?; + return Ok(Some(ChargebackOutcome::Recorded(PostingRef { + entry_id, + created_seq: 0, + replayed: true, + }))); + } + if status != STATUS_QUEUED { + // CLAIMED (an in-flight inline post): not a replay — fall through. + return Ok(None); + } + // QUEUED: surface `queued_at` from the work-state queue row. + let row = self + .pending_queue + .get(scope, req.tenant_id, FLOW_CHARGEBACK, business_id) + .await + .map_err(|e| DomainError::Internal(format!("chargeback queue read: {e}")))? + .ok_or_else(|| { + DomainError::Internal(format!( + "dedup QUEUED but no queue row for ({}, {FLOW_CHARGEBACK}, {business_id})", + req.tenant_id + )) + })?; + Ok(Some(ChargebackOutcome::Queued(QueuedDispute { + flow: FLOW_CHARGEBACK.to_owned(), + business_id: business_id.to_owned(), + queued_at: row.queued_at, + }))) + } + + /// Intake for an out-of-order `won`/`lost` (its `opened` not yet landed, + /// §4.7): claim the dedup row as `QUEUED` and insert the work-state queue row, + /// in ONE `db.transaction` (mirrors + /// [`crate::infra::payment::allocate::AllocationService::enqueue_allocation`]). + /// The dedup `payload_hash` is request-based (`content_hash` over the + /// canonical [`QueuedDisputePayload`]) — the same hash the inline post would + /// store is entry-derived, but a queued phase never inlines under the same + /// key, so request-based is the stable choice and `claim_queued`'s `Replay` + /// makes the intake idempotent. + async fn enqueue_phase( + &self, + scope: &AccessScope, + req: &ChargebackRequest, + business_id: &str, + ) -> Result { + let now = Utc::now(); + let payload = QueuedDisputePayload::from_request(req); + let payload_json = serde_json::to_value(&payload) + .map_err(|e| DomainError::Internal(format!("serialize queue payload: {e}")))?; + let payload_hash = dispute_request_hash(&payload)?; + + let tenant = req.tenant_id; + let gate = IdempotencyGate::new(); + // Own everything the closure needs (it is `FnOnce`, so the captures move + // straight into the async future — no inner re-clone). Mirrors + // `AllocationService::enqueue_allocation`. + let scope_owned = scope.clone(); + let business_id_owned = business_id.to_owned(); + let outcome = self + .db + .transaction(move |txn| { + Box::pin(async move { + let claim = gate + .claim_queued( + txn, + tenant, + FLOW_CHARGEBACK, + &business_id_owned, + &payload_hash, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + match claim { + ClaimOutcome::Claimed => { + PendingQueueRepo::insert_queued( + txn, + &scope_owned, + &NewQueueRow { + tenant_id: tenant, + flow: FLOW_CHARGEBACK.to_owned(), + business_id: business_id_owned.clone(), + payload: payload_json, + queued_at: now, + // Immediately eligible once the `opened` lands. + apply_after: None, + }, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string())))?; + Ok::(IntakeOutcome::Enqueued { queued_at: now }) + } + ClaimOutcome::Replay(row) => { + if row.payload_hash != payload_hash { + Ok(IntakeOutcome::Conflict) + } else if row.status == STATUS_QUEUED { + Ok(IntakeOutcome::AlreadyQueued) + } else { + // Same payload but a POSTED race (the `opened` + // landed AND the drain applied it between the early + // check and this claim) — transient; the caller + // retries and the early check returns the POSTED + // replay cleanly. + Err(DbError::Sea(DbErr::Custom(format!( + "chargeback intake: unexpected dedup status {:?} for \ + ({tenant}, {FLOW_CHARGEBACK}, {business_id_owned})", + row.status + )))) + } + } + } + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("chargeback intake: {e}")))?; + + match outcome { + IntakeOutcome::Enqueued { queued_at } => Ok(QueuedDispute { + flow: FLOW_CHARGEBACK.to_owned(), + business_id: business_id.to_owned(), + queued_at, + }), + IntakeOutcome::Conflict => Err(DomainError::IdempotencyConflict(format!( + "dispute phase {business_id} reused with a different payload" + ))), + IntakeOutcome::AlreadyQueued => { + let row = self + .pending_queue + .get(scope, req.tenant_id, FLOW_CHARGEBACK, business_id) + .await + .map_err(|e| DomainError::Internal(format!("chargeback queue read: {e}")))? + .ok_or_else(|| { + DomainError::Internal(format!( + "intake replay but no queue row for \ + ({}, {FLOW_CHARGEBACK}, {business_id})", + req.tenant_id + )) + })?; + Ok(QueuedDispute { + flow: FLOW_CHARGEBACK.to_owned(), + business_id: business_id.to_owned(), + queued_at: row.queued_at, + }) + } + } + } + + /// Apply ONE queued dispute row (the drain): deserialize the payload, re-read + /// the dispute state, and — if its `opened` has now landed — RE-RUN the + /// inline post path (guard + caps re-evaluated against then-current state) + /// via [`PostingService::post_queued_apply`] with a COMPOSITE sidecar that + /// does the dispute writes AND flips the queue row `→APPLIED`, both in the + /// post txn. Returns [`ApplyOutcome::NotReady`] when the `opened` is STILL + /// absent (leave `QUEUED`, no attempt bump), [`ApplyOutcome::Applied`] on a + /// successful post, or [`ApplyOutcome::Blocked`] on an apply-time guard/cap + /// rejection (caller bumps attempts + leaves `QUEUED`). + /// + /// `pub(crate)` so the [`crate::infra::jobs::queue_applier`] sweep can drive a + /// single row; the public surface is [`Self::drain`]. + /// + /// # Errors + /// [`DomainError::Internal`] on an infra fault (bad payload, dispute read, or + /// an engine `Internal`) — propagated so the caller can isolate the row. + pub(crate) async fn apply_queued_row( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + row: &pending_event_queue::Model, + ) -> Result { + // 1. Deserialize the PII-free snapshot + reconstruct the request. + let payload: QueuedDisputePayload = serde_json::from_value(row.payload.clone()) + .map_err(|e| DomainError::Internal(format!("deserialize queued dispute: {e}")))?; + let req = payload.into_request()?; + + // 2. Re-read the dispute. ABSENT ⇒ NotReady (the `opened` still hasn't + // landed — leave QUEUED, no attempt bump; an `opened` post retries). + let existing = self + .dispute_repo + .read_dispute(scope, req.tenant_id, &req.dispute_id) + .await?; + if existing.is_none() { + return Ok(ApplyOutcome::NotReady); + } + + // 3. Re-run the inline build/guard/post, but via the queued-apply engine + // path with a COMPOSITE sidecar (dispute writes + queue `→APPLIED` + // flip). A guard/cap rejection ⇒ Blocked; an `Internal` propagates. + match self + .post_phase_queued_apply(ctx, scope, &req, existing.as_ref(), &row.business_id) + .await + { + Ok(posting) => Ok(ApplyOutcome::Applied(posting)), + Err(e) if is_apply_blocked(&e) => Ok(ApplyOutcome::Blocked(e)), + Err(e) => Err(e), + } + } + + /// The deferred-apply twin of [`Self::post_phase_inline`]: same variant + /// guard + net cash-leg read + build, but posts via + /// [`PostingService::post_queued_apply`] with a COMPOSITE sidecar that wraps + /// the [`ChargebackSidecar`] AND flips the queue row `→APPLIED` in the same + /// txn. The dedup row claimed `QUEUED` at intake is read (not re-claimed) and + /// finalized `QUEUED → POSTED`. + async fn post_phase_queued_apply( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: &ChargebackRequest, + existing: Option<&crate::infra::storage::entity::dispute::Model>, + business_id: &str, + ) -> Result { + // Guard + variant (re-evaluated at apply time). + let variant = match req.phase { + DisputePhase::Won | DisputePhase::Lost => { + let row = existing.ok_or_else(|| { + DomainError::InvalidDisputeTransition(format!( + "dispute {} has no opened cycle to resolve", + req.dispute_id + )) + })?; + guard_outcome_transition(row, &req.dispute_id)?; + DisputeVariant::parse(&row.variant).ok_or_else(|| { + DomainError::Internal(format!( + "ledger_dispute {} carries an unknown variant {:?}", + req.dispute_id, row.variant + )) + })? + } + // Only `won`/`lost` are ever queued (intake guards this); anything + // else here is an invariant breach. + other => { + return Err(DomainError::Internal(format!( + "queued dispute carries non-outcome phase {}", + other.as_str() + ))); + } + }; + + let input = ChargebackInput { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id.clone(), + dispute_id: req.dispute_id.clone(), + cycle: req.cycle, + phase: req.phase, + variant, + disputed_amount_minor: req.disputed_amount_minor, + invoice_id: req.invoice_id.clone(), + currency: req.currency.clone(), + effective_at: req.effective_at, + }; + + let chart = load_chart(&self.reference, scope, input.tenant_id).await?; + // Net cash-leg size (Model N). This path is outcome-only (won/lost), so a + // CASH_HOLD sizes its release / forfeit off the amount STORED in the hold + // at open (`existing.cash_hold_minor`), NOT a re-read `settled − fee` — a + // settlement-return between `opened` and this deferred apply must not + // change what the hold gives back (mirrors `post_phase_inline`; the fix + // for the stranded-hold bug applies to the queued path too). AR_RECLASS + // has no cash leg ⇒ `0`. `existing` is `Some` here (guarded above). + let net = match input.variant { + DisputeVariant::CashHold => existing.map_or(0, |row| row.cash_hold_minor), + DisputeVariant::ArReclass => 0, + }; + // The hold size for the sidecar row (no-op `min` on an outcome, where + // `net` already is the stored hold); the `Advance` op does not rewrite it. + let cash_hold_minor = net.min(input.disputed_amount_minor); + let clawed_back = clawed_back_on_post(&input, net); + if clawed_back > 0 { + self.guard_not_on_refunded(scope, &input, clawed_back) + .await?; + } + let mut entry = build_chargeback_entry(&input, net)?; + overwrite_header(&mut entry, ctx, req.effective_at); + for line in &mut entry.lines { + line.account_id = resolve_line(&chart, line).ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + line.account_class.as_str(), + line.revenue_stream, + line.currency + )) + })?; + } + // Slice 5 (F3): functional carry-forward on the deferred-apply path too — + // a queued won/lost drains after its opened lands; same carried-rate + // reclassification as `post_phase_inline` (see `apply_fx_carry_forward`). + self.apply_fx_carry_forward(scope, &input, &mut entry) + .await?; + let sidecar: Arc = Arc::new(QueuedChargebackApplySidecar { + inner: ChargebackSidecar { + tenant: req.tenant_id, + dispute_id: req.dispute_id.clone(), + payment_id: req.payment_id.clone(), + currency: req.currency.clone(), + variant, + cycle: req.cycle, + disputed_amount_minor: req.disputed_amount_minor, + cash_hold_minor, + op: ChargebackDisputeOp::Advance { + last_phase: req.phase, + clawed_back_minor: clawed_back, + }, + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }, + flow: FLOW_CHARGEBACK.to_owned(), + business_id: business_id.to_owned(), + tenant: req.tenant_id, + }); + let (new_entry, new_lines) = self.to_engine_inputs(scope, entry).await?; + let posting = self + .posting + .post_queued_apply(ctx, scope, new_entry, new_lines, Some(sidecar)) + .await?; + Ok(posting) + } + + /// Drain up to `limit` due queued dispute phases for one tenant: claim them + /// under `SKIP LOCKED` in a short claim txn, then apply EACH in its OWN txn + /// (the "apply is a second txn" shape, §4.7). `Blocked` ⇒ bump attempts + + /// back off via `apply_after`; `NotReady` ⇒ skip (no bump). Mirrors + /// [`crate::infra::payment::allocate::AllocationService::drain`]. + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn fails; per-row + /// faults are isolated inside the pass. + pub async fn drain( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + let now = Utc::now(); + let pending_queue = self.pending_queue.clone(); + let scope_owned = scope.clone(); + let claimed: Vec = self + .db + .transaction(move |txn| { + Box::pin(async move { + pending_queue + .claim_due(txn, &scope_owned, tenant, FLOW_CHARGEBACK, now, limit) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("chargeback drain claim: {e}")))?; + + let mut report = DrainReport::default(); + for row in claimed { + match self.apply_queued_row(ctx, scope, &row).await { + Ok(ApplyOutcome::Applied(_)) => report.applied += 1, + Ok(ApplyOutcome::NotReady) => report.not_ready += 1, + Ok(ApplyOutcome::Blocked(err)) => { + report.blocked += 1; + if let Err(bump_err) = self + .bump_attempts_own_txn( + scope, + tenant, + &row.business_id, + i64::from(row.attempts), + ) + .await + { + tracing::error!( + tenant_id = %tenant, + business_id = %row.business_id, + blocked_by = %err, + error = %bump_err, + "bss-ledger: chargeback drain failed to bump attempts for blocked phase" + ); + } else { + tracing::warn!( + tenant_id = %tenant, + business_id = %row.business_id, + error = %err, + "bss-ledger: queued dispute phase blocked at apply (attempts bumped, left QUEUED)" + ); + } + } + Err(e) => { + tracing::error!( + tenant_id = %tenant, + business_id = %row.business_id, + error = %e, + "bss-ledger: queued dispute phase apply failed (infra); continuing" + ); + } + } + } + Ok(report) + } + + /// Bump one queue row's `attempts` + defer its next eligibility by an + /// exponential backoff, in its own short transaction. Mirrors + /// [`crate::infra::payment::allocate::AllocationService`]'s `bump_attempts_own_txn`. + async fn bump_attempts_own_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + business_id: &str, + prior_attempts: i64, + ) -> Result<(), DomainError> { + let scope_owned = scope.clone(); + let business_id = business_id.to_owned(); + let defer_until = Utc::now() + blocked_backoff(prior_attempts + 1); + self.db + .transaction(move |txn| { + Box::pin(async move { + PendingQueueRepo::bump_attempts_and_defer( + txn, + &scope_owned, + tenant, + FLOW_CHARGEBACK, + &business_id, + defer_until, + ) + .await + .map_err(|e| DbError::Sea(DbErr::Custom(e.to_string()))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("chargeback drain bump attempts: {e}"))) + } + + /// Read the net cash-leg size for a `CASH_HOLD` dispute (Model N): + /// `net = settled_minor − fee_minor`, the cash that actually entered + /// `CASH_CLEARING` at `settle` (the PSP fee went straight to `PSP_FEE_EXPENSE`, + /// never into clearing). Read out-of-txn before the build so the pure builder + /// can size every `CASH_HOLD` cash leg (opened/won/lost) at `net`. + /// + /// Only called for the `CASH_HOLD` variant — an `AR_RECLASS` dispute has no + /// PSP fee and no cash leg, so the caller passes `0` without reading. + /// + /// # Errors + /// [`DomainError::Internal`] on a storage failure, or when the settlement is + /// ABSENT — a `CASH_HOLD` dispute on a payment that was never settled is an + /// upstream contract violation (the cash it claims to hold never moved). + async fn read_net( + &self, + scope: &AccessScope, + input: &ChargebackInput, + ) -> Result { + let settlement = self + .payment_repo + .read_settlement(scope, input.tenant_id, &input.payment_id) + .await + .map_err(|e| DomainError::Internal(format!("read settlement: {e}")))? + .ok_or_else(|| { + DomainError::Internal(format!( + "CASH_HOLD chargeback on payment {} has no settlement row \ + (cannot size the net cash leg)", + input.payment_id + )) + })?; + // The dispute must be denominated in the SETTLED currency: this read sizes + // the net cash leg (`settled − fee`) and the sidecar updates that same + // settlement row, while the journal legs post in `input.currency`. A + // mismatch (mistyped or malicious — e.g. a USD payment disputed as EUR) + // would post foreign legs against the original payment — reject before + // sizing (mirrors `AllocateService`'s settlement-currency gate). + if settlement.currency != input.currency { + return Err(DomainError::CurrencyMismatch(format!( + "chargeback currency {} != settled currency {} for payment {}", + input.currency, settlement.currency, input.payment_id + ))); + } + Ok(settlement.settled_minor - settlement.fee_minor) + } + + /// `CHARGEBACK_ON_REFUNDED` pre-check: a `lost` cash-out (`clawed_back > 0`) + /// whose clawback cannot fit under the total money-out cap + /// (`refunded_minor + clawed_back_minor + clawed_back > settled_minor`) AND + /// where the payment was already refunded (`refunded_minor > 0`) routes to the + /// minimal exception stub: a logged skip + [`DomainError::ChargebackOnRefunded`] + /// (the full exception queue is Slice 7 / VHP-1859). When the payment was NOT + /// refunded the over-cap is a plain `ChargebackExceedsSettled` (left to the + /// sidecar's cap CHECK). A missing settlement is left to the sidecar too (the + /// clawback bump's `rows_affected == 0` surfaces as Internal — a chargeback on + /// an unsettled payment is an upstream contract violation). + async fn guard_not_on_refunded( + &self, + scope: &AccessScope, + input: &ChargebackInput, + clawed_back: i64, + ) -> Result<(), DomainError> { + let Some(settlement) = self + .payment_repo + .read_settlement(scope, input.tenant_id, &input.payment_id) + .await + .map_err(|e| DomainError::Internal(format!("read settlement: {e}")))? + else { + return Ok(()); + }; + // Widen to i128 for the cap sum: three i64 minor-unit totals could + // overflow at the extreme. The DB cap CHECK is the authoritative backstop, + // but this pre-check must not panic (debug) or wrap (release) before the + // post ever reaches it. + let total_out = i128::from(settlement.refunded_minor) + + i128::from(settlement.clawed_back_minor) + + i128::from(clawed_back); + let fits = total_out <= i128::from(settlement.settled_minor); + if !fits && settlement.refunded_minor > 0 { + tracing::warn!( + tenant_id = %input.tenant_id, + payment_id = %input.payment_id, + dispute_id = %input.dispute_id, + refunded_minor = settlement.refunded_minor, + settled_minor = settlement.settled_minor, + clawed_back, + "bss-ledger: chargeback lost on an already-refunded payment — routed to \ + exception stub (full exception_queue is Slice 7)" + ); + // Slice 7 Phase 2: ADDITIVE close-blocking exception row beside the log + + // the rejection below. Keyed `(dispute_id, payment_id)`; fire-and-forget. + if let Some(ex) = &self.exceptions { + let detail = serde_json::json!({ + "dispute_id": input.dispute_id, + "payment_id": input.payment_id, + "clawed_back_minor": clawed_back, + "refunded_minor": settlement.refunded_minor, + "settled_minor": settlement.settled_minor, + }); + ex.route( + input.tenant_id, + crate::domain::exception::ExceptionType::ChargebackOnRefunded, + &format!("{}:{}", input.dispute_id, input.payment_id), + Some(detail), + ) + .await; + } + return Err(DomainError::ChargebackOnRefunded(format!( + "dispute {} lost on payment {}: clawback {} cannot fit under the money-out cap \ + (refunded={}, clawed={}, settled={})", + input.dispute_id, + input.payment_id, + clawed_back, + settlement.refunded_minor, + settlement.clawed_back_minor, + settlement.settled_minor + ))); + } + Ok(()) + } + + /// Stamp functional **carry-forward** onto a cross-currency chargeback entry + /// (Slice 5 F3, design §3.5 — chargeback close). A dispute phase reclassifies a + /// position WITHOUT locking a new rate: `CASH_HOLD` moves cash + /// `CASH_CLEARING ↔ DISPUTE_HOLD` (and `DISPUTE_HOLD → DISPUTE_LOSS` on a lost + /// forfeit); `AR_RECLASS` moves the receivable `ACTIVE ↔ DISPUTED` at one grain + /// (and `DISPUTED → DISPUTE_LOSS` on a lost write-off). The functional cost + /// basis of the grain it CLOSES therefore carries forward to the counter-leg + /// unchanged — realized FX is recognised only at a cash in/out point (settle + /// S2 / refund S3), never on an internal reclassification. + /// + /// Reads the carried `(functional, transaction)` value of the grain the phase + /// closes and stamps EVERY line's functional at that grain's WAC pro-rata + /// ([`carried_relief`]): + /// - `CASH_HOLD` `opened` → `CASH_CLEARING`; `won`/`lost` → `DISPUTE_HOLD` + /// (`account_balance`, found by the closing leg's bound `account_id`); + /// - `AR_RECLASS` (any) → the disputed AR invoice (`ar_invoice_balance`). + /// + /// Every chargeback entry is two legs of EQUAL transaction amount, so both legs + /// get the SAME functional → the entry's functional column nets to zero (NO + /// `FX_GAIN_LOSS` line) while the closing grain's functional decrements by + /// exactly its pro-rata carried value (a full close → 0), keeping the + /// functional column in lockstep with `balance_minor` under the dual-column + /// commit trigger. + /// + /// No-op (leaves functional NULL — byte-green single-currency path) when the + /// closing grain carries no functional balance (design decision 8) OR when the + /// relieved amount exceeds the grain's balance (an over-relief the projector + /// rejects with `NegativeBalance`; skipping lets that cleaner rejection surface, + /// mirroring allocate F1's pool-underflow guard). + /// + /// # Errors + /// [`DomainError::Internal`] on a carried-read fault, a missing closing leg, or + /// a [`carried_relief`] misuse (a malformed grain value — an internal + /// invariant breach). + async fn apply_fx_carry_forward( + &self, + scope: &AccessScope, + input: &ChargebackInput, + entry: &mut PostEntry, + ) -> Result<(), DomainError> { + // Read the carried (transaction, functional) value of the grain this phase + // CLOSES (the position whose functional cost basis carries forward). + let carried = match input.variant { + DisputeVariant::ArReclass => { + // The disputed AR invoice grain. invoice_id is guaranteed present + // (the builder rejects an AR_RECLASS without it before this runs). + let invoice_id = input.invoice_id.as_deref().ok_or_else(|| { + DomainError::Internal( + "chargeback FX: AR_RECLASS entry has no invoice_id".to_owned(), + ) + })?; + self.payment_repo + .read_ar_invoice_carried( + scope, + input.tenant_id, + input.payer_tenant_id, + invoice_id, + &input.currency, + ) + .await + .map_err(|e| DomainError::Internal(format!("read ar invoice carried: {e}")))? + } + DisputeVariant::CashHold => { + // The cash grain this phase relieves: CASH_CLEARING at `opened` + // (cash leaves clearing into the hold), DISPUTE_HOLD at + // `won`/`lost` (the hold is released / forfeited). `partial` is + // rejected in the builder; map it to DISPUTE_HOLD defensively. + let closing_class = match input.phase { + DisputePhase::Opened => AccountClass::CashClearing, + DisputePhase::Won | DisputePhase::Lost | DisputePhase::Partial => { + AccountClass::DisputeHold + } + }; + let account_id = entry + .lines + .iter() + .find(|l| l.account_class == closing_class) + .map(|l| l.account_id) + .ok_or_else(|| { + DomainError::Internal(format!( + "chargeback FX: CASH_HOLD entry has no {} leg", + closing_class.as_str() + )) + })?; + self.payment_repo + .read_account_carried(scope, input.tenant_id, account_id, &input.currency) + .await + .map_err(|e| DomainError::Internal(format!("read account carried: {e}")))? + } + }; + + // Cross-currency detect (design decision 8): the closing grain carries a + // functional balance. NULL ⇒ single-currency close: leave functional NULL. + let (Some(carried_functional), Some(functional_ccy)) = ( + carried.functional_balance_minor, + carried.functional_currency, + ) else { + return Ok(()); + }; + + // The closing leg's relieved transaction amount (every chargeback leg + // shares the amount, so the first line's amount is it). A non-positive + // carried balance or an over-relief ⇒ skip carry-forward so the projector's + // NegativeBalance surfaces (the close never posts cleanly either way). + let relieved = entry.lines.first().map_or(0, |l| l.amount_minor); + if carried.balance_minor <= 0 || relieved > carried.balance_minor { + return Ok(()); + } + + // Stamp every line's functional at the grain's WAC pro-rata of its OWN + // amount. All legs share the amount, so both get the same value → the + // functional column nets to zero (carry-forward; no FX line) and the + // closing grain decrements by exactly its pro-rata carried functional. + for line in &mut entry.lines { + let func = carried_relief(carried_functional, carried.balance_minor, line.amount_minor) + .map_err(|e| DomainError::Internal(format!("chargeback FX carry-forward: {e}")))?; + line.functional_amount_minor = Some(func); + line.functional_currency = Some(functional_ccy.clone()); + } + Ok(()) + } + + /// Map an already-account-bound [`PostEntry`] to the engine's + /// `NewEntry`/`NewLine`, resolving each line's scale, and post INLINE with the + /// chargeback sidecar. + async fn post_bound( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: PostEntry, + sidecar: Arc, + ) -> Result { + let (new_entry, new_lines) = self.to_engine_inputs(scope, entry).await?; + self.posting + .post(ctx, scope, new_entry, new_lines, Some(sidecar)) + .await + } + + /// Map an already-account-bound [`PostEntry`] to the engine's `NewEntry` + + /// `Vec`, resolving each line's currency scale. Shared by the inline + /// and deferred-apply post paths. + async fn to_engine_inputs( + &self, + scope: &AccessScope, + entry: PostEntry, + ) -> Result<(NewEntry, Vec), DomainError> { + let new_entry = NewEntry { + entry_id: entry.entry_id, + tenant_id: entry.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: entry.tenant_id, + period_id: entry.period_id.clone(), + entry_currency: entry.entry_currency.clone(), + source_doc_type: entry.source_doc_type, + source_business_id: entry.source_business_id.clone(), + reverses_entry_id: entry.reverses_entry_id, + reverses_period_id: entry.reverses_period_id.clone(), + posted_at_utc: Utc::now(), + effective_at: entry.effective_at, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: entry.posted_by_actor_id, + correlation_id: entry.correlation_id, + rounding_evidence: serde_json::Value::Null, + // Slice 5 (F3): a chargeback locks NO new rate — it reclassifies at the + // carried rate (functional carry-forward in `apply_fx_carry_forward`), + // so the entry never carries a rate_snapshot_ref. + rate_snapshot_ref: None, + }; + let mut new_lines: Vec = Vec::with_capacity(entry.lines.len()); + for line in entry.lines { + let scale = self + .resolver + .resolve(scope, entry.tenant_id, &line.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + new_lines.push(new_line(line, scale)); + } + Ok((new_entry, new_lines)) + } + + /// Emit `chargeback(outcome)` + the `Chargeback`-labelled payment-post + /// duration for one attempt. A `Queued` outcome is an ENQUEUE, not a post: it + /// records ONLY the duration (the post — and the Posted/Replayed/Rejected + /// outcome — happens later on the drain), mirroring the allocate `record`. + fn record(&self, result: &Result, started: Instant) { + match result { + Ok(ChargebackOutcome::Recorded(r)) => { + let outcome = if r.replayed { + PostResult::Replayed + } else { + PostResult::Posted + }; + self.metrics.chargeback(outcome); + } + Err(_) => self.metrics.chargeback(PostResult::Rejected), + // Enqueue: duration only, no outcome counter. + Ok(ChargebackOutcome::Queued(_)) => {} + } + self.metrics + .payment_post_duration(started.elapsed().as_secs_f64(), PostFlow::Chargeback); + } +} + +/// Guard the `∅ → opened` / `{won,lost} → opened` transition (design §2): an +/// `opened` is valid only when the dispute has no row yet, or its prior cycle +/// already ended (`last_phase` is a terminal `WON`/`LOST`). An `opened` on a +/// dispute whose `last_phase` is still `OPENED` (or `PARTIAL`) is an illegal +/// re-open and is rejected. +fn guard_open_transition( + existing: Option<&crate::infra::storage::entity::dispute::Model>, + dispute_id: &str, +) -> Result<(), DomainError> { + let Some(row) = existing else { + return Ok(()); + }; + match DisputePhase::parse(&row.last_phase) { + Some(DisputePhase::Won | DisputePhase::Lost) => Ok(()), + _ => Err(DomainError::InvalidDisputeTransition(format!( + "dispute {dispute_id} is already {} — cannot open a new cycle until it is won/lost", + row.last_phase + ))), + } +} + +/// Guard the `opened → {won,lost}` transition (design §2): an outcome is valid +/// only when the dispute's current `last_phase` is `OPENED`. A `won`/`lost` on a +/// dispute that already resolved (`WON`/`LOST`) or was never opened is an illegal +/// transition. +fn guard_outcome_transition( + row: &crate::infra::storage::entity::dispute::Model, + dispute_id: &str, +) -> Result<(), DomainError> { + match DisputePhase::parse(&row.last_phase) { + Some(DisputePhase::Opened) => Ok(()), + _ => Err(DomainError::InvalidDisputeTransition(format!( + "dispute {dispute_id} is {} — only an OPENED dispute can be won/lost", + row.last_phase + ))), + } +} + +/// Overwrite the placeholder header fields the pure builder emits (mirrors +/// [`crate::infra::payment::settlement_return`]'s overwrite): derive the +/// `period_id` (YYYYMM) and a real `effective_at` from the phase instant +/// (`None` ⇒ now), stamp the actor, and mint a fresh correlation id. +fn overwrite_header( + entry: &mut PostEntry, + ctx: &SecurityContext, + effective_at: Option>, +) { + let eff_instant = effective_at.unwrap_or_else(Utc::now); + let eff_date = eff_instant.date_naive(); + entry.effective_at = eff_date; + entry.period_id = format!("{:04}{:02}", eff_date.year(), eff_date.month()); + entry.posted_by_actor_id = ctx.subject_id(); + entry.correlation_id = Uuid::now_v7(); +} + +/// Thin `PostLine` adapter over [`ChartIndex::resolve`]. The chargeback classes +/// (`DISPUTE_HOLD` / `CASH_CLEARING` / `AR` / `DISPUTE_LOSS_EXPENSE`) are all +/// stream-less, so this resolves on `stream = None`. +fn resolve_line(chart: &ChartIndex, line: &PostLine) -> Option { + chart.resolve( + line.account_class, + &line.currency, + line.revenue_stream.as_deref(), + ) +} + +/// Map one SDK [`PostLine`] + its resolved scale to the engine's [`NewLine`] +/// (mirrors `settlement_return::new_line`). +fn new_line(line: PostLine, scale: u8) -> NewLine { + NewLine { + line_id: line.line_id, + payer_tenant_id: line.payer_tenant_id, + seller_tenant_id: line.seller_tenant_id, + resource_tenant_id: line.resource_tenant_id, + account_id: line.account_id, + account_class: line.account_class, + gl_code: line.gl_code, + side: line.side, + amount_minor: line.amount_minor, + currency: line.currency, + currency_scale: scale, + invoice_id: line.invoice_id, + due_date: line.due_date, + revenue_stream: line.revenue_stream, + mapping_status: line.mapping_status, + functional_amount_minor: line.functional_amount_minor, + functional_currency: line.functional_currency, + tax_jurisdiction: line.tax_jurisdiction, + tax_filing_period: line.tax_filing_period, + tax_rate_ref: line.tax_rate_ref, + legal_entity_id: None, + invoice_item_ref: line.invoice_item_ref, + sku_or_plan_ref: line.sku_or_plan_ref, + price_id: line.price_id, + pricing_snapshot_ref: line.pricing_snapshot_ref, + po_allocation_group: line.po_allocation_group, + credit_grant_event_type: line.credit_grant_event_type, + ar_status: line.ar_status, + } +} + +/// Is this rejection an apply-time guard/cap failure (so the drain leaves the row +/// `QUEUED` + bumps attempts), as opposed to an infra fault (which propagates)? +/// The blocked set is the guard/cap family a chargeback apply can hit against +/// then-current state: the transition guard, the clawback cap, the +/// already-refunded route, a closed period, the projector's no-negative guard, an +/// unprovisioned chart, and a malformed-amount/invoice request. Everything else +/// (notably [`DomainError::Internal`]) is infra. +fn is_apply_blocked(err: &DomainError) -> bool { + matches!( + err, + DomainError::InvalidDisputeTransition(_) + | DomainError::ChargebackExceedsSettled(_) + | DomainError::ChargebackOnRefunded(_) + | DomainError::MoneyOutCapExceeded(_) + | DomainError::NegativeBalance(_) + | DomainError::PeriodClosed(_) + | DomainError::AccountClosed(_) + | DomainError::InvalidRequest(_) + ) +} + +/// Exponential backoff (wall-clock) before a `Blocked` queued dispute phase may +/// be re-claimed (mirrors the allocate `blocked_backoff`): ~`2^(attempts-1)` +/// seconds, capped at 5 minutes — keeps a durably-`Blocked` ("poison") row from +/// hot-looping the drain before Phase 5 adds quarantine. +fn blocked_backoff(attempts: i64) -> Duration { + const BASE_SECS: i64 = 2; + const MAX_SECS: i64 = 300; + let shift = attempts.clamp(1, 16) - 1; + let secs = BASE_SECS.saturating_mul(1_i64 << shift).min(MAX_SECS); + Duration::seconds(secs) +} + +/// The request-based idempotency hash for a queued dispute phase — the +/// `content_hash` of the canonical [`QueuedDisputePayload`]. Stable across the +/// apply's state-dependent entry rebuild, so it (not an entry hash) is what the +/// queued-intake dedup row stores; the early `replay_short_circuit` / +/// `claim_queued` compare it to reject a same-key / different-payload reuse. +fn dispute_request_hash(payload: &QueuedDisputePayload) -> Result { + let canonical = serde_json::to_string(payload) + .map_err(|e| DomainError::Internal(format!("canonicalize dispute payload: {e}")))?; + Ok(IdempotencyGate::content_hash(&canonical)) +} + +/// Composite in-transaction sidecar for the deferred apply of a queued dispute +/// phase: it runs the SAME dispute writes + clawback bump + `dispute.recorded` +/// publish as the inline path (by delegating to the wrapped [`ChargebackSidecar`]) +/// and THEN flips the work-state queue row `→APPLIED` — both inside the post txn +/// opened by [`PostingService::post_queued_apply`]. So the dispute effect and the +/// queue-row transition commit atomically, or roll back together. Mirrors +/// [`crate::infra::payment::allocate`]'s `QueuedAllocationApplySidecar`. +struct QueuedChargebackApplySidecar { + inner: ChargebackSidecar, + flow: String, + business_id: String, + tenant: Uuid, +} + +#[async_trait::async_trait] +impl PostSidecar for QueuedChargebackApplySidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + posted: &crate::infra::posting::service::PostedFacts, + ) -> Result<(), DomainError> { + // 1. The dispute writes + clawback bump + the in-txn `dispute.recorded` + // publish (delegated — the cap CHECK is re-evaluated here). + self.inner.run(txn, scope, posted).await?; + // 2. Flip the work-state queue row `→APPLIED` in the SAME txn. + PendingQueueRepo::mark_applied(txn, scope, self.tenant, &self.flow, &self.business_id) + .await + .map_err(|e| DomainError::Internal(format!("queue-apply mark_applied: {e}")))?; + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/payment/credit.rs b/gears/bss/ledger/ledger/src/infra/payment/credit.rs new file mode 100644 index 000000000..f90fdff0d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/payment/credit.rs @@ -0,0 +1,606 @@ +//! `CreditApplicationService` — the orchestrator that drives the pure +//! reusable-credit (wallet) domain (`crate::domain::payment::credit`) through the +//! foundation engine. It moves money in and out of a tenant's reusable-credit +//! wallet (architecture §5.2) and is the credit counterpart to +//! [`crate::infra::payment::allocate::AllocationService`] — same deps, same +//! header/chart/post plumbing, two flows instead of one. +//! +//! - **grant** ([`grant_credit`](CreditApplicationService::grant_credit)) — parks +//! unallocated pool cash into the wallet: **DR `UNALLOCATED`** / **CR +//! `REUSABLE_CREDIT`**. Capped at the payer's live unallocated pool +//! ([`PaymentRepo::read_unallocated`]) — a grant of more than the pool holds is +//! rejected with [`DomainError::GrantExceedsUnallocated`] before any post (the +//! pool can't fund credit it doesn't have). +//! - **apply** ([`apply_credit`](CreditApplicationService::apply_credit)) — spends +//! the wallet against open receivables: **N×DR `REUSABLE_CREDIT`** (one per drawn +//! sub-grain) / **M×CR `AR`** (one per receivable). Two caps bound it, each read +//! from live ledger state: the receivable side is validated against the payer's +//! open AR candidates ([`validate_credit_targets`] ⇒ +//! [`DomainError::CreditExceedsOpenAr`]), and the wallet side is planned against +//! the payer's spendable sub-grains oldest-grant-first ([`plan_wallet_debit`] ⇒ +//! [`DomainError::CreditExceedsWallet`]). The drawn total always equals the +//! target total, so the entry balances by construction. +//! +//! Sequence for one flow: +//! 1. **read the cap state** — the unallocated pool (grant) or the open AR +//! candidates + spendable wallet sub-grains (apply). +//! 2. **decide / validate** — for apply, validate the caller's targets against the +//! open candidates and plan the wallet draw-down for their total; grant has no +//! split to decide (it is a single pool→wallet move). +//! 3. **build** the balanced entry ([`build_grant_entry`] / [`build_apply_entry`]), +//! **overwrite** its placeholder header, **bind** chart `account_id`s, and +//! **post**. +//! 4. **emit metrics** — `credit_application` (outcome) + the payment-post +//! duration under [`PostFlow::CreditApply`]. +//! +//! **No sidecar, no settlement gate.** Unlike allocate, credit is *wallet-sourced*, +//! not *payment-sourced*: there is no per-payment money-out cap to enforce in-txn, +//! so no [`PostSidecar`] is threaded (`None` is passed to the engine). The wallet +//! balance is itself a projector grain — the reusable-credit sub-balance cache the +//! engine maintains from the posted lines — so there is no counter table to bump +//! and no settlement row to gate on. Idempotent on `(tenant, CREDIT_APPLY, +//! credit_application_id)`: a replay of the same `credit_application_id` returns the +//! prior entry with no new ledger effect (both grant and apply share the +//! `CREDIT_APPLY` source doc type — see the domain module). Lives in `infra` (not +//! `domain`) because it needs repo + posting access; the domain module it calls +//! stays pure (dylint DE0301). + +use std::sync::Arc; +use std::time::Instant; + +use bss_ledger_sdk::{PostEntry, PostLine, PostingRef, SourceDocType}; +use chrono::{Datelike, Utc}; +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::payment::credit::{ + ApplyInput, CreditDebit, GrantInput, build_apply_entry, build_grant_entry, plan_wallet_debit, + validate_credit_targets, +}; +use crate::domain::payment::precedence::{Allocated, Candidate}; +use crate::domain::ports::metrics::{LedgerMetricsPort, PostFlow, PostResult}; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::posting::chart::{ChartIndex, load_chart}; +use crate::infra::posting::idempotency::IdempotencyGate; +use crate::infra::posting::service::{PostSidecar, PostingService}; +use crate::infra::storage::repo::{PaymentRepo, ReferenceRepo}; + +/// Origin literal stamped on posts made through this service. +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// One grant request: park `amount_minor` of the payer's unallocated pool into +/// their reusable-credit wallet sub-grain. +pub struct GrantRequest { + /// The seller tenant whose ledger this posts into. + pub tenant_id: Uuid, + /// The tenant whose wallet is credited (the pool owner / single payer). + pub payer_tenant_id: Uuid, + /// The `CREDIT_APPLY` idempotency business id. + pub credit_application_id: String, + /// ISO currency of the grant. + pub currency: String, + /// Amount to park into the wallet, in minor units. Capped at the payer's live + /// unallocated pool. + pub amount_minor: i64, + /// The wallet sub-grain bucket the credit accrues to. + pub credit_grant_event_type: String, +} + +/// One apply request: spend the payer's reusable-credit wallet against the named +/// open receivables. +pub struct ApplyRequest { + /// The seller tenant whose ledger this posts into. + pub tenant_id: Uuid, + /// The tenant whose wallet is spent and whose receivables are paid (the single + /// payer). + pub payer_tenant_id: Uuid, + /// The `CREDIT_APPLY` idempotency business id. + pub credit_application_id: String, + /// ISO currency of the application. + pub currency: String, + /// The per-invoice receivable shares to apply the wallet to. Validated against + /// the payer's open AR candidates (presence / per-invoice cap / positivity / + /// no-duplicate); their total sizes the wallet draw-down. + pub targets: Vec, +} + +/// The result of a grant or apply: the posting handle, and — for an apply — the +/// per-sub-grain wallet draw-downs (`debits`) and the per-invoice receivable +/// shares (`targets`) the post wrote. A grant moves no wallet/AR splits, so both +/// vectors are empty for it. +#[derive(Debug)] +pub struct CreditApplicationOutcome { + pub posting: PostingRef, + pub debits: Vec, + pub targets: Vec, +} + +/// Orchestrates the reusable-credit domain (grant / apply) over the foundation +/// engine. +pub struct CreditApplicationService { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + repo: PaymentRepo, + metrics: Arc, +} + +impl CreditApplicationService { + /// Build the service over one database provider, the event publisher + /// (threaded into the posting engine), and the metrics sink. Same deps as + /// [`crate::infra::payment::allocate::AllocationService`]. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + let posting = PostingService::new(db.clone(), publisher); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + let repo = PaymentRepo::new(db); + Self { + posting, + reference, + resolver, + repo, + metrics, + } + } + + /// Return the prior posting as a replay when `credit_application_id` already + /// finalized a `CREDIT_APPLY` post for `tenant` — the idempotency + /// short-circuit that runs BEFORE the state-dependent caps. Both flows + /// validate against mutable ledger state (grant against the live unallocated + /// pool, apply against open AR + the wallet), so without this a + /// retry-after-success would re-read the now-drained state and reject (e.g. a + /// full-payment apply retried after the AR closed). The engine's in-txn claim + /// in `post` remains the authoritative dedup for a concurrent first post; a + /// `None` here just proceeds into that path. The replayed outcome carries no + /// `debits`/`targets` (the splits were returned by the original call; a + /// replay key on the entry id). + async fn replay_if_posted( + &self, + scope: &AccessScope, + tenant: Uuid, + credit_application_id: &str, + expected_hash: &str, + ) -> Result, DomainError> { + let Some((entry_id, stored_hash)) = self + .repo + .lookup_finalized_post( + scope, + tenant, + SourceDocType::CreditApply, + credit_application_id, + ) + .await + .map_err(|e| DomainError::Internal(format!("idempotency lookup: {e}")))? + else { + return Ok(None); + }; + // A replay must carry the SAME request payload. A reuse of + // `credit_application_id` with a different kind / amount / currency / + // targets is an idempotency-key conflict, not a replay — reject it rather + // than silently returning the prior posting (the engine's in-txn claim + // makes the same comparison for a concurrent first post). + if stored_hash != expected_hash { + return Err(DomainError::IdempotencyConflict(format!( + "credit_application_id {credit_application_id} reused with a different payload" + ))); + } + Ok(Some(CreditApplicationOutcome { + posting: PostingRef { + entry_id, + // A replay carries the prior, finalized entry id; the sequence is + // not re-read (replay callers key on the id — mirrors the engine's + // own replay `PostingRef`). + created_seq: 0, + replayed: true, + }, + debits: vec![], + targets: vec![], + })) + } + + /// Grant `amount_minor` of the payer's unallocated pool into their wallet + /// sub-grain. Returns the posting handle (the `debits`/`targets` are empty — + /// a grant moves no wallet/AR splits). + /// + /// On success emits `credit_application(Posted | Replayed)` + the payment-post + /// duration; every rejection emits `credit_application(Rejected)` + the + /// duration. + /// + /// # Errors + /// [`DomainError::GrantExceedsUnallocated`] when the grant amount exceeds the + /// payer's live unallocated pool; [`DomainError::InvalidRequest`] when the + /// amount is non-positive or the event-type is empty (the builder's shape + /// checks); any foundation rejection or [`DomainError::Internal`] on an infra + /// fault. + pub async fn grant_credit( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: GrantRequest, + ) -> Result { + let started = Instant::now(); + let result = self.grant_credit_inner(ctx, scope, req).await; + self.record(&result, started); + result + } + + /// Run the grant sequence (no metrics — the public wrapper records them). + async fn grant_credit_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: GrantRequest, + ) -> Result { + // 0. Idempotency short-circuit: a retry of an already-finalized grant + // returns the prior posting BEFORE the unallocated-pool cap re-reads + // the (now-reduced) pool and would spuriously reject. The request-based + // hash lets the short-circuit reject a same-id / different-payload reuse. + let request_hash = grant_request_hash(&req); + if let Some(replay) = self + .replay_if_posted( + scope, + req.tenant_id, + &req.credit_application_id, + &request_hash, + ) + .await? + { + return Ok(replay); + } + + // 1. Cap the grant at the payer's live unallocated pool — the pool can't + // fund credit it doesn't hold. SQL-level BOLA: a foreign tenant reads 0. + let available = self + .repo + .read_unallocated(scope, req.tenant_id, req.payer_tenant_id, &req.currency) + .await + .map_err(|e| DomainError::Internal(format!("read unallocated: {e}")))?; + if req.amount_minor > available { + return Err(DomainError::GrantExceedsUnallocated(format!( + "grant {} exceeds available unallocated {} for payer {}", + req.amount_minor, available, req.payer_tenant_id + ))); + } + + // 2. Build the balanced grant entry (DR UNALLOCATED / CR REUSABLE_CREDIT), + // overwrite the placeholder header, bind chart account_ids, and post — + // no sidecar (the wallet balance is a projector grain, not a counter). + let mut entry = build_grant_entry(&GrantInput { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + credit_application_id: req.credit_application_id.clone(), + currency: req.currency.clone(), + amount_minor: req.amount_minor, + credit_grant_event_type: req.credit_grant_event_type.clone(), + // Credit posts effective-now; thread a request field here if a + // back-dated grant is ever needed. + effective_at: None, + })?; + overwrite_header(&mut entry, ctx); + self.bind_chart(scope, &mut entry).await?; + + let posting = self + .post_bound(ctx, scope, entry, None, request_hash) + .await?; + Ok(CreditApplicationOutcome { + posting, + debits: vec![], + targets: vec![], + }) + } + + /// Apply the payer's reusable-credit wallet against the named open + /// receivables, drawing the wallet down oldest-grant-first. Returns the + /// posting handle, the per-sub-grain draw-downs, and the validated per-invoice + /// shares. + /// + /// On success emits `credit_application(Posted | Replayed)` + the payment-post + /// duration; every rejection emits `credit_application(Rejected)` + the + /// duration. + /// + /// # Errors + /// [`DomainError::CreditExceedsOpenAr`] when a target names an unknown/closed + /// invoice, over-applies an invoice, repeats one, or is non-positive; + /// [`DomainError::CreditExceedsWallet`] when the payer's spendable wallet + /// cannot cover the target total; [`DomainError::InvalidRequest`] when the + /// built entry fails a shape/balance check; any foundation rejection or + /// [`DomainError::Internal`] on an infra fault. + pub async fn apply_credit( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: ApplyRequest, + ) -> Result { + let started = Instant::now(); + let result = self.apply_credit_inner(ctx, scope, req).await; + self.record(&result, started); + result + } + + /// Run the apply sequence (no metrics — the public wrapper records them). + async fn apply_credit_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + req: ApplyRequest, + ) -> Result { + // 0. Idempotency short-circuit: a retry of an already-finalized apply + // returns the prior posting BEFORE the open-AR / wallet caps re-read + // the (now-drained) state and would spuriously reject (e.g. a + // full-payment apply retried after the invoice closed). The request-based + // hash lets the short-circuit reject a same-id / different-payload reuse. + let request_hash = apply_request_hash(&req); + if let Some(replay) = self + .replay_if_posted( + scope, + req.tenant_id, + &req.credit_application_id, + &request_hash, + ) + .await? + { + return Ok(replay); + } + + // 1. Read the open AR candidate set (oldest-first) — the receivable side's + // cap basis. SQL-level BOLA: a foreign tenant yields no rows. + let rows = self + .repo + .list_open_ar_invoices(scope, req.tenant_id, req.payer_tenant_id, &req.currency) + .await + .map_err(|e| DomainError::Internal(format!("list open ar invoices: {e}")))?; + let candidates: Vec = rows + .into_iter() + .map(|r| Candidate { + invoice_id: r.invoice_id, + open_minor: r.balance_minor, + original_posted_at: r.original_posted_at, + }) + .collect(); + + // 2. Validate the caller's targets against the open candidates (presence / + // per-invoice cap / positivity / no-duplicate); the validated shares are + // the CR AR side, in the caller's order. + let targets = validate_credit_targets(&candidates, &req.targets)?; + // 3. The target total sizes the wallet draw-down (Σ DR == Σ CR). Sum in + // i128 to avoid an i64 overflow on a large validated target set + // (mirrors the domain builder's i128 accumulation), then narrow — an + // out-of-range total is rejected, never silently wrapped. + let total_minor: i128 = targets.iter().map(|t| i128::from(t.amount_minor)).sum(); + let total = i64::try_from(total_minor).map_err(|_| { + DomainError::AmountOutOfRange("credit target total exceeds i64 range".to_owned()) + })?; + + // 4. Read the payer's spendable wallet sub-grains (oldest-grant-first) and + // plan the draw-down for the total — the wallet-side cap is enforced + // here (Σ available < total ⇒ CreditExceedsWallet). + let subgrains = self + .repo + .list_credit_subgrains(scope, req.tenant_id, req.payer_tenant_id, &req.currency) + .await + .map_err(|e| DomainError::Internal(format!("list credit subgrains: {e}")))?; + let debits = plan_wallet_debit(&subgrains, total)?; + + // 5. Build the balanced apply entry (N×DR REUSABLE_CREDIT / M×CR AR), + // overwrite the placeholder header, bind chart account_ids, and post — + // no sidecar (the wallet balance is a projector grain, not a counter). + let mut entry = build_apply_entry(&ApplyInput { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + credit_application_id: req.credit_application_id.clone(), + currency: req.currency.clone(), + debits: debits.clone(), + targets: targets.clone(), + // Credit posts effective-now; thread a request field here if a + // back-dated apply is ever needed. + effective_at: None, + })?; + overwrite_header(&mut entry, ctx); + self.bind_chart(scope, &mut entry).await?; + + let posting = self + .post_bound(ctx, scope, entry, None, request_hash) + .await?; + Ok(CreditApplicationOutcome { + posting, + debits, + targets, + }) + } + + /// Bind each line's chart `account_id` from its `(account_class, currency)` — + /// the credit classes (UNALLOCATED / `REUSABLE_CREDIT` / AR) are stream-less. + /// An unprovisioned account surfaces as [`DomainError::AccountClosed`]. + async fn bind_chart( + &self, + scope: &AccessScope, + entry: &mut PostEntry, + ) -> Result<(), DomainError> { + let chart = load_chart(&self.reference, scope, entry.tenant_id).await?; + for line in &mut entry.lines { + line.account_id = resolve_line(&chart, line).ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + line.account_class.as_str(), + line.revenue_stream, + line.currency + )) + })?; + } + Ok(()) + } + + /// Map an already-account-bound [`PostEntry`] to the engine's + /// `NewEntry`/`NewLine`, resolving each line's scale, and post. `sidecar` is + /// always `None` for credit (the wallet balance is a projector grain — no + /// in-txn counter write to thread). Binds the request-based idempotency hash + /// (`post_with_request_hash`) so the replay short-circuit can reject a same-id + /// / different-payload reuse. + async fn post_bound( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: PostEntry, + sidecar: Option>, + request_hash: String, + ) -> Result { + let new_entry = NewEntry { + entry_id: entry.entry_id, + tenant_id: entry.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: entry.tenant_id, + period_id: entry.period_id.clone(), + entry_currency: entry.entry_currency.clone(), + source_doc_type: entry.source_doc_type, + source_business_id: entry.source_business_id.clone(), + reverses_entry_id: entry.reverses_entry_id, + reverses_period_id: entry.reverses_period_id.clone(), + posted_at_utc: Utc::now(), + effective_at: entry.effective_at, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: entry.posted_by_actor_id, + correlation_id: entry.correlation_id, + rounding_evidence: serde_json::Value::Null, + // Slice 5: reusable-credit wallet posts are same-currency in v1. + rate_snapshot_ref: None, + }; + let mut new_lines: Vec = Vec::with_capacity(entry.lines.len()); + for line in entry.lines { + let scale = self + .resolver + .resolve(scope, entry.tenant_id, &line.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + new_lines.push(new_line(line, scale)); + } + self.posting + .post_with_request_hash(ctx, scope, new_entry, new_lines, sidecar, request_hash) + .await + } + + /// Emit `credit_application(outcome)` + the `CreditApply`-labelled + /// payment-post duration for one attempt (mirrors `allocate::record`). + fn record(&self, result: &Result, started: Instant) { + let outcome = match result { + Ok(o) if o.posting.replayed => PostResult::Replayed, + Ok(_) => PostResult::Posted, + Err(_) => PostResult::Rejected, + }; + self.metrics.credit_application(outcome); + self.metrics + .payment_post_duration(started.elapsed().as_secs_f64(), PostFlow::CreditApply); + } +} + +/// Overwrite the placeholder header fields the pure builder emits: credit posts +/// effective-now, so derive the `period_id` (YYYYMM) and `effective_at` from the +/// wall clock, stamp the actor from the security context, and mint a fresh +/// correlation id. If `period_id` stayed `""` the post would fail the +/// fiscal-period gate, so this overwrite is mandatory (mirrors +/// `allocate::overwrite_header`). +fn overwrite_header(entry: &mut PostEntry, ctx: &SecurityContext) { + let eff_date = Utc::now().date_naive(); + entry.effective_at = eff_date; + entry.period_id = format!("{:04}{:02}", eff_date.year(), eff_date.month()); + entry.posted_by_actor_id = ctx.subject_id(); + entry.correlation_id = Uuid::now_v7(); +} + +/// Thin `PostLine` adapter over [`ChartIndex::resolve`]: the credit classes +/// (UNALLOCATED / `REUSABLE_CREDIT` / AR) are stream-less, so this resolves on +/// `stream = None` (mirrors `allocate::resolve_line`). +fn resolve_line(chart: &ChartIndex, line: &PostLine) -> Option { + chart.resolve( + line.account_class, + &line.currency, + line.revenue_stream.as_deref(), + ) +} + +/// Map one SDK [`PostLine`] + its resolved scale to the engine's [`NewLine`] +/// (mirrors `allocate::new_line`). +fn new_line(line: PostLine, scale: u8) -> NewLine { + NewLine { + line_id: line.line_id, + payer_tenant_id: line.payer_tenant_id, + seller_tenant_id: line.seller_tenant_id, + resource_tenant_id: line.resource_tenant_id, + account_id: line.account_id, + account_class: line.account_class, + gl_code: line.gl_code, + side: line.side, + amount_minor: line.amount_minor, + currency: line.currency, + currency_scale: scale, + invoice_id: line.invoice_id, + due_date: line.due_date, + revenue_stream: line.revenue_stream, + mapping_status: line.mapping_status, + functional_amount_minor: line.functional_amount_minor, + functional_currency: line.functional_currency, + tax_jurisdiction: line.tax_jurisdiction, + tax_filing_period: line.tax_filing_period, + tax_rate_ref: line.tax_rate_ref, + legal_entity_id: None, + invoice_item_ref: line.invoice_item_ref, + sku_or_plan_ref: line.sku_or_plan_ref, + price_id: line.price_id, + pricing_snapshot_ref: line.pricing_snapshot_ref, + po_allocation_group: line.po_allocation_group, + credit_grant_event_type: line.credit_grant_event_type, + ar_status: line.ar_status, + } +} + +/// The request-based idempotency hash for a credit GRANT — `content_hash` over +/// the canonical request fields (a `grant` discriminant + tenant, payer, +/// application id, currency, amount, event type). Stable across the post's +/// state-dependent rebuild, so the dedup row stores it and +/// [`CreditApplicationService::replay_if_posted`] compares it to reject a same +/// `credit_application_id` reused with a different payload. +fn grant_request_hash(req: &GrantRequest) -> String { + let canonical = format!( + "grant\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}", + req.tenant_id, + req.payer_tenant_id, + req.credit_application_id, + req.currency, + req.amount_minor, + req.credit_grant_event_type, + ); + IdempotencyGate::content_hash(&canonical) +} + +/// The request-based idempotency hash for a credit APPLY — `content_hash` over +/// the canonical request fields (an `apply` discriminant + tenant, payer, +/// application id, currency, and the per-invoice targets sorted for +/// order-independence). The `grant` / `apply` discriminant means reusing one +/// `credit_application_id` across the two flows is correctly a conflict. See +/// [`grant_request_hash`]. +fn apply_request_hash(req: &ApplyRequest) -> String { + let mut targets: Vec = req + .targets + .iter() + .map(|t| format!("{}\u{1d}{}", t.invoice_id, t.amount_minor)) + .collect(); + targets.sort(); + let canonical = format!( + "apply\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}", + req.tenant_id, + req.payer_tenant_id, + req.credit_application_id, + req.currency, + targets.join("\u{1e}"), + ); + IdempotencyGate::content_hash(&canonical) +} diff --git a/gears/bss/ledger/ledger/src/infra/payment/queue_apply.rs b/gears/bss/ledger/ledger/src/infra/payment/queue_apply.rs new file mode 100644 index 000000000..de70be0f5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/payment/queue_apply.rs @@ -0,0 +1,278 @@ +//! `QueueApplier` — the deferred-apply driver for queued payment allocations +//! (Slice 2b, Group D). +//! +//! An allocate of a not-yet-settled payment is durably queued at intake (§4.7, +//! `AllocationService::enqueue_allocation`). This applier drains those rows once +//! the settlement lands: for each due `QUEUED` row it re-derives the split +//! against THEN-CURRENT state (caps re-evaluated at apply time) and posts it +//! through the engine's `ClaimMode::QueuedApply` path, atomically flipping the +//! queue row `→APPLIED`. +//! +//! ## Why a thin wrapper over `AllocationService` +//! The apply re-runs the EXACT decide/validate/build path the inline allocate +//! uses (read open AR + cap, resolve precedence or validate a caller split, build +//! the entry, overwrite the header, bind the chart). That logic — and the private +//! helpers it leans on (`overwrite_header`, `resolve_line`, `new_line`, +//! `to_engine_inputs`) — lives on [`AllocationService`]. Rather than duplicate it +//! or widen those helpers' visibility, `QueueApplier` holds the same deps and +//! constructs an `AllocationService`, delegating to its `pub(crate)` +//! [`AllocationService::apply_queued_row`] / public [`AllocationService::drain`]. +//! +//! ## Two-transaction apply (§4.7 "apply is a second txn") +//! Draining is two transaction shapes, never one: a short CLAIM txn selects due +//! rows under `FOR UPDATE SKIP LOCKED`, then EACH row is applied in its OWN txn. +//! The authoritative work-state flip (`→APPLIED`) rides the apply's post txn (the +//! composite sidecar), not the claim. This keeps a slow apply from holding the +//! claim lock across the batch and isolates a per-row failure. +//! +//! `SKIP LOCKED` only REDUCES overlap between concurrent appliers (the sweep job +//! + a drain-on-settle) — it does NOT hand them disjoint batches. The claim txn +//! holds each row lock only for its own (short) duration and does NOT flip the +//! status, so once it commits a second applier can re-select the same still- +//! `QUEUED` row. Exactly-once is therefore enforced NOT by the row lock but by +//! the apply's `SERIALIZABLE` post txn: two appliers of the same row collide on +//! the dedup-row finalize + the `payment_allocation` PK, the loser aborts, and +//! its retry reads `POSTED` and replays. See [`AllocationService::drain`] for the +//! per-outcome handling (`Blocked` ⇒ bump attempts + back off; `NotReady` ⇒ skip). + +use std::sync::Arc; + +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::adjustment::refund_service::{ + ClawbackDrainReport, DisputeHoldDrainReport, QuarantineDrainReport, RefundHandler, +}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::payment::allocate::{ + AllocationService, DrainReport, MAX_INVOICES_PER_ALLOCATION, +}; +use crate::infra::payment::chargeback::{ChargebackService, DrainReport as ChargebackDrainReport}; + +/// Drives the deferred apply of queued payment allocations. Holds the same deps +/// as [`AllocationService`] (`db` / `publisher` / `metrics`) and builds one to +/// delegate the re-derive + queued-apply post. +pub struct QueueApplier { + db: DBProvider, + publisher: Arc, + metrics: Arc, + /// The dual-control engine (Group G de-quarantine): a quarantined refund whose + /// origin landed is re-driven through a GATED `RefundHandler` so an + /// over-THEN-CURRENT-D2 de-quarantine routes to approval (never auto-posts). + /// `None` ⇒ the de-quarantine path posts un-gated (router/unit tests without a + /// governance DB). Wired in `module` via [`Self::with_approval`]. + approval: Option>, + /// The per-allocation touched-invoice cap, threaded into the delegate + /// [`AllocationService`] so a DRAINED (deferred-apply) allocation uses the + /// SAME configured cap as an inline one. Defaults to + /// [`MAX_INVOICES_PER_ALLOCATION`]; set from config via + /// [`Self::with_max_invoices_per_allocation`]. + max_invoices: usize, +} + +impl QueueApplier { + /// Build the applier over one database provider, the event publisher + /// (threaded into the posting engine the delegate builds), and the metrics + /// sink. Same deps — and same shape — as [`AllocationService::new`] and + /// [`crate::infra::payment::settle::SettlementService::new`]. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + Self { + db, + publisher, + metrics, + approval: None, + max_invoices: MAX_INVOICES_PER_ALLOCATION, + } + } + + /// Override the per-allocation touched-invoice cap (from `payments` config), + /// threaded into the delegate [`AllocationService`] so the deferred-apply + /// drain honours the same cap as the inline path. Builder form; defaults to + /// [`MAX_INVOICES_PER_ALLOCATION`]. + #[must_use] + pub fn with_max_invoices_per_allocation(mut self, max_invoices: usize) -> Self { + self.max_invoices = max_invoices; + self + } + + /// Attach the dual-control engine (Group G): the de-quarantine drain + /// ([`Self::drain_quarantine`]) then re-drives a released refund through a GATED + /// [`RefundHandler`], so an over-THEN-CURRENT-D2 de-quarantine routes to approval + /// instead of auto-posting. Builder form (defaults to `None`). + #[must_use] + pub fn with_approval( + mut self, + approval: Arc, + ) -> Self { + self.approval = Some(approval); + self + } + + /// Construct the delegate [`AllocationService`] (cheap — clones the provider + /// Arc + the two `Arc` deps). A fresh service per call keeps `QueueApplier` + /// itself dep-only and avoids holding a second long-lived service. + fn service(&self) -> AllocationService { + AllocationService::new( + self.db.clone(), + Arc::clone(&self.publisher), + Arc::clone(&self.metrics), + ) + .with_max_invoices_per_allocation(self.max_invoices) + } + + /// Construct the delegate [`ChargebackService`] (same shape as + /// [`Self::service`]) for the CHARGEBACK-flow drain (out-of-order `won`/`lost` + /// phases whose `opened` has since landed). + fn chargeback_service(&self) -> ChargebackService { + ChargebackService::new( + self.db.clone(), + Arc::clone(&self.publisher), + Arc::clone(&self.metrics), + ) + } + + /// Construct the delegate [`RefundHandler`] (un-gated — the drain re-drives an + /// already-accepted claw-back, never a fresh preparer decision) for the + /// `REFUND_CLAWBACK`-flow drain (deferred refund-of-refund claw-backs whose + /// matching outbound refund stage-1 may have since landed). Group E. + fn refund_handler(&self) -> RefundHandler { + RefundHandler::new(self.db.clone(), Arc::clone(&self.publisher)) + } + + /// Construct a GATED delegate [`RefundHandler`] for the de-quarantine drain + /// (Group G): `.with_approval(...)` so a released-but-over-THEN-CURRENT-D2 refund + /// routes to the preparer→approver queue instead of auto-posting (design §4.4 — + /// de-quarantine NEVER auto-posts over threshold). `.with_metrics(...)` so a + /// de-quarantine post bumps `ledger_refund_total`. When no approval engine is + /// wired this falls back to the un-gated handler (router/unit tests). + fn gated_refund_handler(&self) -> RefundHandler { + let base = RefundHandler::new(self.db.clone(), Arc::clone(&self.publisher)) + .with_metrics(Arc::clone(&self.metrics)); + match &self.approval { + Some(approval) => base.with_approval(Arc::clone(approval)), + None => base, + } + } + + /// Drain up to `limit` due queued allocations for `tenant`, scoped by + /// `scope`. Delegates to [`AllocationService::drain`] (claim under SKIP LOCKED + /// in a short txn, then apply each row in its own txn). Used by both the + /// drain-on-settle hook ([`crate::infra::payment::settle::SettlementService`]) + /// and the periodic sweep job ([`crate::infra::jobs::queue_applier`]). + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn fails; per-row + /// faults are isolated inside the pass (logged, the row stays `QUEUED`). + pub async fn drain( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + self.service().drain(ctx, scope, tenant, limit).await + } + + /// Drain up to `limit` due queued CHARGEBACK phases for `tenant` (out-of-order + /// `won`/`lost` whose `opened` has since landed). Delegates to + /// [`ChargebackService::drain`] (claim under SKIP LOCKED in a short txn, then + /// apply each row in its own txn). Driven both by the inline drain-on-`opened` + /// hook ([`ChargebackService::record_phase`]) and the periodic sweep job + /// ([`crate::infra::jobs::queue_applier`], the backstop). + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn fails; per-row + /// faults are isolated inside the pass. + pub async fn drain_chargeback( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + self.chargeback_service() + .drain(ctx, scope, tenant, limit) + .await + } + + /// Drain up to `limit` due deferred `REFUND_CLAWBACK` rows for `tenant` (Group E): + /// a refund-of-refund claw-back that underflowed at intake and was queued, whose + /// matching outbound refund stage-1 may have since landed. Delegates to + /// [`RefundHandler::drain_clawbacks`] (claim under SKIP LOCKED, then re-try each + /// row in its own txn; reconciled → APPLIED, still-underflow → back off, aged out + /// → CANCELLED + escalate). Driven by the periodic sweep job + /// ([`crate::infra::jobs::queue_applier`]). + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn fails; per-row faults + /// are isolated inside the pass. + pub async fn drain_clawback( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + self.refund_handler() + .drain_clawbacks(ctx, scope, tenant, limit) + .await + } + + /// Drain up to `limit` due QUARANTINED refund-before-payment rows for `tenant` + /// (Group G de-quarantine): a refund whose origin payment was absent at intake + /// and may have since landed. Delegates to [`RefundHandler::drain_quarantine`] + /// (claim under SKIP LOCKED, then RE-VALIDATE each — re-resolve the origin + + /// re-check the §4.7 caps + the THEN-CURRENT D2 threshold; released → APPLIED, + /// over-threshold → an approval opens (NEVER auto-posts), still-missing → back + /// off, aged out → CANCELLED + escalate). Uses the GATED handler. Driven by the + /// periodic sweep job ([`crate::infra::jobs::queue_applier`]). + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn fails; per-row faults + /// are isolated inside the pass. + pub async fn drain_quarantine( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + self.gated_refund_handler() + .drain_quarantine(ctx, scope, tenant, limit) + .await + } + + /// Drain up to `limit` due DISPUTE-HELD refund rows for `tenant` (Z5-2 / design + /// §5): a refund whose origin payment had an OPEN dispute at intake (the cash leg + /// must not move while the dispute is sub judice). Delegates to + /// [`RefundHandler::drain_dispute_hold`] (claim under SKIP LOCKED, then RE-READ + /// the dispute per row — WON → re-drive the gated post (over-threshold → an + /// approval opens, NEVER auto-posts); LOST → CANCEL the hold + escalate (a + /// chargeback already returned the money — posting would double-pay); still-OPEN → + /// back off, aged out → CANCEL + escalate). Uses the GATED handler. Driven by the + /// periodic sweep job ([`crate::infra::jobs::queue_applier`]). + /// + /// # Errors + /// [`DomainError::Internal`] only if the initial claim txn fails; per-row faults + /// are isolated inside the pass. + pub async fn drain_dispute_hold( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + limit: u64, + ) -> Result { + self.gated_refund_handler() + .drain_dispute_hold(ctx, scope, tenant, limit) + .await + } +} diff --git a/gears/bss/ledger/ledger/src/infra/payment/settle.rs b/gears/bss/ledger/ledger/src/infra/payment/settle.rs new file mode 100644 index 000000000..1476cbba9 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/payment/settle.rs @@ -0,0 +1,367 @@ +//! `SettlementService` — the orchestrator that drives the pure settlement +//! domain (`crate::domain::payment::settlement`) through the foundation engine +//! (Pattern A). It records the **money-in** side of a payment: a settled +//! receipt lands in the payer's unallocated pool (`CR UNALLOCATED` for the +//! gross), the net cash hits clearing (`DR CASH_CLEARING`), and the processor +//! fee — when any — is expensed (`DR PSP_FEE_EXPENSE`). It does NOT move AR; +//! allocation (`AllocationService`) drains the pool into receivables later. +//! +//! It ties the pieces together for one settle post (mirrors the +//! invoice-post orchestrator's shape): +//! 1. **build** the balanced Pattern-A entry +//! (`domain::payment::settlement::build_settlement_entry`). +//! 2. **overwrite header** — the pure builder emits placeholder header fields; +//! the orchestrator stamps the real `period_id` (derived from the effective +//! date), `effective_at` (a real date for the `None` case), the actor (from +//! the security context), and a fresh `correlation_id`. +//! 3. **bind** each line's real chart `account_id` from the provisioned chart of +//! accounts (the pure builder emits a nil placeholder). +//! 4. **resolve scale** per line and **post** via [`PostingService`], threading +//! the [`SettlementSidecar`] so the `payment_settlement` counter row is seeded +//! in the same serializable transaction (or rolled back with the entry). +//! 5. **emit metrics** — `payment_settle` (outcome) + the payment-post duration +//! on every attempt. +//! +//! There is NO payer gate (unlike the invoice-post path): a settlement records +//! money already received and must land even for a closed payer. Lives in +//! `infra` (not `domain`) because it needs repo + posting access; the domain +//! module it calls stays pure (dylint DE0301). + +use std::sync::Arc; +use std::time::Instant; + +use bss_ledger_sdk::{PostEntry, PostLine, PostingRef}; +use chrono::{Datelike, Utc}; +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::payment::settlement::{SettlementInput, build_settlement_entry}; +use crate::domain::ports::metrics::{LedgerMetricsPort, PostFlow, PostResult}; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::fx::rate_locker::RateLocker; +use crate::infra::payment::queue_apply::QueueApplier; +use crate::infra::payment::sidecar::SettlementSidecar; +use crate::infra::posting::chart::{ChartIndex, load_chart}; +use crate::infra::posting::service::{PostSidecar, PostingService}; +use crate::infra::storage::repo::ReferenceRepo; + +/// Origin literal stamped on posts made through this service. +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// Per-tenant cap on the drain-on-settle pass (D3): a sane batch ceiling so a +/// settle that unblocks a large backlog doesn't post an unbounded number of +/// allocations inline on the settle path. The periodic sweep (D4) drains the +/// remainder. +const DRAIN_ON_SETTLE_CAP: u64 = 100; + +/// Orchestrates the settlement domain (Pattern A) over the foundation engine. +pub struct SettlementService { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + metrics: Arc, + // Retained so the post-settle drain hook (D3) can build a `QueueApplier` + // (same db/publisher/metrics deps as `AllocationService`): a settlement is + // exactly the event that unblocks a queued allocation, so we drain the + // tenant's queue right after the settle commits. + db: DBProvider, + publisher: Arc, + /// The S2 settle FX lock (Slice 5). `None` = single-currency (no FX); + /// `with_fx` attaches it for a deployment with an FX rate source. + rate_locker: Option, +} + +impl SettlementService { + /// Build the service over one database provider, the event publisher + /// (threaded into the posting engine), and the metrics sink. Mirrors + /// [`crate::infra::invoice_post::InvoicePostService::new`]. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + Self { + posting, + reference, + resolver, + metrics, + db, + publisher, + rate_locker: None, + } + } + + /// Attach the S2 settle FX lock (Slice 5). A settle of a receipt whose + /// currency differs from the seller's functional currency then resolves + + /// snapshots the locked rate and stamps the functional translation on every + /// line (one rate per entry, §4.3). Builder form so the existing `new` call + /// sites stay single-currency (FX off) unchanged. + #[must_use] + pub fn with_fx(mut self, rate_locker: RateLocker) -> Self { + self.rate_locker = Some(rate_locker); + self + } + + /// Settle a payment (Pattern A): post `DR CASH_CLEARING (net)` + + /// `DR PSP_FEE_EXPENSE (fee, omitted when zero)` + `CR UNALLOCATED (gross)`, + /// seeding the `payment_settlement` counter row in the same transaction. + /// Idempotent on `(tenant, PAYMENT_SETTLE, payment_id)` — a re-settle of the + /// same payment replays the prior entry with no new ledger effect. + /// + /// On success emits `payment_settle(Posted | Replayed)` + the payment-post + /// duration; every rejection emits `payment_settle(Rejected)` + the duration. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] for an unrepresentable settlement + /// (`gross < 0`, `fee < 0`, `fee > gross`); [`DomainError::AccountClosed`] + /// when a required class (`CASH_CLEARING` / `UNALLOCATED` / `PSP_FEE_EXPENSE`) is + /// not provisioned; any foundation rejection (period-closed / account-closed + /// / …) or [`DomainError::Internal`] on an infrastructure fault. + pub async fn settle( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + input: SettlementInput, + ) -> Result { + let started = Instant::now(); + let tenant = input.tenant_id; + let result = self.settle_inner(ctx, scope, input).await; + self.record(&result, started); + + // D3 — drain-on-settle. A settlement is the event that unblocks a queued + // allocation (allocate-before-settlement, §4.7): once this settle COMMITS, + // drain the tenant's queued allocations so a previously-NotReady apply + // posts immediately rather than waiting for the periodic sweep. ONLY on a + // FRESH settle (`Ok` and not `replayed`): a rejected settle wrote no + // settlement, and an idempotent replay wrote nothing new either — the + // original settle already ran this drain, so re-draining on every retried + // settle is pure waste (and re-drives any apply-blocked rows on each + // retry). A drain error MUST NOT fail the settle — log + swallow; the + // sweep job retries. The drain re-reads the settlement per row, so it is + // safe even though the just-committed row is now visible out-of-txn. + if matches!(&result, Ok(posting) if !posting.replayed) { + let applier = QueueApplier::new( + self.db.clone(), + Arc::clone(&self.publisher), + Arc::clone(&self.metrics), + ); + match applier.drain(ctx, scope, tenant, DRAIN_ON_SETTLE_CAP).await { + Ok(report) => { + if report.applied > 0 || report.blocked > 0 { + tracing::info!( + tenant_id = %tenant, + applied = report.applied, + not_ready = report.not_ready, + blocked = report.blocked, + "bss-ledger: drain-on-settle applied queued allocations" + ); + } + } + Err(e) => tracing::error!( + tenant_id = %tenant, + error = %e, + "bss-ledger: drain-on-settle failed (swallowed; sweep will retry)" + ), + } + } + + result + } + + /// Build + post the settlement entry (no metrics — the public wrapper records + /// them). + async fn settle_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + input: SettlementInput, + ) -> Result { + // 1. Build the balanced Pattern-A entry (validates gross/fee invariants). + let mut entry = build_settlement_entry(&input)?; + + // 2. Overwrite the placeholder header fields the pure builder emits. + overwrite_header(&mut entry, ctx, input.effective_at); + + // 3. Bind each line's real chart account_id from the provisioned chart. + let chart = load_chart(&self.reference, scope, entry.tenant_id).await?; + for line in &mut entry.lines { + line.account_id = resolve_line(&chart, line).ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + line.account_class.as_str(), + line.revenue_stream, + line.currency + )) + })?; + } + + // 4. Map to the engine's NewEntry/NewLine (resolving per-line scale) and + // post, threading the settlement sidecar so the payment_settlement + // counter is seeded atomically with the journal entry. + let sidecar: Arc = Arc::new(SettlementSidecar { + tenant: input.tenant_id, + payment_id: input.payment_id.clone(), + currency: input.currency.clone(), + gross_minor: input.gross_minor, + fee_minor: input.fee_minor, + }); + self.post_bound(ctx, scope, entry, sidecar).await + } + + /// Map an already-account-bound [`PostEntry`] to the engine's + /// `NewEntry`/`NewLine`, resolving each line's scale, and post with the + /// settlement sidecar. + async fn post_bound( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: PostEntry, + sidecar: Arc, + ) -> Result { + let mut new_entry = NewEntry { + entry_id: entry.entry_id, + tenant_id: entry.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: entry.tenant_id, + period_id: entry.period_id.clone(), + entry_currency: entry.entry_currency.clone(), + source_doc_type: entry.source_doc_type, + source_business_id: entry.source_business_id.clone(), + reverses_entry_id: entry.reverses_entry_id, + reverses_period_id: entry.reverses_period_id.clone(), + posted_at_utc: Utc::now(), + effective_at: entry.effective_at, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: entry.posted_by_actor_id, + correlation_id: entry.correlation_id, + rounding_evidence: serde_json::Value::Null, + // Slice 5: set by the S2 FX lock below on a cross-currency settle. + rate_snapshot_ref: None, + }; + let mut new_lines: Vec = Vec::with_capacity(entry.lines.len()); + for line in entry.lines { + let scale = self + .resolver + .resolve(scope, entry.tenant_id, &line.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + new_lines.push(new_line(line, scale)); + } + // S2 settle FX lock: when configured AND the receipt currency differs from + // the seller's functional currency, resolve + snapshot the locked rate and + // stamp functional on every line (one rate per entry, §4.3). Inert (None) + // for a single-currency tenant — existing settles stay byte-green. Mirrors + // the S1 invoice-post hook. + if let Some(locker) = &self.rate_locker { + let functional_ccy = self + .reference + .functional_currency(scope, new_entry.tenant_id) + .await + .map_err(|e| DomainError::Internal(format!("functional currency lookup: {e}")))?; + if let Some(fc) = functional_ccy + && fc != new_entry.entry_currency + { + new_entry.rate_snapshot_ref = locker + .lock_and_stamp( + scope, + new_entry.tenant_id, + &mut new_lines, + &new_entry.entry_currency, + &fc, + Utc::now(), + ) + .await?; + } + } + self.posting + .post(ctx, scope, new_entry, new_lines, Some(sidecar)) + .await + } + + /// Emit `payment_settle(outcome)` + the `Settle`-labelled payment-post + /// duration for one attempt (mirrors `invoice_post::record`). + fn record(&self, result: &Result, started: Instant) { + let outcome = match result { + Ok(r) if r.replayed => PostResult::Replayed, + Ok(_) => PostResult::Posted, + Err(_) => PostResult::Rejected, + }; + self.metrics.payment_settle(outcome); + self.metrics + .payment_post_duration(started.elapsed().as_secs_f64(), PostFlow::Settle); + } +} + +/// Overwrite the placeholder header fields the pure builder emits: derive the +/// `period_id` (YYYYMM) and a real `effective_at` from the settlement instant +/// (`None` ⇒ now), stamp the actor from the security context, and mint a fresh +/// correlation id. If `period_id` stayed `""` the post would fail the +/// fiscal-period gate, so this overwrite is mandatory. +fn overwrite_header( + entry: &mut PostEntry, + ctx: &SecurityContext, + effective_at: Option>, +) { + let eff_instant = effective_at.unwrap_or_else(Utc::now); + let eff_date = eff_instant.date_naive(); + entry.effective_at = eff_date; + entry.period_id = format!("{:04}{:02}", eff_date.year(), eff_date.month()); + entry.posted_by_actor_id = ctx.subject_id(); + entry.correlation_id = Uuid::now_v7(); +} + +/// Thin `PostLine` adapter over [`ChartIndex::resolve`]: projects a built line's +/// `(account_class, currency, revenue_stream)` onto the key-based resolver. The +/// settlement classes (`CASH_CLEARING` / `UNALLOCATED` / `PSP_FEE_EXPENSE`) are all +/// stream-less, so this resolves on `stream = None`. +fn resolve_line(chart: &ChartIndex, line: &PostLine) -> Option { + chart.resolve( + line.account_class, + &line.currency, + line.revenue_stream.as_deref(), + ) +} + +/// Map one SDK [`PostLine`] + its resolved scale to the engine's [`NewLine`] +/// (mirrors `invoice_post::new_line`). +fn new_line(line: PostLine, scale: u8) -> NewLine { + NewLine { + line_id: line.line_id, + payer_tenant_id: line.payer_tenant_id, + seller_tenant_id: line.seller_tenant_id, + resource_tenant_id: line.resource_tenant_id, + account_id: line.account_id, + account_class: line.account_class, + gl_code: line.gl_code, + side: line.side, + amount_minor: line.amount_minor, + currency: line.currency, + currency_scale: scale, + invoice_id: line.invoice_id, + due_date: line.due_date, + revenue_stream: line.revenue_stream, + mapping_status: line.mapping_status, + functional_amount_minor: line.functional_amount_minor, + functional_currency: line.functional_currency, + tax_jurisdiction: line.tax_jurisdiction, + tax_filing_period: line.tax_filing_period, + tax_rate_ref: line.tax_rate_ref, + legal_entity_id: None, + invoice_item_ref: line.invoice_item_ref, + sku_or_plan_ref: line.sku_or_plan_ref, + price_id: line.price_id, + pricing_snapshot_ref: line.pricing_snapshot_ref, + po_allocation_group: line.po_allocation_group, + credit_grant_event_type: line.credit_grant_event_type, + ar_status: line.ar_status, + } +} diff --git a/gears/bss/ledger/ledger/src/infra/payment/settlement_return.rs b/gears/bss/ledger/ledger/src/infra/payment/settlement_return.rs new file mode 100644 index 000000000..212cc3d06 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/payment/settlement_return.rs @@ -0,0 +1,531 @@ +//! `SettlementReturnService` — orchestrates the settlement-return domain +//! (`crate::domain::payment::settlement_return`) over the foundation engine. It +//! records a clawed-back receipt as the SYMMETRIC reverse of settle (Model N, +//! D1): `DR UNALLOCATED amount / CR CASH_CLEARING (amount − fee_share) / CR +//! PSP_FEE_EXPENSE fee_share`, decrementing BOTH the original payment's +//! `settled_minor` (by the gross) and `fee_minor` (by the proportional +//! `fee_share`) in the same serializable transaction (via +//! [`SettlementReturnSidecar`]), and publishing `settlement.returned` in-txn. +//! +//! Mirrors [`crate::infra::payment::settle::SettlementService`] minus the +//! drain-on-settle hook (a return unblocks no queued allocation): read the +//! settlement + compute the proportional `fee_share` → build → overwrite header → +//! bind chart → resolve scale + post with the sidecar → emit metrics. The +//! `fee_share = fee_minor × amount / settled_minor` is computed against the +//! CURRENT remaining balances so repeated partial returns stay proportional. +//! There is NO payer gate (a return records money already moved and must land +//! even for a closed payer). Idempotent on `(tenant, SETTLEMENT_RETURN, +//! psp_return_id)` — a re-posted return replays the prior entry with no new +//! ledger effect. Lives in `infra` (needs repo + posting access); the domain +//! builder it calls stays pure (dylint DE0301). + +use std::sync::Arc; +use std::time::Instant; + +use bss_ledger_sdk::{AccountClass, PostEntry, PostLine, PostingRef}; +use chrono::{Datelike, Utc}; +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::fx::realized::carried_relief; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::payment::settlement_return::{ + SettlementReturnInput, build_settlement_return_entry, +}; +use crate::domain::ports::metrics::{LedgerMetricsPort, PostFlow, PostResult}; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::exception::ExceptionRouter; +use crate::infra::payment::sidecar::SettlementReturnSidecar; +use crate::infra::posting::chart::{ChartIndex, load_chart}; +use crate::infra::posting::service::{PostSidecar, PostingService}; +use crate::infra::storage::repo::{PaymentRepo, ReferenceRepo}; + +/// Origin literal stamped on posts made through this service. +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// Orchestrates the settlement-return domain over the foundation engine. +pub struct SettlementReturnService { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + // The payment counter repo: reads the settlement pre-build to size the + // proportional `fee_share` for the symmetric reverse (Model N, D1). + payment_repo: PaymentRepo, + // The event publisher — threaded into the posting engine AND held so the + // sidecar can publish `settlement.returned` in-txn. + publisher: Arc, + metrics: Arc, + // Slice 7 Phase 2: routes the `SETTLEMENT_RETURN_OVER_ALLOCATED` stub to a + // durable close-blocking exception row (ADDITIVE beside the rejection). `None` + // until `with_exceptions` wires it (so existing constructions are unchanged). + exceptions: Option>, +} + +impl SettlementReturnService { + /// Build the service over one database provider, the event publisher + /// (threaded into the posting engine + the sidecar's in-txn publish), and the + /// metrics sink. Mirrors [`crate::infra::payment::settle::SettlementService::new`]. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + let payment_repo = PaymentRepo::new(db); + Self { + posting, + reference, + resolver, + payment_repo, + publisher, + metrics, + exceptions: None, + } + } + + /// Attach the exception router (Slice 7 Phase 2) so a `SETTLEMENT_RETURN_OVER_ALLOCATED` + /// rejection also opens a durable close-blocking exception row. Additive — the + /// existing rejection is unchanged. + #[must_use] + pub fn with_exceptions(mut self, exceptions: Arc) -> Self { + self.exceptions = Some(exceptions); + self + } + + /// Post a settlement return (clawback) as the symmetric reverse of settle + /// (Model N): `DR UNALLOCATED amount / CR CASH_CLEARING (amount − fee_share) / + /// CR PSP_FEE_EXPENSE fee_share`, decrementing BOTH the original payment's + /// `settled_minor` and `fee_minor` in the same transaction. Idempotent on + /// `(tenant, SETTLEMENT_RETURN, psp_return_id)`. + /// + /// On success emits `settlement_return(Posted | Replayed)` + the + /// payment-post duration; every rejection emits `settlement_return(Rejected)` + /// + the duration. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] for a non-positive amount; + /// [`DomainError::SettlementReturnOverAllocated`] when the return (or its fee + /// reverse) exceeds the still-returnable settled amount; + /// [`DomainError::AccountClosed`] when a required class (`UNALLOCATED` / + /// `CASH_CLEARING` / `PSP_FEE_EXPENSE`) is not provisioned; any foundation + /// rejection (period-closed / negative-balance / …) or + /// [`DomainError::Internal`] on an infrastructure fault (incl. a return + /// against a payment that never settled). + pub async fn return_settlement( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + input: SettlementReturnInput, + ) -> Result { + let started = Instant::now(); + let result = self.return_inner(ctx, scope, input).await; + self.record(&result, started); + result + } + + /// Build + post the settlement-return entry (no metrics — the public wrapper + /// records them). + async fn return_inner( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + input: SettlementReturnInput, + ) -> Result { + // `fee_share` is sized off the payment's CURRENT remaining + // `(settled, fee)`, read OUT-OF-TXN (the chart + scale reads the build + // needs likewise run out-of-txn — Postgres forbids them inside the post's + // serializable transaction), while the sidecar decrements those counters + // IN-TXN. Two CONCURRENT partial returns reading the same pre-decrement + // snapshot would size identical fee shares; the second to commit can then + // trip the `fee_minor <= settled_minor` cap CHECK (a FALSE + // `SettlementReturnOverAllocated`) even though, applied serially, it fits. + // + // Recompute + retry on that cap: a GENUINE over-return re-fails on an + // UNCHANGED snapshot and propagates immediately; a concurrent shift + // re-sizes `fee_share` against the now-committed counters and succeeds. + // The row-level lock the counter UPDATEs take already serializes the + // decrements — this loop only re-aligns the out-of-txn `fee_share` read + // with the committed state. Bounded so a true over-return can't spin. + const MAX_RECOMPUTE: u32 = 8; + let mut prev_snapshot: Option<(i64, i64)> = None; + for _ in 0..=MAX_RECOMPUTE { + // 1. Read the settlement and size the proportional fee slice this + // return reverses (Model N, D1): `fee_share = fee × amount / + // settled` (i128 intermediate; against the CURRENT remaining + // balances). Also returns the `(settled, fee)` snapshot the retry + // guard below compares to tell a genuine over-return from a race. + let (fee_share_minor, snapshot) = self.fee_share(scope, &input).await?; + + // 2. Build the balanced symmetric-reverse entry (validates amount > 0 + // and 0 <= fee_share <= amount). + let mut entry = build_settlement_return_entry(&input, fee_share_minor)?; + + // 3. Overwrite the placeholder header fields the pure builder emits. + overwrite_header(&mut entry, ctx, input.effective_at); + + // 4. Bind each line's real chart account_id from the provisioned chart. + let chart = load_chart(&self.reference, scope, entry.tenant_id).await?; + for line in &mut entry.lines { + line.account_id = resolve_line(&chart, line).ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + line.account_class.as_str(), + line.revenue_stream, + line.currency + )) + })?; + } + + // 4b. Stamp the functional carry-forward on the bound legs (Slice 5, + // decision 8): a cross-currency settle stamped the UNALLOCATED + // pool's functional at the locked rate, so this symmetric reverse + // relieves that SAME carried basis (no new lock, no realized FX). + // Single-currency pool ⇒ leaves functional NULL on every leg. + self.stamp_fx_carry_forward(scope, &input, fee_share_minor, &mut entry.lines) + .await?; + + // 5. Post, threading the return sidecar so BOTH `settled_minor` and + // `fee_minor` are decremented — and `settlement.returned` is + // published — atomically with the journal entry (or rolled back). + let sidecar: Arc = Arc::new(SettlementReturnSidecar { + tenant: input.tenant_id, + payment_id: input.payment_id.clone(), + psp_return_id: input.psp_return_id.clone(), + amount_minor: input.amount_minor, + fee_share_minor, + currency: input.currency.clone(), + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }); + match self.post_bound(ctx, scope, entry, sidecar).await { + Ok(reference) => return Ok(reference), + // A cap rejection on a snapshot that CHANGED since our last + // attempt is a concurrent partial return that shifted the + // counters under us — re-size and retry. The SAME snapshot twice + // is a genuine over-return — propagate it. + Err(DomainError::SettlementReturnOverAllocated(_)) + if prev_snapshot != Some(snapshot) => + { + prev_snapshot = Some(snapshot); + } + // The SAME snapshot twice is a GENUINE over-return (the return truly + // cannot fit, not a transient race): Slice 7 Phase 2 — ADDITIVELY open + // a durable close-blocking exception row beside the rejection before + // propagating it. (The contention-exhaustion fall-through below is a + // retryable timeout, NOT a genuine over-allocation, so it does NOT + // route — that would be a spurious close-blocker on a return the PSP + // retries successfully.) + Err(e @ DomainError::SettlementReturnOverAllocated(_)) => { + if let Some(ex) = &self.exceptions { + ex.route( + input.tenant_id, + crate::domain::exception::ExceptionType::SettlementReturnOverAllocated, + &input.psp_return_id, + Some(serde_json::json!({ + "psp_return_id": input.psp_return_id, + "payment_id": input.payment_id, + })), + ) + .await; + } + return Err(e); + } + Err(e) => return Err(e), + } + } + // Exhausted recomputes under sustained contention: surface the cap rejection + // rather than spin (the caller / PSP webhook can retry). NOT routed to the + // exception queue — this is a transient contention timeout, not a genuine + // over-allocation (see the genuine arm above). + Err(DomainError::SettlementReturnOverAllocated(format!( + "settlement return on payment {} kept losing a race to concurrent \ + returns after {MAX_RECOMPUTE} recomputes", + input.payment_id + ))) + } + + /// Compute the proportional fee slice a return of `amount` reverses (Model N, + /// D1): `fee_share = fee_minor × amount_minor / settled_minor`, using an i128 + /// intermediate so the product can't overflow i64. Reads the settlement + /// out-of-txn before the build, and returns the `(settled_minor, fee_minor)` + /// snapshot alongside `fee_share` so the caller's recompute-on-conflict loop + /// can tell a concurrent counter shift from a genuine over-return. + /// + /// # Errors + /// [`DomainError`] when the settlement is ABSENT or has `settled_minor == 0` + /// — a return against a payment that never settled (or settled nothing) is an + /// upstream contract violation (the cash it claims to claw back never moved), + /// mirroring [`crate::infra::payment::chargeback::ChargebackService`]'s + /// missing-settlement guard. + async fn fee_share( + &self, + scope: &AccessScope, + input: &SettlementReturnInput, + ) -> Result<(i64, (i64, i64)), DomainError> { + let settlement = self + .payment_repo + .read_settlement(scope, input.tenant_id, &input.payment_id) + .await + .map_err(|e| DomainError::Internal(format!("read settlement: {e}")))? + .ok_or_else(|| { + DomainError::Internal(format!( + "settlement return on payment {} has no settlement row \ + (cannot size the fee share)", + input.payment_id + )) + })?; + // The return must be denominated in the SETTLED currency: this read sizes + // the fee share off `settlement.{fee,settled}_minor` and the sidecar + // decrements those same counters, while the journal legs post in + // `input.currency`. A mismatch (mistyped or malicious) would post foreign + // legs against the original payment's counters — reject before sizing + // (mirrors `AllocateService`'s settlement-currency gate). + if settlement.currency != input.currency { + return Err(DomainError::CurrencyMismatch(format!( + "settlement-return currency {} != settled currency {} for payment {}", + input.currency, settlement.currency, input.payment_id + ))); + } + // Guard against a zero settled total: it would both be a contract + // violation (nothing was ever settled) and a divide-by-zero below. + if settlement.settled_minor <= 0 { + return Err(DomainError::Internal(format!( + "settlement return on payment {} has settled_minor={} \ + (cannot size the fee share against a zero/negative settlement)", + input.payment_id, settlement.settled_minor + ))); + } + // i128 intermediate: `fee × amount` can exceed i64 for large minor-unit + // values; the quotient is provably back in i64 range (`fee_share <= fee + // <= settled <= i64::MAX`), so `try_from` never errors here — guard it + // defensively rather than an unchecked `as` cast (clippy + // cast_possible_truncation). + let fee_share_raw = i128::from(settlement.fee_minor) * i128::from(input.amount_minor) + / i128::from(settlement.settled_minor); + let fee_share_minor = i64::try_from(fee_share_raw).map_err(|_| { + DomainError::Internal(format!( + "settlement return fee_share {fee_share_raw} overflows i64 (payment {})", + input.payment_id + )) + })?; + // The `(settled, fee)` snapshot the build was sized against — the retry + // guard compares it across attempts to separate a race from an over-return. + Ok(( + fee_share_minor, + (settlement.settled_minor, settlement.fee_minor), + )) + } + + /// Stamp the functional carry-forward on the symmetric-reverse legs (Slice 5, + /// decision 8). A cross-currency settle stamped the `UNALLOCATED` pool's + /// functional at the locked rate; this return claws `amount` back out of that + /// pool, so it relieves the pool's functional at the SAME carried basis (WAC + /// pro-rata) and carries that basis onto the cash + fee legs — a reversal, NOT + /// a realized-FX point, so the functional column nets to zero with no + /// `FX_GAIN_LOSS` line. The cash leg takes the exact residual so + /// `SUM(DR.functional) = SUM(CR.functional)` holds under banker's rounding. + /// + /// A single-currency pool (functional NULL) leaves functional NULL on every + /// leg (the projector + the `check_entry_balanced` trigger then treat the entry + /// single-currency). A non-positive pool or an over-claw skips the carry-forward + /// — the post is rejected on the transaction balance (projector `NegativeBalance` + /// / the `settled_minor` cap) before it commits, so leaving functional NULL + /// drifts nothing. + /// + /// # Errors + /// [`DomainError::Internal`] on a carried-read fault or a [`carried_relief`] + /// misuse (a malformed grain value — an internal invariant breach). + async fn stamp_fx_carry_forward( + &self, + scope: &AccessScope, + input: &SettlementReturnInput, + fee_share_minor: i64, + lines: &mut [PostLine], + ) -> Result<(), DomainError> { + let pool = self + .payment_repo + .read_unallocated_carried( + scope, + input.tenant_id, + input.payer_tenant_id, + &input.currency, + ) + .await + .map_err(|e| DomainError::Internal(format!("read unallocated carried: {e}")))?; + + // Single-currency pool ⇒ leave functional NULL on every leg. + let (Some(pool_functional), Some(functional_ccy)) = + (pool.functional_balance_minor, pool.functional_currency) + else { + return Ok(()); + }; + + // A non-positive pool or an over-claw ⇒ skip; the post is rejected on the + // transaction balance before it commits, so leaving functional NULL is safe. + if pool.balance_minor <= 0 || input.amount_minor > pool.balance_minor { + return Ok(()); + } + + // Relieve the pool (DR UNALLOCATED) at its WAC, then split the SAME basis + // across the CR legs so the functional column nets to zero: the fee leg + // pro-rata, the cash leg the exact residual. + let dr_func = carried_relief(pool_functional, pool.balance_minor, input.amount_minor) + .map_err(|e| { + DomainError::Internal(format!("settlement-return FX carry-forward: {e}")) + })?; + let fee_func = if fee_share_minor > 0 { + carried_relief(pool_functional, pool.balance_minor, fee_share_minor).map_err(|e| { + DomainError::Internal(format!("settlement-return FX fee carry-forward: {e}")) + })? + } else { + 0 + }; + let cash_func = dr_func - fee_func; + + for line in lines.iter_mut() { + let func = match line.account_class { + AccountClass::Unallocated => dr_func, + AccountClass::PspFeeExpense => fee_func, + AccountClass::CashClearing => cash_func, + // The builder emits only those three classes; an unexpected leg + // would surface as FUNCTIONAL_PARTIAL at the balance trigger + // (fail loud, never silently drift). + _ => continue, + }; + line.functional_amount_minor = Some(func); + line.functional_currency = Some(functional_ccy.clone()); + } + Ok(()) + } + + /// Map an already-account-bound [`PostEntry`] to the engine's + /// `NewEntry`/`NewLine`, resolving each line's scale, and post with the + /// settlement-return sidecar. + async fn post_bound( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: PostEntry, + sidecar: Arc, + ) -> Result { + let new_entry = NewEntry { + entry_id: entry.entry_id, + tenant_id: entry.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: entry.tenant_id, + period_id: entry.period_id.clone(), + entry_currency: entry.entry_currency.clone(), + source_doc_type: entry.source_doc_type, + source_business_id: entry.source_business_id.clone(), + reverses_entry_id: entry.reverses_entry_id, + reverses_period_id: entry.reverses_period_id.clone(), + posted_at_utc: Utc::now(), + effective_at: entry.effective_at, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: entry.posted_by_actor_id, + correlation_id: entry.correlation_id, + rounding_evidence: serde_json::Value::Null, + // Settlement-return carries the pool's functional basis forward (Slice 5, + // decision 8) — no NEW rate is locked, so the entry stamps no fresh + // snapshot ref; the carried basis traces to the original settle's + // snapshot through the relieved UNALLOCATED grain. + rate_snapshot_ref: None, + }; + let mut new_lines: Vec = Vec::with_capacity(entry.lines.len()); + for line in entry.lines { + let scale = self + .resolver + .resolve(scope, entry.tenant_id, &line.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + new_lines.push(new_line(line, scale)); + } + self.posting + .post(ctx, scope, new_entry, new_lines, Some(sidecar)) + .await + } + + /// Emit `settlement_return(outcome)` + the `SettlementReturn`-labelled + /// payment-post duration for one attempt. + fn record(&self, result: &Result, started: Instant) { + let outcome = match result { + Ok(r) if r.replayed => PostResult::Replayed, + Ok(_) => PostResult::Posted, + Err(_) => PostResult::Rejected, + }; + self.metrics.settlement_return(outcome); + self.metrics + .payment_post_duration(started.elapsed().as_secs_f64(), PostFlow::SettlementReturn); + } +} + +/// Overwrite the placeholder header fields the pure builder emits: derive the +/// `period_id` (YYYYMM) and a real `effective_at` from the return instant +/// (`None` ⇒ now), stamp the actor from the security context, and mint a fresh +/// correlation id. Mandatory — a `""` `period_id` would fail the fiscal-period +/// gate. Mirrors [`crate::infra::payment::settle`]'s overwrite. +fn overwrite_header( + entry: &mut PostEntry, + ctx: &SecurityContext, + effective_at: Option>, +) { + let eff_instant = effective_at.unwrap_or_else(Utc::now); + let eff_date = eff_instant.date_naive(); + entry.effective_at = eff_date; + entry.period_id = format!("{:04}{:02}", eff_date.year(), eff_date.month()); + entry.posted_by_actor_id = ctx.subject_id(); + entry.correlation_id = Uuid::now_v7(); +} + +/// Thin `PostLine` adapter over [`ChartIndex::resolve`]. The return classes +/// (`UNALLOCATED` / `CASH_CLEARING`) are stream-less, so this resolves on +/// `stream = None`. +fn resolve_line(chart: &ChartIndex, line: &PostLine) -> Option { + chart.resolve( + line.account_class, + &line.currency, + line.revenue_stream.as_deref(), + ) +} + +/// Map one SDK [`PostLine`] + its resolved scale to the engine's [`NewLine`] +/// (mirrors `settle::new_line`). +fn new_line(line: PostLine, scale: u8) -> NewLine { + NewLine { + line_id: line.line_id, + payer_tenant_id: line.payer_tenant_id, + seller_tenant_id: line.seller_tenant_id, + resource_tenant_id: line.resource_tenant_id, + account_id: line.account_id, + account_class: line.account_class, + gl_code: line.gl_code, + side: line.side, + amount_minor: line.amount_minor, + currency: line.currency, + currency_scale: scale, + invoice_id: line.invoice_id, + due_date: line.due_date, + revenue_stream: line.revenue_stream, + mapping_status: line.mapping_status, + functional_amount_minor: line.functional_amount_minor, + functional_currency: line.functional_currency, + tax_jurisdiction: line.tax_jurisdiction, + tax_filing_period: line.tax_filing_period, + tax_rate_ref: line.tax_rate_ref, + legal_entity_id: None, + invoice_item_ref: line.invoice_item_ref, + sku_or_plan_ref: line.sku_or_plan_ref, + price_id: line.price_id, + pricing_snapshot_ref: line.pricing_snapshot_ref, + po_allocation_group: line.po_allocation_group, + credit_grant_event_type: line.credit_grant_event_type, + ar_status: line.ar_status, + } +} diff --git a/gears/bss/ledger/ledger/src/infra/payment/sidecar.rs b/gears/bss/ledger/ledger/src/infra/payment/sidecar.rs new file mode 100644 index 000000000..6fcef6090 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/payment/sidecar.rs @@ -0,0 +1,418 @@ +//! The payment post sidecars — in-transaction [`PostSidecar`] hooks the +//! settle / allocate orchestrators thread into the posting engine. +//! +//! [`SettlementSidecar`] seeds the `payment_settlement` counter row for a fresh +//! settlement. [`AllocationSidecar`] applies one allocation: it bumps +//! `allocated_minor` (the per-payment cap CHECK is the `SERIALIZABLE` +//! concurrency backstop — an over-cap surfaces as +//! [`DomainError::MoneyOutCapExceeded`]), inserts the N `payment_allocation` +//! rows, then bumps the per-`(payment, invoice)` allocation-refund counter. +//! +//! Both run inside the SAME serializable transaction as the journal post (see +//! the [`PostSidecar`] contract): their writes commit atomically with the entry +//! or roll back with it. This is INFRA code, so the wall clock (`Utc::now()`) +//! for the `allocated_at_utc` audit stamp is allowed here. + +use std::sync::Arc; + +use chrono::Utc; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::RepoError; +use crate::domain::payment::chargeback::DisputeVariant; +use crate::infra::events::payloads::{LedgerDisputeRecorded, LedgerSettlementReturned}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::posting::service::{PostSidecar, PostedFacts}; +use crate::infra::storage::repo::payment_repo::NewAllocationRow; +use crate::infra::storage::repo::{DisputeRepo, PaymentRepo}; + +/// In-transaction sidecar for a payment settlement: seeds the +/// `payment_settlement` counter row so a later allocation can net against it. +pub struct SettlementSidecar { + pub tenant: Uuid, + pub payment_id: String, + pub currency: String, + pub gross_minor: i64, + pub fee_minor: i64, +} + +/// In-transaction sidecar for a settlement return (Model N, D1): decrements BOTH +/// the original payment's `settled_minor` (by the returned gross) AND its +/// `fee_minor` (by the reversed proportional `fee_share_minor`), so the +/// clawed-back receipt no longer counts as settled and `net = settled − fee` +/// stays consistent. It also publishes `billing.ledger.settlement.returned` IN +/// the same post txn (transactional outbox), so the event commits atomically with +/// the entry + the counter decrements, or rolls back with them (mirrors +/// [`ChargebackSidecar`]). The per-payment cap CHECKs are the `SERIALIZABLE` +/// backstop — an over-claw (return / fee-reverse more than is still returnable) +/// surfaces as [`DomainError::SettlementReturnOverAllocated`]. +pub struct SettlementReturnSidecar { + pub tenant: Uuid, + pub payment_id: String, + /// External return identity — the event's `psp_return_id`. + pub psp_return_id: String, + /// The returned gross (decremented from `settled_minor`; the event amount). + pub amount_minor: i64, + /// The proportional fee slice reversed (decremented from `fee_minor`). `0` + /// when the original settle had no fee. + pub fee_share_minor: i64, + pub currency: String, + /// The event publisher: `billing.ledger.settlement.returned` is published IN + /// this post txn (the transactional outbox) so it commits atomically with the + /// return entry, or rolls back with it. Mirrors [`ChargebackSidecar`]. + pub publisher: Arc, + /// The security context for the in-txn outbox publish (the same `ctx` the + /// engine threads through; cloned by the service into the sidecar). + pub ctx: SecurityContext, +} + +/// In-transaction sidecar for a chargeback phase, lock rank 0 (taken before the +/// rank-1 settlement write). The `op` selects the dispute-row write: +/// +/// - [`ChargebackDisputeOp::Open`] seeds the `ledger_dispute` current-state row +/// (the chosen `variant`, `cycle`, `disputed_amount_minor`, `last_phase = +/// OPENED`) so the later `won`/`lost` outcomes can branch on the recorded +/// variant. +/// - [`ChargebackDisputeOp::Advance`] advances the existing row's `last_phase` +/// (to `WON`/`LOST`) and — when `clawed_back_minor > 0` (a `lost` cash-out) — +/// bumps the payment's `clawed_back_minor` under the total money-out cap CHECK. +pub struct ChargebackSidecar { + pub tenant: Uuid, + pub dispute_id: String, + pub payment_id: String, + pub currency: String, + pub variant: DisputeVariant, + pub cycle: i32, + pub disputed_amount_minor: i64, + /// The cash held in `DISPUTE_HOLD` at `opened` (`min(disputed, net)`, Model + /// N) — persisted on the dispute row by the [`ChargebackDisputeOp::Open`] + /// write so the later `won`/`lost` outcome sizes its release / forfeit off + /// THIS amount, not a re-read `settled − fee` (a settlement-return between + /// `opened` and the outcome would otherwise strand the hold). `0` for + /// `AR_RECLASS` (no cash leg) and irrelevant on the `Advance` op (it never + /// rewrites the stored hold). + pub cash_hold_minor: i64, + /// The dispute-row write this phase performs (open vs advance-to-outcome). + pub op: ChargebackDisputeOp, + /// The event publisher: the `billing.ledger.dispute.recorded` event is + /// published IN this post txn (the transactional outbox) so it commits + /// atomically with the dispute entry, or rolls back with it. Wired for every + /// phase (opened too) — design §6 / C3. + pub publisher: Arc, + /// The security context for the in-txn outbox publish (the same `ctx` the + /// engine threads through; cloned by the service into the sidecar). + pub ctx: SecurityContext, +} + +/// The dispute current-state write a [`ChargebackSidecar`] performs, selected by +/// the phase the service is posting. +pub enum ChargebackDisputeOp { + /// `opened`: seed the `ledger_dispute` row (`last_phase = OPENED`). + Open, + /// `won` / `lost`: advance the existing row to `last_phase`, and (on a + /// `lost` cash-out) bump `clawed_back_minor` by `clawed_back_minor` under the + /// per-payment total money-out cap CHECK. `clawed_back_minor == 0` ⇒ no cash + /// left (a `won`, or a negative-cash documented-loss `lost`), so the counter + /// is untouched. + Advance { + last_phase: crate::domain::payment::chargeback::DisputePhase, + clawed_back_minor: i64, + }, +} + +/// In-transaction sidecar for a payment allocation: bumps the settled-payment's +/// `allocated_minor`, inserts the per-invoice `payment_allocation` rows, and +/// bumps each `(payment, invoice)` allocation-refund counter. +pub struct AllocationSidecar { + pub tenant: Uuid, + pub payer: Uuid, + pub payment_id: String, + pub allocation_id: Uuid, + pub currency: String, + pub splits: Vec, + pub total_minor: i64, + pub policy_ref: String, +} + +#[async_trait::async_trait] +impl PostSidecar for SettlementSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + _posted: &PostedFacts, + ) -> Result<(), DomainError> { + PaymentRepo::seed_settlement( + txn, + scope, + self.tenant, + &self.payment_id, + &self.currency, + self.gross_minor, + self.fee_minor, + ) + .await + .map_err(map_repo_err)?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl PostSidecar for AllocationSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + _posted: &PostedFacts, + ) -> Result<(), DomainError> { + // 1. Bump the settled-payment's running allocated total. The + // `allocated_minor <= settled_minor` cap CHECK is the SERIALIZABLE + // backstop — an over-cap surfaces as `MoneyOutCapExceeded`. + PaymentRepo::add_allocated(txn, scope, self.tenant, &self.payment_id, self.total_minor) + .await + .map_err(map_repo_err)?; + + // 2. Insert one `payment_allocation` row per split. + let now = Utc::now(); + let rows: Vec = self + .splits + .iter() + .map(|split| NewAllocationRow { + tenant_id: self.tenant, + allocation_id: self.allocation_id, + payer_tenant_id: self.payer, + payment_id: self.payment_id.clone(), + invoice_id: split.invoice_id.clone(), + amount_minor: split.amount_minor, + currency: self.currency.clone(), + precedence_policy_ref: self.policy_ref.clone(), + allocated_at_utc: now, + }) + .collect(); + PaymentRepo::insert_allocation_rows(txn, scope, &rows) + .await + .map_err(map_repo_err)?; + + // 3. Bump the per-`(payment, invoice)` allocation-refund counter by the + // amount this allocation applied (feeds the refund cap downstream). + for split in &self.splits { + PaymentRepo::bump_allocation_refund( + txn, + scope, + self.tenant, + &self.payment_id, + &split.invoice_id, + split.amount_minor, + ) + .await + .map_err(map_repo_err)?; + } + + Ok(()) + } +} + +#[async_trait::async_trait] +impl PostSidecar for SettlementReturnSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + _posted: &PostedFacts, + ) -> Result<(), DomainError> { + // Claw the receipt back out (Model N, symmetric reverse): decrement BOTH + // `settled_minor` (by the returned gross) AND `fee_minor` (by the + // reversed proportional fee slice), so `net = settled − fee` stays + // consistent. The per-payment cap CHECKs reject a return that exceeds + // what is still returnable (over the allocated / refunded / clawed-back + // total, or below zero) — surfaced as `SettlementReturnOverAllocated`. + // Decrement `fee_minor` FIRST, then `settled_minor`. The + // `fee_minor <= settled_minor` CHECK is re-evaluated after EVERY UPDATE, + // so dropping `settled_minor` to its new (lower) total while `fee_minor` + // still holds the old (higher) value would trip the CHECK mid-sidecar + // (a full return: settled 100->0 with fee still 3 ⇒ `3 <= 0` fails). + // Reversing the fee slice first keeps `fee <= settled` true at every step. + // Skip the fee write when there is no slice to reverse (avoids a no-op + // UPDATE + version bump). The `fee_minor >= 0` / `<= settled_minor` CHECKs + // back this decrement (mapped to `SettlementReturnOverAllocated`). + if self.fee_share_minor != 0 { + PaymentRepo::add_fee( + txn, + scope, + self.tenant, + &self.payment_id, + -self.fee_share_minor, + ) + .await + .map_err(map_return_repo_err)?; + } + // Claw the receipt's gross back out of the pool. The per-payment cap + // CHECKs reject a return exceeding what is still returnable (over the + // allocated / refunded / clawed-back total, or below zero) — surfaced as + // `SettlementReturnOverAllocated`. + PaymentRepo::add_settled( + txn, + scope, + self.tenant, + &self.payment_id, + -self.amount_minor, + ) + .await + .map_err(map_return_repo_err)?; + + // Publish `billing.ledger.settlement.returned` into the SAME post txn + // (transactional outbox): the event row commits atomically with the + // return entry + the counter decrements, or a publish failure rolls the + // whole post back. Ids + amount only (no PII). + self.publisher + .publish_settlement_returned( + &self.ctx, + txn, + LedgerSettlementReturned { + payment_id: self.payment_id.clone(), + psp_return_id: self.psp_return_id.clone(), + tenant_id: self.tenant, + amount_minor: self.amount_minor, + currency: self.currency.clone(), + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("publish settlement_returned: {e}")))?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl PostSidecar for ChargebackSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + _posted: &PostedFacts, + ) -> Result<(), DomainError> { + // Lock rank 0 — the dispute-state write precedes the rank-1 settlement + // write. A replay returns before the sidecar, so this is only reached on + // the first post for `(tenant, CHARGEBACK, dispute_id:cycle:phase)`. + match &self.op { + // `opened`: seed the dispute current-state row. + ChargebackDisputeOp::Open => { + DisputeRepo::dispute_upsert( + txn, + scope, + self.tenant, + &self.dispute_id, + &self.payment_id, + &self.currency, + self.variant, + self.cycle, + self.disputed_amount_minor, + self.cash_hold_minor, + ) + .await + .map_err(map_repo_err)?; + } + // `won` / `lost`: advance the existing row to the outcome, and (on a + // `lost` cash-out) bump the payment's `clawed_back_minor` under the + // total money-out cap CHECK (refunded + clawed <= settled). + ChargebackDisputeOp::Advance { + last_phase, + clawed_back_minor, + } => { + DisputeRepo::dispute_advance( + txn, + scope, + self.tenant, + &self.dispute_id, + *last_phase, + // Re-state the same cycle + disputed amount (the outcome does + // not re-open a cycle; `dispute_advance` re-sets, not nets). + self.cycle, + self.disputed_amount_minor, + ) + .await + .map_err(map_repo_err)?; + if *clawed_back_minor > 0 { + PaymentRepo::add_clawed_back( + txn, + scope, + self.tenant, + &self.payment_id, + *clawed_back_minor, + ) + .await + .map_err(map_clawback_repo_err)?; + } + } + } + + // Publish `billing.ledger.dispute.recorded` into the SAME post txn + // (transactional outbox): the event row commits atomically with the + // dispute entry + the dispute-state write, or a publish failure rolls the + // whole post back. Wired for EVERY phase (opened/won/lost). Ids + enum + // codes only (no PII / amounts). + let phase = match &self.op { + ChargebackDisputeOp::Open => crate::domain::payment::chargeback::DisputePhase::Opened, + ChargebackDisputeOp::Advance { last_phase, .. } => *last_phase, + }; + self.publisher + .publish_dispute_recorded( + &self.ctx, + txn, + LedgerDisputeRecorded { + dispute_id: self.dispute_id.clone(), + payment_id: self.payment_id.clone(), + tenant_id: self.tenant, + cycle: self.cycle, + phase: phase.as_str().to_owned(), + variant: self.variant.as_str().to_owned(), + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("publish dispute_recorded: {e}")))?; + Ok(()) + } +} + +/// Map a settlement-return counter [`RepoError`] into [`DomainError`]: a cap +/// CHECK violation (the return would push `settled_minor` below the still-owed +/// allocated / refunded / clawed-back total, or below zero) becomes +/// [`DomainError::SettlementReturnOverAllocated`] (the +/// `SETTLEMENT_RETURN_OVER_ALLOCATED` wire code); every other repo failure is an +/// infrastructure fault whose diagnostic stays server-side. +fn map_return_repo_err(e: RepoError) -> DomainError { + match e { + RepoError::MoneyOutCapExceeded(m) => DomainError::SettlementReturnOverAllocated(m), + other => DomainError::Internal(format!("settlement-return sidecar: {other}")), + } +} + +/// Map a payment-counter [`RepoError`] into the sidecar's [`DomainError`]: a +/// per-payment cap CHECK violation becomes [`DomainError::MoneyOutCapExceeded`] +/// (the `ALLOCATION_EXCEEDS_SETTLED` wire code); every other repo failure is an +/// infrastructure fault whose diagnostic stays server-side. +fn map_repo_err(e: RepoError) -> DomainError { + match e { + RepoError::MoneyOutCapExceeded(m) => DomainError::MoneyOutCapExceeded(m), + // The dispute-outcome advance lost a race (or got a stale cycle): a clean + // non-retryable `INVALID_DISPUTE_PHASE`, not a server fault. + RepoError::DisputeNotOpen(m) => DomainError::InvalidDisputeTransition(m), + other => DomainError::Internal(format!("payment sidecar: {other}")), + } +} + +/// Map a chargeback clawback-counter [`RepoError`] into [`DomainError`]: the +/// total money-out cap CHECK violation (`refunded_minor + clawed_back_minor > +/// settled_minor` — the same settlement that was already paid out via a refund +/// can't also be clawed back) becomes [`DomainError::ChargebackExceedsSettled`] +/// (the `CHARGEBACK_EXCEEDS_SETTLED` wire code); every other repo failure is an +/// infrastructure fault whose diagnostic stays server-side. Distinct from +/// [`map_repo_err`] only in the cap-violation variant it raises. +fn map_clawback_repo_err(e: RepoError) -> DomainError { + match e { + RepoError::MoneyOutCapExceeded(m) => DomainError::ChargebackExceedsSettled(m), + other => DomainError::Internal(format!("chargeback sidecar: {other}")), + } +} diff --git a/gears/bss/ledger/ledger/src/infra/period_close.rs b/gears/bss/ledger/ledger/src/infra/period_close.rs new file mode 100644 index 000000000..1e724e2d0 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/period_close.rs @@ -0,0 +1,836 @@ +//! `PeriodCloseService` — the gated `OPEN→CLOSED` fiscal-period transition +//! (Slice 7 Group B). The close is **single-active** via a `coord` lease +//! (keyed `period-close:{tenant}:{legal_entity}:{period}`; a concurrent close +//! sees [`CoordError::LeaseHeld`] → [`DomainError::PeriodCloseInProgress`]), and +//! under the lease the read, the gate, and the flip run in ONE `SERIALIZABLE` +//! transaction with retry — so a post that lands an entry into the period +//! concurrently conflicts (Postgres SSI) and the close retries against the new +//! entry instead of certifying a period the entry slipped into. +//! +//! Close is **single-tenant**: everything runs under +//! `AccessScope::for_tenant(tenant_id)` plus a `(legal_entity, period)` filter +//! (NOT the cross-tenant system scope the daily jobs use). +//! +//! The **gate** blocks close while any of: the pre-close tie-out is not clean +//! (variance / imbalance / negative / PENDING mapping line); an OPEN +//! close-blocking `exception_queue` row exists for the period; a recognition +//! segment due `<=` the period is not `DONE`; or — Slice 7 Phase 3, behind their +//! enforcement flags — the period's **bill run is not asserted finished** or the +//! independent **issued-invoice manifest** shows issued invoices with no committed +//! posting (a configured-but-absent/failing feed fails loud) (design §4.5). A blocked close +//! records `period_close = CLOSING` + `blocked_reasons` (observability) and +//! returns [`DomainError::PeriodCloseBlocked`]; a clean close flips +//! `fiscal_period OPEN→CLOSED` and `period_close → CLOSED` in the same commit and +//! emits `billing.ledger.period.closed` in-txn (transactional outbox). +//! +//! **Realized as `coord` lease + SERIALIZABLE/SSI** rather than the design's +//! literal `fiscal_period FOR UPDATE` two-phase (recompute-outside-lock + +//! watermark): the toolkit's `SecureORM` does not expose row locks, and SSI is +//! correctness-equivalent for the close-vs-post race (the lease adds +//! single-active mutual exclusion). The recompute-outside-lock optimization is a +//! deferred follow-up (spec §3.1/§8). REOPEN + dual-control land in Group B-reopen. + +use std::sync::Arc; +use std::time::Duration; + +use coord::{CoordError, LeaseGuard, LeaseManager}; +use sea_orm::sea_query::Expr; +use sea_orm::{ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{ + AccessScope, DbTx, ScopeError, SecureEntityExt, SecureUpdateExt, TxConfig, +}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use bss_ledger_sdk::{ + BillRunFinishedV1, CloseOutcome, IssuedInvoiceManifestV1, UnconfiguredBillRunFinishedV1, + UnconfiguredIssuedInvoiceManifestV1, +}; + +use crate::domain::error::DomainError; +use crate::domain::fx::revaluation_mode::RevaluationMode; +use crate::domain::model::RepoError; +use crate::domain::status::{PERIOD_STATUS_CLOSED, PERIOD_STATUS_OPEN}; +use crate::infra::audit::secured_audit_sink::{AuditEventType, SecuredAuditSink}; +use crate::infra::events::payloads::{ + AlarmCategory, AlarmSeverity, LedgerInvariantAlarm, LedgerPeriodClosed, +}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::jobs::tieout::TieOutJob; +use crate::infra::storage::entity::fiscal_period; +use crate::infra::storage::repo::{ + ExceptionQueueRepo, FxRevaluationModeRepo, FxRevaluationRunRepo, PeriodCloseRepo, + RecognitionRepo, +}; + +/// `period_close.status` literal for an attempted-but-blocked / in-flight close. +const PERIOD_CLOSE_STATUS_CLOSING: &str = "CLOSING"; + +/// The Slice 7 Phase 3 pre-close control-feed gate inputs (design §4.5 / decision 3): +/// the two launch-blocking control feeds (issued-invoice manifest, bill-run-finished) +/// plus their per-deployment enforcement flags. Built in `init()` from `ClientHub` +/// (the fail-safe `Unconfigured…` defaults) and `ReconConfig`. With a flag OFF the +/// corresponding gate is inert; with it ON, an absent / failing feed **fails loud** +/// (blocks close), never silently passes (decision 3). [`Self::inert`] disables both +/// gates (the reopen-only executor instance + tests that do not exercise completeness). +#[derive(Clone)] +#[allow( + clippy::struct_excessive_bools, + reason = "three independent per-deployment close-enforcement flags (manifest, bill-run, fx-revaluation)" +)] +pub struct CloseControlFeeds { + /// The issued-invoice manifest read port (invoice-completeness pre-close gate). + pub manifest_feed: Arc, + /// The bill-run-finished read port (close-after-bill-run gate, N-core-8 / S7-F1). + pub bill_run_feed: Arc, + /// Block close on an unresolved invoice-completeness gap (`manifest_enforcement`). + pub manifest_enforcement: bool, + /// Block close until the bill-run-finished signal is asserted (`bill_run_enforcement`). + pub bill_run_enforcement: bool, + /// Block close until the period-end Mode-B FX revaluation recorded a COMPLETE + /// marker (`fx.revaluation_enabled`, VHP-1859 review C3). Inert while Mode-B is + /// off (the v1 default). + pub fx_revaluation_enforcement: bool, +} + +impl CloseControlFeeds { + /// Both gates disabled with fail-safe `Unconfigured…` feeds — the close gate's + /// completeness/bill-run checks are inert (the default before any feed is wired). + #[must_use] + pub fn inert() -> Self { + Self { + manifest_feed: Arc::new(UnconfiguredIssuedInvoiceManifestV1), + bill_run_feed: Arc::new(UnconfiguredBillRunFinishedV1), + manifest_enforcement: false, + bill_run_enforcement: false, + fx_revaluation_enforcement: false, + } + } +} + +/// Lease TTL for a close — comfortably longer than the renewal so one missed +/// heartbeat does not drop it, yet short enough that a crashed close's lease +/// lapses (and the period is re-closable) within a couple of minutes. +const CLOSE_LEASE_TTL: Duration = Duration::from_mins(2); +/// Renewal heartbeat period (~`TTL`/3): a live close renews well before expiry. +const CLOSE_LEASE_RENEW: Duration = Duration::from_secs(40); + +/// Carries the in-transaction close decision out of the `SERIALIZABLE` body. +/// Business outcomes are `Ok(_)` (the txn commits — only the guarded flip and the +/// `period_close` row write); infrastructure / serialization faults are `Err`. +enum CloseTxnResult { + NotFound, + AlreadyClosed, + NotOpen, + /// Gate blocked the close; the `period_close` row was written `CLOSING` with + /// these reasons (observability). Mapped to [`DomainError::PeriodCloseBlocked`]. + Blocked(Vec), + Flipped { + already_closed: bool, + }, +} + +/// Carries the in-transaction reopen decision out of the `SERIALIZABLE` body. +enum ReopenTxnResult { + NotFound, + /// Already OPEN (never closed, or a prior reopen) — idempotent no-op. + NoOp, + Reopened, +} + +/// Extractor for the retry helper: a wrapped `DbErr` (so a serialization failure +/// surfaced at a statement or COMMIT is recognised as retryable). +fn as_db_err(e: &DbError) -> Option<&sea_orm::DbErr> { + match e { + DbError::Sea(db_err) => Some(db_err), + _ => None, + } +} + +/// Map a `SecureORM` error into `DbError`, preserving the inner `DbErr` so a +/// serialization failure at a scoped read / flip stays retryable via +/// [`as_db_err`]. +fn scope_to_db(e: ScopeError) -> DbError { + match e { + ScopeError::Db(db_err) => DbError::Sea(db_err), + other => DbError::Other(anyhow::anyhow!("scope: {other}")), + } +} + +/// Map a repo error into `DbError`. The gate's `exception_queue` / `period_close` +/// statements are serialised against peer closes by the `coord` lease, so the +/// lost-retryability of a stringified repo error here is benign (a rare conflict +/// fails the close; the caller retries). The post-vs-close race is caught by the +/// tie-out reads + the flip, which preserve the retryable `DbErr` via +/// [`scope_to_db`]. +#[allow( + clippy::needless_pass_by_value, + reason = "error adapter used as a map_err fn-pointer; takes the error by value to match the closure signature" +)] +fn repo_to_db(e: RepoError) -> DbError { + DbError::Other(anyhow::anyhow!("period-close repo: {e}")) +} + +/// Closes a clean fiscal period after a single-active, gated pre-close check; +/// also owns the dual-control `reopen` (the CLOSED→REOPENED seam). +pub struct PeriodCloseService { + db: DBProvider, + publisher: Arc, + /// Single-active-close lease (design §4.5). Keyed + /// `period-close:{tenant}:{legal_entity}:{period}`, built over the same `Db` + /// as the repos (mirrors `RecognitionRunService`). + lease: LeaseManager, + /// Secured-audit sink for the `period-reopen` record (NO-OP until Slice 6; + /// the close path itself writes no audit record). + audit: Arc, + /// Slice 7 Phase 3 pre-close control-feed gate (manifest completeness + bill-run + /// finished, flag-gated). `None` ⇒ inert (the reopen-only executor instance); the + /// close path attaches the real feeds via [`Self::with_control_feeds`]. + control: Option, +} + +impl PeriodCloseService { + /// Build the service over one database provider and the event publisher + /// (threaded into the pre-close [`TieOutJob`]); the lease manager is built + /// from the provider's `Db` (`db.db()`), mirroring `RecognitionRunService`. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + audit: Arc, + ) -> Self { + let lease = LeaseManager::new(db.db()); + Self { + db, + publisher, + lease, + audit, + control: None, + } + } + + /// Attach the Slice 7 Phase 3 pre-close control-feed gate (manifest completeness + + /// bill-run-finished, flag-gated — design §4.5). The close path (the in-process + /// client's instance) sets this; the reopen-only executor instance leaves it inert. + #[must_use] + pub fn with_control_feeds(mut self, control: CloseControlFeeds) -> Self { + self.control = Some(control); + self + } + + /// Close the `(tenant, legal_entity, period)` fiscal period: take the + /// single-active lease, then under it assert `OPEN`, run the gate, and flip + /// to `CLOSED`. The caller's `ctx` threads the initiating Finance actor onto + /// the `period_close` row (`initiated_by`) and the `period.closed` event. + /// + /// Idempotent — a period that is already `CLOSED` returns + /// `Ok(already_closed = true)` without re-running the gate or re-emitting the + /// `period.closed` event. + /// + /// # Errors + /// [`DomainError::PeriodCloseInProgress`] when a peer close holds the lease; + /// [`DomainError::PeriodCloseBlocked`] when the gate blocks (tie-out variance + /// / open exception / due recognition segment); [`DomainError::PeriodNotFound`] + /// when the row is absent; [`DomainError::PeriodNotOpen`] when the period is + /// neither `OPEN` nor `CLOSED`; [`DomainError::Internal`] on a storage / + /// lease failure. + pub async fn close( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + legal_entity_id: Uuid, + period_id: String, + ) -> Result { + let lease_key = format!("period-close:{tenant_id}:{legal_entity_id}:{period_id}"); + let guard = match self.lease.acquire(&lease_key, CLOSE_LEASE_TTL).await { + Ok(guard) => guard, + Err(CoordError::LeaseHeld) => { + return Err(DomainError::PeriodCloseInProgress(format!( + "{legal_entity_id}/{period_id}" + ))); + } + Err(e) => { + return Err(DomainError::Internal(format!( + "period-close lease acquire ({lease_key}): {e}" + ))); + } + }; + + // Keep the lease live across the gate (the tie-out can be heavy); stop the + // heartbeat before release. + let renewal = guard.spawn_renewal(CLOSE_LEASE_RENEW); + let result = self + .close_locked(ctx, tenant_id, legal_entity_id, period_id) + .await; + renewal.shutdown().await; + Self::release_lease(guard, result.is_ok()).await; + result + } + + /// The gated close body under a held lease: one `SERIALIZABLE` transaction + /// (with retry) carrying the read → gate → flip → `period.closed` emit, mapped + /// onto the SDK outcome. + async fn close_locked( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + legal_entity_id: Uuid, + period_id: String, + ) -> Result { + let publisher = Arc::clone(&self.publisher); + let db = self.db.clone(); + let pid = period_id.clone(); + // Owned for the retryable `move` closure (a borrow of `ctx` cannot enter it); + // re-cloned per retry attempt below, mirroring `publisher` / `db` / `pid`. + let ctx = ctx.clone(); + // Slice 7 Phase 3 pre-close control-feed gate inputs (manifest + bill-run, + // flag-gated); `None` ⇒ the gate is inert. Cheap to clone (`Arc`s + bools). + let control = self.control.clone(); + + let result: Result = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let publisher = Arc::clone(&publisher); + let db = db.clone(); + let pid = pid.clone(); + let ctx = ctx.clone(); + let control = control.clone(); + Box::pin(async move { + close_in_txn( + &ctx, + txn, + &db, + &publisher, + control.as_ref(), + tenant_id, + legal_entity_id, + &pid, + ) + .await + }) + }) + .await; + + match result { + Ok(CloseTxnResult::NotFound) => Err(DomainError::PeriodNotFound(period_id)), + Ok(CloseTxnResult::AlreadyClosed) => Ok(CloseOutcome { + period_id, + already_closed: true, + }), + Ok(CloseTxnResult::NotOpen) => Err(DomainError::PeriodNotOpen( + "fiscal period is not OPEN".to_owned(), + )), + Ok(CloseTxnResult::Blocked(reasons)) => { + Err(DomainError::PeriodCloseBlocked(reasons.join("; "))) + } + Ok(CloseTxnResult::Flipped { already_closed }) => Ok(CloseOutcome { + period_id, + already_closed, + }), + Err(e) => Err(DomainError::Internal(format!("close txn: {e}"))), + } + } + + /// Reopen a CLOSED fiscal period (design §7 / N-core-3) — the last + /// dual-control seam (VHP-1852). Flips `fiscal_period CLOSED→OPEN` and + /// `period_close → REOPENED`, and writes a Slice-6 `period-reopen` + /// secured-audit record, all in one `SERIALIZABLE` txn under the single-active + /// close lease. Called ONLY by the dual-control executor on approve (reopen is + /// ALWAYS dual-control, policy `requires_dual_control`); never inline. + /// Idempotent — an already-OPEN period is a no-op success. + /// + /// # Errors + /// [`DomainError::PeriodCloseInProgress`] on a held lease; + /// [`DomainError::PeriodNotFound`] when the row is absent; + /// [`DomainError::Internal`] on a storage / lease failure. + pub async fn reopen( + &self, + tenant_id: Uuid, + legal_entity_id: Uuid, + period_id: &str, + actor: Uuid, + ) -> Result<(), DomainError> { + let lease_key = format!("period-close:{tenant_id}:{legal_entity_id}:{period_id}"); + let guard = match self.lease.acquire(&lease_key, CLOSE_LEASE_TTL).await { + Ok(guard) => guard, + Err(CoordError::LeaseHeld) => { + return Err(DomainError::PeriodCloseInProgress(format!( + "{legal_entity_id}/{period_id}" + ))); + } + Err(e) => { + return Err(DomainError::Internal(format!( + "period-reopen lease acquire ({lease_key}): {e}" + ))); + } + }; + let renewal = guard.spawn_renewal(CLOSE_LEASE_RENEW); + let result = self + .reopen_locked(tenant_id, legal_entity_id, period_id, actor) + .await; + renewal.shutdown().await; + Self::release_lease(guard, result.is_ok()).await; + result + } + + /// The reopen body under a held lease: one `SERIALIZABLE` transaction (with + /// retry) carrying the read → flip → secured-audit. + async fn reopen_locked( + &self, + tenant_id: Uuid, + legal_entity_id: Uuid, + period_id: &str, + actor: Uuid, + ) -> Result<(), DomainError> { + let audit = Arc::clone(&self.audit); + let pid = period_id.to_owned(); + + let result: Result = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let audit = Arc::clone(&audit); + let pid = pid.clone(); + Box::pin(async move { + reopen_in_txn(txn, &audit, tenant_id, legal_entity_id, &pid, actor).await + }) + }) + .await; + + match result { + Ok(ReopenTxnResult::NotFound) => Err(DomainError::PeriodNotFound(period_id.to_owned())), + Ok(ReopenTxnResult::NoOp | ReopenTxnResult::Reopened) => Ok(()), + Err(e) => Err(DomainError::Internal(format!("reopen txn: {e}"))), + } + } + + /// Release the close lease, logging (not failing) a release fault — the lease + /// lapses at TTL regardless. A failed close uses `release_with_retry` to free + /// the slot promptly so a retry is not blocked by a stale lease. + async fn release_lease(guard: LeaseGuard, succeeded: bool) { + let key = guard.key().to_owned(); + let released = if succeeded { + guard.release().await + } else { + guard.release_with_retry().await + }; + if let Err(e) = released { + tracing::warn!( + target: "bss-ledger", + error = %e, + lease_key = %key, + "failed to release period-close lease (will lapse at TTL)" + ); + } + } +} + +/// In-transaction close body: read (OPEN?) → gate (tie-out + open exceptions + +/// due recognition segments) → on block persist `period_close = CLOSING` + +/// reasons; else flip `fiscal_period OPEN→CLOSED` + `period_close → CLOSED` and +/// emit `period.closed` (all in-txn, transactional outbox). Runs under the +/// caller's `SERIALIZABLE` transaction so the gate reads, the flip, and the event +/// share one snapshot and conflict with a concurrent post (SSI). The initiating +/// Finance actor (`ctx.subject_id()`) is stamped on the `period_close` row and the +/// event. +#[allow( + clippy::too_many_arguments, + clippy::too_many_lines, + reason = "the in-txn close body threads the gate inputs (db/publisher/control-feeds) + the (tenant, legal_entity, period) coordinate through one serializable transaction; the gated sequence (tie-out, exceptions, recognition, control feeds, fx-revaluation, flip, emit) reads top-to-bottom as one cohesive unit" +)] +async fn close_in_txn( + ctx: &SecurityContext, + txn: &DbTx<'_>, + db: &DBProvider, + publisher: &Arc, + control: Option<&CloseControlFeeds>, + tenant_id: Uuid, + legal_entity_id: Uuid, + period_id: &str, +) -> Result { + let scope = AccessScope::for_tenant(tenant_id); + let actor = ctx.subject_id(); + let actor_s = actor.to_string(); + + // 1. Read the period row (in-txn → joins the serializable snapshot). + let row = fiscal_period::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(fiscal_period::Column::TenantId.eq(tenant_id)) + .add(fiscal_period::Column::LegalEntityId.eq(legal_entity_id)) + .add(fiscal_period::Column::PeriodId.eq(period_id.to_owned())), + ) + .one(txn) + .await + .map_err(scope_to_db)?; + + match row { + None => return Ok(CloseTxnResult::NotFound), + Some(p) if p.status == PERIOD_STATUS_CLOSED => return Ok(CloseTxnResult::AlreadyClosed), + Some(p) if p.status == PERIOD_STATUS_OPEN => {} + Some(_) => return Ok(CloseTxnResult::NotOpen), + } + + // 2. Gate — accumulate blocking reasons (all in this txn so a concurrent post + // conflicts via SSI). + let mut reasons: Vec = Vec::new(); + + // 2a. Pre-close tie-out (variance / imbalance / negative / PENDING mapping). + let report = TieOutJob::new(db.clone(), Arc::clone(publisher)) + .tie_out_on(txn, tenant_id) + .await + .map_err(|e| DbError::Other(anyhow::anyhow!("pre-close tie-out: {e}")))?; + if !report.is_clean() { + reasons.push(format!("tie-out not clean: {}", report.summary())); + } + + // 2b. OPEN close-blocking exceptions for the period (APPROVED_EXCEPTION rows + // are not OPEN, so a Finance-acknowledged GL-writeoff variance is excluded). + let open = ExceptionQueueRepo::list_open_in_txn(txn, &scope, tenant_id, period_id) + .await + .map_err(repo_to_db)?; + if !open.is_empty() { + let listed: Vec = open + .iter() + .map(|e| format!("{}({})", e.exception_type, e.business_ref)) + .collect(); + reasons.push(format!( + "{} open exception(s): {}", + open.len(), + listed.join(", ") + )); + } + + // 2c. Recognition segments due `<=` this period that have not released. + let due_not_done = + RecognitionRepo::count_due_not_done_in_txn(txn, &scope, tenant_id, period_id).await?; + if due_not_done > 0 { + reasons.push(format!( + "{due_not_done} recognition segment(s) due <= {period_id} not DONE" + )); + } + + // 2d. (The Mode-B FX-revaluation-incomplete gate — design §4.5 / PRD F7 — is the + // `control.fx_revaluation_enforcement` block below, resolved PER-TENANT (VHP-1986): + // only an effective-Mode-B tenant must have a COMPLETE marker. Inert while + // enforcement is off, the v1 default.) + + // 2e. (Slice 7 Phase 3) the mandatory pre-close control-feed gates (design §4.5 / + // decision 3), each behind its enforcement flag. A configured-but-absent/failing + // feed FAILS LOUD (blocks), never silently passes. Both are inert by default (the + // feeds are launch-blocking cross-team; flags OFF until live), so completeness then + // leans on runbook discipline (the stated residual MVP risk). + if let Some(control) = control { + // Close-after-bill-run (N-core-8 / S7-F1): block until the period's bill run is + // asserted finished. `None` (not asserted) and `Some(false)` both block when ON. + if control.bill_run_enforcement { + match control + .bill_run_feed + .is_finished(tenant_id, period_id) + .await + { + Ok(Some(true)) => {} + Ok(Some(false)) => reasons.push("bill run not finished for the period".to_owned()), + Ok(None) => { + reasons.push("bill-run-finished not asserted (enforcement on)".to_owned()); + } + Err(e) => reasons.push(format!("bill-run feed error (fail-loud): {e}")), + } + } + // Invoice-completeness (N-recon-1): the independent issued-invoice manifest vs + // the posted `INVOICE_POST` set. Any issued invoice with no committed entry blocks. + if control.manifest_enforcement { + match control + .manifest_feed + .latest_manifest(tenant_id, period_id) + .await + { + Ok(Some(manifest)) => { + let posted = crate::infra::reconciliation::posted_invoice_ids( + txn, &scope, tenant_id, period_id, + ) + .await?; + let missing = manifest + .invoice_ids + .iter() + .filter(|id| !posted.contains(*id)) + .count(); + if missing > 0 { + reasons.push(format!( + "{missing} issued invoice(s) with no committed posting (missed-posting)" + )); + } + // Reconcile the manifest's count control-total (design §3.3): an + // INVOICE_POST not on the manifest (an extra / duplicate posting) or + // a count-inconsistent feed leaves `missing == 0` yet the posted set + // differs in size — block rather than silently certify a period whose + // posted invoices do not match what was billed. (Gross-total + // reconciliation needs a defined posted-gross-per-invoice semantic + // aligned with the issuer's `gross_total_minor`; tracked follow-up.) + let posted_count = u64::try_from(posted.len()).unwrap_or(u64::MAX); + if manifest.count != posted_count { + reasons.push(format!( + "issued-invoice count mismatch (manifest {}, posted {posted_count})", + manifest.count + )); + } + } + // Configured-but-missing fails loud (decision 3): a manifest must be + // present once enforcement is on, or close cannot prove completeness. + Ok(None) => { + reasons.push("issued-invoice manifest unavailable (enforcement on)".to_owned()); + } + Err(e) => reasons.push(format!("manifest feed error (fail-loud): {e}")), + } + } + // Mode-B FX-revaluation completeness (VHP-1859 review C3): when Mode-B is + // enabled the period-end revaluation MUST have recorded a COMPLETE marker + // for this period. Without it (a failed/lagged run) close BLOCKS and emits + // FxRevaluationIncomplete — never certifying a period whose missing + // FX_REVALUATION the closed-period guard would make unpostable forever. + // Entry-existence cannot prove the run happened (a clean run legitimately + // posts zero entries), so the marker is required. + if control.fx_revaluation_enforcement { + // VHP-1986: enforce the COMPLETE marker only for a tenant that is + // effectively Mode B (BSS = ledger of record). An explicit Mode-A tenant + // (its ERP revalues) is exempt; an unconfigured tenant follows the fleet + // default (enforcement-on ⇒ Mode B). + let mode = FxRevaluationModeRepo::read_effective_mode_in_txn( + txn, + &scope, + tenant_id, + chrono::Utc::now(), + ) + .await + .map_err(repo_to_db)? + .unwrap_or(RevaluationMode::fleet_default( + control.fx_revaluation_enforcement, + )); + let complete = if mode.revalues() { + FxRevaluationRunRepo::is_period_complete(txn, &scope, tenant_id, period_id) + .await + .map_err(repo_to_db)? + } else { + // Mode A is exempt — the tenant's ERP revalues; nothing to require. + true + }; + if !complete { + reasons.push( + "Mode-B FX revaluation not COMPLETE for the period (enforcement on)".to_owned(), + ); + publisher + .emit_invariant_alarm( + ctx, + LedgerInvariantAlarm { + category: AlarmCategory::FxRevaluationIncomplete, + severity: AlarmSeverity::Critical, + tenant_id, + scope: format!("tenant:{tenant_id}"), + code: "FX_REVALUATION_INCOMPLETE".to_owned(), + detail: format!( + "period {period_id} closing without a COMPLETE Mode-B \ + revaluation marker" + ), + affected: Vec::new(), + }, + ) + .await; + } + } + } + + // 3. Blocked → persist CLOSING + reasons for the dashboard, return Blocked. + if !reasons.is_empty() { + let blocked = serde_json::Value::Array( + reasons + .iter() + .map(|r| serde_json::Value::String(r.clone())) + .collect(), + ); + PeriodCloseRepo::upsert_status( + txn, + &scope, + tenant_id, + legal_entity_id, + period_id, + PERIOD_CLOSE_STATUS_CLOSING, + &actor_s, + Some(blocked), + None, + None, + ) + .await + .map_err(repo_to_db)?; + return Ok(CloseTxnResult::Blocked(reasons)); + } + + // 4. Clean → flip `fiscal_period OPEN→CLOSED` (guarded on status=OPEN) and + // record `period_close = CLOSED` in the same commit. + let res = fiscal_period::Entity::update_many() + .secure() + .scope_with(&scope) + .col_expr( + fiscal_period::Column::Status, + Expr::value(PERIOD_STATUS_CLOSED), + ) + .filter( + Condition::all() + .add(fiscal_period::Column::TenantId.eq(tenant_id)) + .add(fiscal_period::Column::LegalEntityId.eq(legal_entity_id)) + .add(fiscal_period::Column::PeriodId.eq(period_id.to_owned())) + .add(fiscal_period::Column::Status.eq(PERIOD_STATUS_OPEN)), + ) + .exec(txn) + .await + .map_err(scope_to_db)?; + + let closed_at = chrono::Utc::now(); + PeriodCloseRepo::upsert_status( + txn, + &scope, + tenant_id, + legal_entity_id, + period_id, + PERIOD_STATUS_CLOSED, + &actor_s, + None, + None, + Some(closed_at), + ) + .await + .map_err(repo_to_db)?; + + // Emit `period.closed` ONLY on a real `OPEN→CLOSED` flip (`rows_affected > 0`), + // never on an idempotent no-op (a concurrent peer beat us to the flip under the + // same lease — `rows_affected == 0`). In-txn (transactional outbox), so the + // event commits atomically with the flip or rolls back with it. + if res.rows_affected > 0 { + // VHP-1843: snapshot the just-verified caches as the cumulative tie-out + // baseline through this closing period — in the SAME txn, so it rolls back + // with the close on abort. The pre-close tie-out (2a) proved the caches, + // so they ARE the verified total through `period_id`; the daily / recon + // incremental tie-out then verifies `baseline + fold(open) == cache` + // instead of folding all-time. Only on a real OPEN→CLOSED flip (not an + // idempotent re-entry — the prior close already wrote the baseline). + TieOutJob::new(db.clone(), Arc::clone(publisher)) + .snapshot_baseline(txn, tenant_id, period_id) + .await + .map_err(|e| { + DbError::Other(anyhow::anyhow!("close: snapshot tie-out baseline: {e}")) + })?; + publisher + .publish_period_closed( + ctx, + txn, + LedgerPeriodClosed { + tenant_id, + legal_entity_id, + period_id: period_id.to_owned(), + closed_by: actor, + closed_at_utc: closed_at, + }, + ) + .await + .map_err(|e| DbError::Other(anyhow::anyhow!("publish period.closed: {e}")))?; + } + + Ok(CloseTxnResult::Flipped { + already_closed: res.rows_affected == 0, + }) +} + +/// In-transaction reopen body: read (CLOSED?) → flip `fiscal_period CLOSED→OPEN` +/// (guarded) + `period_close → REOPENED` → write the `period-reopen` +/// secured-audit record. Runs under the caller's `SERIALIZABLE` transaction. +async fn reopen_in_txn( + txn: &DbTx<'_>, + audit: &Arc, + tenant_id: Uuid, + legal_entity_id: Uuid, + period_id: &str, + actor: Uuid, +) -> Result { + let scope = AccessScope::for_tenant(tenant_id); + + let row = fiscal_period::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(fiscal_period::Column::TenantId.eq(tenant_id)) + .add(fiscal_period::Column::LegalEntityId.eq(legal_entity_id)) + .add(fiscal_period::Column::PeriodId.eq(period_id.to_owned())), + ) + .one(txn) + .await + .map_err(scope_to_db)?; + + match row { + None => return Ok(ReopenTxnResult::NotFound), + // Already OPEN (never closed, or a prior reopen) — idempotent no-op. + Some(p) if p.status == PERIOD_STATUS_OPEN => return Ok(ReopenTxnResult::NoOp), + _ => {} + } + + // Flip CLOSED→OPEN (guarded on status=CLOSED). + fiscal_period::Entity::update_many() + .secure() + .scope_with(&scope) + .col_expr( + fiscal_period::Column::Status, + Expr::value(PERIOD_STATUS_OPEN), + ) + .filter( + Condition::all() + .add(fiscal_period::Column::TenantId.eq(tenant_id)) + .add(fiscal_period::Column::LegalEntityId.eq(legal_entity_id)) + .add(fiscal_period::Column::PeriodId.eq(period_id.to_owned())) + .add(fiscal_period::Column::Status.eq(PERIOD_STATUS_CLOSED)), + ) + .exec(txn) + .await + .map_err(scope_to_db)?; + + // Record the close-process row as REOPENED. + let actor_s = actor.to_string(); + PeriodCloseRepo::upsert_status( + txn, + &scope, + tenant_id, + legal_entity_id, + period_id, + "REOPENED", + &actor_s, + None, + None, + None, + ) + .await + .map_err(repo_to_db)?; + + // Slice-6 `period-reopen` secured-audit record (NO-OP sink until Slice 6). + let before_after = serde_json::json!({ + "period_id": period_id, + "legal_entity_id": legal_entity_id.to_string(), + "transition": "CLOSED->REOPENED", + }); + audit + .append( + txn, + &scope, + tenant_id, + AuditEventType::PeriodReopen, + Some(actor_s.as_str()), + Some("period-reopen"), + &before_after, + None, + None, + ) + .await?; + + Ok(ReopenTxnResult::Reopened) +} diff --git a/gears/bss/ledger/ledger/src/infra/pii.rs b/gears/bss/ledger/ledger/src/infra/pii.rs new file mode 100644 index 000000000..b2834944f --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/pii.rs @@ -0,0 +1,677 @@ +//! PII minimization, erasure, and re-identification (Slice 6 Phase 3 Group 3A, +//! architecture §4.5 / AC #22). +//! +//! Three pieces: +//! +//! - [`PiiMinimizer`] — a PURE scanner that walks a JSON payload and flags the +//! PRD-prohibited PII categories whose keys appear anywhere in it (customer +//! name, email, phone, payment-instrument detail, street address). The ledger +//! stores ledger truth, never raw customer PII; this guard lets a caller prove +//! a payload carries only internal ids before it is persisted. +//! +//! - [`PayerPiiMap`] — a stateless repo over `bss.payer_pii_map`: one row per +//! `(tenant, payer_tenant_id)` holding the opaque `pii_ref` (a pointer into +//! the external PII store) and an `erased` tombstone. +//! +//! - [`ErasureService`] — the GDPR right-to-erasure + forensic +//! re-identification path. `erase` flips the tombstone and records ONE +//! `erasure` secured-audit record in one `SERIALIZABLE` transaction (idempotent +//! — re-erasing an already-tombstoned map still records the audit event but +//! touches no financial table; erasing a payer with no map row is a 404). +//! `reidentify` is forensic-gated exactly like the cross-tenant audit read +//! (`reason` + `reason_code` required, else +//! [`DomainError::MissingInvestigationReason`]): it records ONE +//! `re-identification` record BEFORE returning the `pii_ref` (even of a +//! tombstoned payer — the documented investigator path). +//! +//! Both forensic records are written onto the **home (actor) tenant's** audit +//! chain — the same rule [`crate::infra::authz::cross_tenant::CrossTenantGateway`] +//! follows for `cross-tenant-access`. The map write (tombstone / read) is +//! scoped to the **data (target) tenant**. For a routine same-tenant call the +//! two coincide; for a cross-tenant erase/re-identify the actor's own chain +//! carries the trail of what they did to the other tenant. +//! +//! Neither path touches `journal_entry` / `journal_line`: the financial truth +//! (and its tamper-evidence chain) stays byte-identical across an erasure. + +use sea_orm::sea_query::{Expr, OnConflict}; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{ + AccessScope, DbTx, SecureEntityExt, SecureInsertExt, SecureUpdateExt, TxConfig, +}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use std::sync::Arc; + +use crate::domain::error::DomainError; +use crate::domain::model::RepoError; +use crate::domain::ports::metrics::{LedgerMetricsPort, NoopLedgerMetrics}; +use crate::infra::audit::event_type::AuditEventType; +use crate::infra::audit::store::SecuredAuditStore; +use crate::infra::posting::service::{business, repo_to_db}; +use crate::infra::storage::entity::payer_pii_map; + +// --------------------------------------------------------------------------- +// PiiMinimizer — pure prohibited-field scanner. +// --------------------------------------------------------------------------- + +/// One PRD-prohibited PII category. The `&'static str` is the stable category +/// label the scanner returns (NOT the matched key — many keys map to one +/// category, e.g. `pan` and `card_number` both map to `payment_instrument`). +mod category { + /// Customer name (personal / full / customer name). + pub const NAME: &str = "name"; + /// Email address. + pub const EMAIL: &str = "email"; + /// Phone / mobile number. + pub const PHONE: &str = "phone"; + /// Payment-instrument detail (card / PAN / IBAN / bank account number). + pub const PAYMENT_INSTRUMENT: &str = "payment_instrument"; + /// Street / postal address. + pub const STREET_ADDRESS: &str = "street_address"; +} + +/// Map ONE object key (already lower-cased) to its prohibited category, or +/// `None` when the key is benign (an internal id is benign). Matching is by the +/// common spellings the PRD enumerates; an unknown key is treated as benign (the +/// scanner flags only KNOWN PII keys, never guesses). +fn category_for_key(key_lower: &str) -> Option<&'static str> { + match key_lower { + // Customer name spellings. + "name" | "full_name" | "fullname" | "customer_name" | "first_name" | "last_name" => { + Some(category::NAME) + } + // Email spellings. + "email" | "email_address" | "e_mail" => Some(category::EMAIL), + // Phone spellings. + "phone" | "phone_number" | "mobile" | "mobile_number" | "msisdn" | "telephone" => { + Some(category::PHONE) + } + // Payment-instrument spellings (card / PAN / IBAN / bank account). + "card_number" + | "cardnumber" + | "pan" + | "iban" + | "account_number" + | "bank_account_number" => Some(category::PAYMENT_INSTRUMENT), + // Street / postal address spellings. + "street" | "address" | "address_line1" | "address_line_1" | "addressline1" + | "postal_address" | "street_address" => Some(category::STREET_ADDRESS), + _ => None, + } +} + +/// `true` if `text` contains something shaped like an email address: a +/// whitespace-delimited token (trimmed of surrounding punctuation) with a +/// non-empty local part, an `@`, and a domain whose last dot-segment is a TLD +/// (2+ ASCII letters). Deliberately strict to keep false positives low on +/// legitimate ledger notes. +fn text_has_email(text: &str) -> bool { + text.split_whitespace().any(|raw| { + let tok = raw.trim_matches(|c: char| { + matches!( + c, + '.' | ',' | ';' | ':' | '(' | ')' | '<' | '>' | '"' | '\'' + ) + }); + let Some(at) = tok.find('@') else { + return false; + }; + let local = &tok[..at]; + let domain = &tok[at + 1..]; + if local.is_empty() { + return false; + } + match domain.rsplit_once('.') { + Some((host, tld)) => { + !host.is_empty() && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()) + } + None => false, + } + }) +} + +/// `true` if `text` contains a run of 10+ digits once intra-number separators +/// (space, dash, parentheses, a `+`) are ignored — the shape of a phone +/// (national 10-digit `415-555-2671` or `+CC` international) or a +/// payment-instrument (card / account) number. The 10-digit floor still skips +/// dates (`2026-06-25` — 8 digits) and short order numbers (≤ 9 digits), +/// keeping false positives low. +fn text_has_long_number(text: &str) -> bool { + /// Digit floor that distinguishes a phone / card / account number from a + /// date or short reference. A national phone number is exactly 10 digits. + const MIN_DIGITS: usize = 10; + let mut run = 0usize; + for c in text.chars() { + if c.is_ascii_digit() { + run += 1; + if run >= MIN_DIGITS { + return true; + } + } else if !matches!(c, ' ' | '-' | '(' | ')' | '+') { + // A non-separator breaks the run; separators neither count nor reset. + run = 0; + } + } + false +} + +/// A pure scanner for PRD-prohibited PII (architecture §4.5). Holds no state. +pub struct PiiMinimizer; + +impl PiiMinimizer { + /// Scan `value` (recursively) for object KEYS matching a prohibited PII + /// category, returning the DISTINCT categories found in stable category + /// order. A clean payload (internal ids only) returns an empty `Vec`. + /// + /// Recursion: an object's keys are tested then its values are descended; + /// an array's elements are each descended; a scalar (string / number / bool + /// / null) contributes nothing (only KEYS are matched, never values — a key + /// is the PII signal, a value could be any opaque string). Both an + /// `{"customer": {"email": …}}` nesting and a `[{"email": …}]` array element + /// are flagged. + #[must_use] + pub fn prohibited_fields(value: &serde_json::Value) -> Vec<&'static str> { + // Collect into a fixed-order de-dup set: walk, then return the canonical + // category order so the result is deterministic regardless of key order. + const ORDER: &[&str] = &[ + category::NAME, + category::EMAIL, + category::PHONE, + category::PAYMENT_INSTRUMENT, + category::STREET_ADDRESS, + ]; + let mut found: Vec<&'static str> = Vec::new(); + Self::walk(value, &mut found); + ORDER + .iter() + .copied() + .filter(|c| found.contains(c)) + .collect() + } + + /// Depth-first walk: push the category of every matching object key, then + /// descend into nested objects / arrays. + fn walk(value: &serde_json::Value, found: &mut Vec<&'static str>) { + match value { + serde_json::Value::Object(map) => { + for (key, child) in map { + if let Some(cat) = category_for_key(&key.to_lowercase()) + && !found.contains(&cat) + { + found.push(cat); + } + Self::walk(child, found); + } + } + serde_json::Value::Array(items) => { + for item in items { + Self::walk(item, found); + } + } + // Scalars carry no key, so they contribute no category. + _ => {} + } + } + + /// Scan `value` recursively for PII embedded in STRING VALUES (not object + /// keys), returning the DISTINCT categories found in stable order. This + /// complements [`Self::prohibited_fields`] (which sees only keys): a + /// free-text field such as a metadata `description` carries its PII in the + /// value, where the key-based scan is blind. + /// + /// Detects the two categories that are reliable to spot in free text — an + /// email address ([`category::EMAIL`]) and a long digit run, a phone or + /// payment-instrument number ([`category::PAYMENT_INSTRUMENT`]). Customer + /// name and street address are not machine-detectable in free text and are + /// left to the key-based scan. + #[must_use] + pub fn prohibited_in_values(value: &serde_json::Value) -> Vec<&'static str> { + const ORDER: &[&str] = &[category::EMAIL, category::PAYMENT_INSTRUMENT]; + let mut found: Vec<&'static str> = Vec::new(); + Self::walk_values(value, &mut found); + ORDER + .iter() + .copied() + .filter(|c| found.contains(c)) + .collect() + } + + /// Depth-first walk that inspects STRING scalars (descending objects / + /// arrays); object KEYS are ignored here — that is [`Self::walk`]'s job. + fn walk_values(value: &serde_json::Value, found: &mut Vec<&'static str>) { + match value { + serde_json::Value::Object(map) => { + for child in map.values() { + Self::walk_values(child, found); + } + } + serde_json::Value::Array(items) => { + for item in items { + Self::walk_values(item, found); + } + } + serde_json::Value::String(text) => { + if text_has_email(text) && !found.contains(&category::EMAIL) { + found.push(category::EMAIL); + } + if text_has_long_number(text) && !found.contains(&category::PAYMENT_INSTRUMENT) { + found.push(category::PAYMENT_INSTRUMENT); + } + } + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// PayerPiiMap — stateless repo over bss.payer_pii_map. +// --------------------------------------------------------------------------- + +/// Stateless repo over `bss.payer_pii_map`. Every method runs inside the +/// caller's transaction (mirrors [`SecuredAuditStore`]). +#[derive(Clone, Default)] +pub struct PayerPiiMap; + +impl PayerPiiMap { + #[must_use] + pub fn new() -> Self { + Self + } + + /// Upsert the `pii_ref` for `(tenant, payer_tenant_id)`: + /// `INSERT … ON CONFLICT (tenant_id, payer_tenant_id) DO UPDATE SET pii_ref`. + /// A fresh row is `erased = false` (the column default); a conflict updates + /// only `pii_ref` (an existing tombstone is NOT resurrected by an upsert). + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn upsert( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payer_tenant_id: Uuid, + pii_ref: &str, + ) -> Result<(), RepoError> { + let am = payer_pii_map::ActiveModel { + tenant_id: Set(tenant), + payer_tenant_id: Set(payer_tenant_id), + pii_ref: Set(pii_ref.to_owned()), + erased: Set(false), + }; + let on_conflict = OnConflict::columns([ + payer_pii_map::Column::TenantId, + payer_pii_map::Column::PayerTenantId, + ]) + .update_columns([payer_pii_map::Column::PiiRef]) + .to_owned(); + + payer_pii_map::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("payer_pii_map upsert scope: {e}")))? + .on_conflict_raw(on_conflict) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("payer_pii_map upsert: {e}")))?; + Ok(()) + } + + /// Read `(pii_ref, erased)` for `(tenant, payer_tenant_id)` under `scope`, + /// or `None` when no row exists. Takes no row lock. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn get( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payer_tenant_id: Uuid, + ) -> Result, RepoError> { + let row = payer_pii_map::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(payer_pii_map::Column::TenantId.eq(tenant)) + .add(payer_pii_map::Column::PayerTenantId.eq(payer_tenant_id)), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("payer_pii_map get: {e}")))?; + Ok(row.map(|r| (r.pii_ref, r.erased))) + } + + /// Tombstone `(tenant, payer_tenant_id)`: `UPDATE … SET erased = true` WHERE + /// the PK matches. Returns whether a row was updated (`rows_affected > 0`) — + /// `false` when no map row exists. Already-tombstoned rows still match (the + /// UPDATE is a harmless no-op write), so re-erasing returns `true`. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure. + pub async fn tombstone( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payer_tenant_id: Uuid, + ) -> Result { + let res = payer_pii_map::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr(payer_pii_map::Column::Erased, Expr::value(true)) + .filter( + Condition::all() + .add(payer_pii_map::Column::TenantId.eq(tenant)) + .add(payer_pii_map::Column::PayerTenantId.eq(payer_tenant_id)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("payer_pii_map tombstone: {e}")))?; + Ok(res.rows_affected > 0) + } +} + +// --------------------------------------------------------------------------- +// ErasureService — erase + forensic re-identify. +// --------------------------------------------------------------------------- + +/// The PII erasure + re-identification service. Holds the append-only +/// [`SecuredAuditStore`] + the stateless [`PayerPiiMap`] repo; every write runs +/// in its own `SERIALIZABLE` transaction (mirrors +/// [`crate::infra::annotation::AnnotationService`]). +#[derive(Clone)] +pub struct ErasureService { + audit: SecuredAuditStore, + map: PayerPiiMap, + metrics: Arc, +} + +impl Default for ErasureService { + fn default() -> Self { + Self::new() + } +} + +impl ErasureService { + #[must_use] + pub fn new() -> Self { + Self { + audit: SecuredAuditStore::new(), + map: PayerPiiMap::new(), + metrics: Arc::new(NoopLedgerMetrics), + } + } + + /// Bind the §9 metrics sink (`ledger_erasure_applied_total` / + /// `ledger_reidentification_total`). Defaults to no-op until wired. + #[must_use] + pub fn with_metrics(mut self, metrics: Arc) -> Self { + self.metrics = metrics; + self + } + + /// Erase (GDPR right-to-erasure) the PII map for + /// `(data_tenant, payer_tenant_id)`: one `SERIALIZABLE` transaction that + /// tombstones the map (`erased = true`) under the DATA (target) scope and + /// appends ONE `erasure` secured-audit record onto the HOME (actor) tenant's + /// audit chain. NO journal table is touched — the financial truth and its + /// chain stay byte-identical. + /// + /// `home_scope`/`home_tenant` own the forensic record + its chain (mirrors + /// [`crate::infra::authz::cross_tenant::CrossTenantGateway`]); + /// `data_scope`/`data_tenant` scope the map write. For a routine same-tenant + /// erasure the two coincide. + /// + /// Idempotent: re-erasing an already-tombstoned map — AND erasing a payer + /// that has no map row at all — is a no-op on the tombstone yet STILL records + /// an `erasure` audit event and succeeds (204). Every erase request is + /// recorded, even a repeat or a never-mapped payer; GDPR right-to-erasure must + /// not leak whether a payer was ever mapped, so "erase what isn't there" is a + /// successful no-op, not a 404 (unlike [`Self::reidentify`], which returns a + /// `pii_ref` and so legitimately 404s on an absent row). + /// + /// # Errors + /// [`DomainError::MissingInvestigationReason`] (empty reason, pre-write), + /// [`DomainError::Internal`] on a storage / scope / audit failure (rolls the + /// transaction back). + #[allow( + clippy::too_many_arguments, + reason = "one erasure's home (forensic chain) + data (map) scope/tenant + payer + actor/reason" + )] + pub async fn erase( + &self, + db: &DBProvider, + ctx: &SecurityContext, + home_scope: &AccessScope, + home_tenant: Uuid, + data_scope: &AccessScope, + data_tenant: Uuid, + payer_tenant_id: Uuid, + actor_ref: String, + reason: String, + correlation_id: Option, + ) -> Result<(), DomainError> { + // Forensic gate: an erasure MUST carry a non-empty reason — + // the same bar `reidentify` enforces. Checked here (not just at the REST + // seam) so every caller, own-tenant or cross-tenant, is covered before any + // write. The trimmed value is what the `erasure` audit record records. + let reason = reason.trim().to_owned(); + if reason.is_empty() { + return Err(DomainError::MissingInvestigationReason(format!( + "erasing payer {payer_tenant_id} requires a non-empty reason" + ))); + } + + // `ctx` authorized the call at the REST seam (the PEP gate ran there); the + // in-txn writes are tenant-scoped (data scope for the map, home scope for + // the forensic record) and the audit append generates its own ids, so the + // txn body does not re-thread `ctx`. + let _ = ctx; + let audit = self.audit.clone(); + let map = self.map.clone(); + let home_scope = home_scope.clone(); + let data_scope = data_scope.clone(); + + let result: Result<(), DbError> = db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let audit = audit.clone(); + let map = map.clone(); + let home_scope = home_scope.clone(); + let data_scope = data_scope.clone(); + let actor_ref = actor_ref.clone(); + let reason = reason.clone(); + Box::pin(async move { + // 1. Tombstone the target map (idempotent). A missing or + // already-tombstoned row is a no-op — erasing a never-mapped + // payer still succeeds and STILL records the forensic + // `erasure` event below (every erase request is recorded; + // GDPR erasure must not reveal whether a payer was mapped). + map.tombstone(txn, &data_scope, data_tenant, payer_tenant_id) + .await + .map_err(repo_to_db)?; + + // 2. Record ONE `erasure` record on the HOME chain in the SAME + // txn — the actor's tenant owns the trail of what it erased + // (in `data_tenant`). + let before_after = serde_json::json!({ + "tenant_id": data_tenant, + "payer_tenant_id": payer_tenant_id, + "erased": true, + }); + audit + .append( + txn, + &home_scope, + home_tenant, + AuditEventType::Erasure, + Some(actor_ref.as_str()), + Some(reason.as_str()), + &before_after, + correlation_id, + None, + ) + // Propagate the append's `DbError` as-is so a retryable + // SSI serialization failure is not buried in a + // non-retryable `DbErr::Custom`. + .await?; + Ok(()) + }) + }) + .await; + + // §9: count a successful erasure (`ledger_erasure_applied_total`); only a + // committed erase increments. `decode_business_error` maps any business + // sentinel back to its `DomainError` rather than mislabelling it 500 (the + // erase txn body raises no business rejection today — the reason gate is + // pre-txn — but keep the decode symmetric with `reidentify`). + let mapped = result.map_err(|e| crate::infra::posting::service::decode_business_error(&e)); + if mapped.is_ok() { + self.metrics.erasure_applied(); + } + mapped + } + + /// Re-identify (forensic-gated) the PII map for + /// `(data_tenant, payer_tenant_id)`: returns the `pii_ref` AFTER recording + /// ONE `re-identification` secured-audit record, in one `SERIALIZABLE` + /// transaction. + /// + /// `home_scope`/`home_tenant` own the forensic record + its chain (mirrors + /// [`crate::infra::authz::cross_tenant::CrossTenantGateway`]); + /// `data_scope`/`data_tenant` scope the map read. For a routine same-tenant + /// re-identify the two coincide. + /// + /// Forensic gate (mirrors [`crate::infra::authz::cross_tenant`]): both a + /// free-text `reason` AND a machine `reason_code` are required — a missing / + /// empty one fails with [`DomainError::MissingInvestigationReason`] BEFORE + /// any read or write (the audit record is never half-written). A + /// re-identify of a TOMBSTONED payer is the documented investigator path, so + /// the `pii_ref` is returned regardless of `erased`. Absent map row → + /// [`DomainError::PayerPiiNotFound`] (404). + /// + /// # Errors + /// [`DomainError::MissingInvestigationReason`] (gate, pre-write), + /// [`DomainError::PayerPiiNotFound`] (no map row), + /// [`DomainError::Internal`] on a storage / scope / audit failure. + #[allow( + clippy::too_many_arguments, + reason = "the full re-identify contract: home (forensic chain) + data (map) scope/tenant + payer + actor/reason/reason_code" + )] + pub async fn reidentify( + &self, + db: &DBProvider, + ctx: &SecurityContext, + home_scope: &AccessScope, + home_tenant: Uuid, + data_scope: &AccessScope, + data_tenant: Uuid, + payer_tenant_id: Uuid, + actor_ref: String, + reason: String, + reason_code: String, + correlation_id: Option, + ) -> Result { + let _ = ctx; + let audit = self.audit.clone(); + let map = self.map.clone(); + let home_scope = home_scope.clone(); + let data_scope = data_scope.clone(); + + let result: Result = db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let audit = audit.clone(); + let map = map.clone(); + let home_scope = home_scope.clone(); + let data_scope = data_scope.clone(); + let actor_ref = actor_ref.clone(); + let reason = reason.clone(); + let reason_code = reason_code.clone(); + Box::pin(async move { + // 1. Forensic gate: BOTH a non-empty reason AND reason_code are + // required (fail BEFORE any read or write — no half-written + // record). Mirrors `CrossTenantGateway::resolve_read_scope`. + let reason = reason.trim(); + let reason_code = reason_code.trim(); + if reason.is_empty() || reason_code.is_empty() { + return Err(business(DomainError::MissingInvestigationReason(format!( + "re-identifying payer {payer_tenant_id} requires both a reason \ + and a reason_code" + )))); + } + + // 2. Read the map row under the DATA scope; 404 if absent + // (re-identify of a tombstoned payer is allowed — the + // `erased` flag is ignored here, the investigator path + // returns the ref regardless). + let (pii_ref, erased) = map + .get(txn, &data_scope, data_tenant, payer_tenant_id) + .await + .map_err(repo_to_db)? + .ok_or_else(|| { + business(DomainError::PayerPiiNotFound(format!( + "no payer_pii_map for payer {payer_tenant_id}" + ))) + })?; + + // 3. Record ONE `re-identification` record on the HOME chain + // BEFORE returning the pii_ref, in the SAME txn (the + // forensic trail and the read commit or roll back together). + let before_after = serde_json::json!({ + "tenant_id": data_tenant, + "payer_tenant_id": payer_tenant_id, + "erased": erased, + "reason": reason, + }); + audit + .append( + txn, + &home_scope, + home_tenant, + AuditEventType::ReIdentification, + Some(actor_ref.as_str()), + Some(reason_code), + &before_after, + correlation_id, + None, + ) + // Propagate the append's `DbError` as-is so a retryable + // SSI serialization failure is not buried in a + // non-retryable `DbErr::Custom`. + .await?; + Ok(pii_ref) + }) + }) + .await; + + // §9: count a successful re-identification (`ledger_reidentification_total`). + // A business rejection (missing reason / not found) is decoded below; only + // a committed Ok increments. + let mapped = result.map_err(|e| crate::infra::posting::service::decode_business_error(&e)); + if mapped.is_ok() { + self.metrics.reidentification(); + } + mapped + } +} + +/// Retry-extractor for the `SERIALIZABLE` PII writes: a wrapped `DbErr` so a +/// serialization failure (statement or COMMIT) is retryable contention; the +/// business-rejection sentinel is a non-retryable `DbErr::Custom` (mirrors +/// `infra::annotation::as_db_err` / `infra::posting::service::as_db_err`). +fn as_db_err(e: &DbError) -> Option<&sea_orm::DbErr> { + match e { + DbError::Sea(db_err) => Some(db_err), + _ => None, + } +} + +#[cfg(test)] +#[path = "pii_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/pii_tests.rs b/gears/bss/ledger/ledger/src/infra/pii_tests.rs new file mode 100644 index 000000000..f598c0cb6 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/pii_tests.rs @@ -0,0 +1,165 @@ +//! Unit tests for [`PiiMinimizer`] (pure prohibited-field scanner). No database. + +use super::PiiMinimizer; + +/// A clean, internal-id-only payload flags NOTHING. +#[test] +fn clean_internal_payload_flags_nothing() { + let v = serde_json::json!({ + "payer_tenant_id": "018f-…", + "invoice_id": "inv-123", + "amount_minor": 1000, + "currency": "USD", + "account_id": "018f-…", + }); + assert!( + PiiMinimizer::prohibited_fields(&v).is_empty(), + "an internal-id-only payload must flag no prohibited fields" + ); +} + +/// Each prohibited category is flagged by at least one common key spelling. +#[test] +fn flags_each_category_by_a_common_spelling() { + assert_eq!( + PiiMinimizer::prohibited_fields(&serde_json::json!({ "customer_name": "Ada" })), + vec!["name"] + ); + assert_eq!( + PiiMinimizer::prohibited_fields(&serde_json::json!({ "email_address": "a@b.co" })), + vec!["email"] + ); + assert_eq!( + PiiMinimizer::prohibited_fields(&serde_json::json!({ "msisdn": "+1555" })), + vec!["phone"] + ); + assert_eq!( + PiiMinimizer::prohibited_fields(&serde_json::json!({ "pan": "4111…" })), + vec!["payment_instrument"] + ); + assert_eq!( + PiiMinimizer::prohibited_fields(&serde_json::json!({ "iban": "DE89…" })), + vec!["payment_instrument"] + ); + assert_eq!( + PiiMinimizer::prohibited_fields(&serde_json::json!({ "address_line1": "1 Main St" })), + vec!["street_address"] + ); +} + +/// Distinct categories are returned once each, in canonical order, regardless of +/// the input key order. +#[test] +fn distinct_categories_in_canonical_order() { + let v = serde_json::json!({ + "street": "1 Main St", + "card_number": "4111…", + "phone": "+1555", + "email": "a@b.co", + "name": "Ada", + // a second name-spelling must not duplicate the `name` category + "full_name": "Ada L.", + }); + assert_eq!( + PiiMinimizer::prohibited_fields(&v), + vec![ + "name", + "email", + "phone", + "payment_instrument", + "street_address" + ], + "categories de-duplicated and returned in canonical order" + ); +} + +/// Keys are matched case-insensitively and at any nesting depth (nested object +/// AND array element). +#[test] +fn matches_nested_and_array_and_case_insensitively() { + let v = serde_json::json!({ + "customer": { "Email": "a@b.co" }, + "contacts": [ { "Phone_Number": "+1555" } ], + "internal_id": "ok", + }); + assert_eq!( + PiiMinimizer::prohibited_fields(&v), + vec!["email", "phone"], + "nested object + array element keys are flagged, case-insensitively" + ); +} + +/// Only KEYS are matched, never values: a benign key whose value happens to be a +/// string like an email is NOT flagged. +#[test] +fn matches_keys_not_values() { + let v = serde_json::json!({ "note": "contact a@b.co or call +1555" }); + assert!( + PiiMinimizer::prohibited_fields(&v).is_empty(), + "a benign key carrying PII-looking text in its VALUE is not flagged" + ); +} + +// --- prohibited_in_values: VALUE-level free-text scan --- + +/// A free-text string value carrying an email is flagged (the value scan sees +/// what the key-only scan cannot). +#[test] +fn value_scan_flags_email_in_free_text() { + let v = serde_json::json!("paid by John Smith, john.smith@example.com, thanks"); + assert_eq!(PiiMinimizer::prohibited_in_values(&v), vec!["email"]); +} + +/// A long digit run (phone / card / account) in a value is flagged as a payment +/// instrument, with separators (spaces / dashes / parentheses / `+`) ignored. +#[test] +fn value_scan_flags_long_number() { + for s in [ + "call +1 415 555 2671 today", // 11 digits across separators + "card 4111-1111-1111-1111", // 16-digit card + "iban-ish 12345678901", // 11 bare digits + "call 415-555-2671 asap", // 10-digit national phone (dashed) + "ring (212) 555-0147 pls", // 10-digit national phone (parens) + "num 4155552671 noted", // 10 bare digits + ] { + let v = serde_json::json!(s); + assert_eq!( + PiiMinimizer::prohibited_in_values(&v), + vec!["payment_instrument"], + "{s:?} should flag a numeric PII run" + ); + } +} + +/// Benign free text — including a date and a short reference number — is NOT +/// flagged (the 10-digit floor keeps false positives low; ≤ 9-digit runs pass). +#[test] +fn value_scan_ignores_dates_and_short_numbers() { + for s in [ + "reconciled on 2026-06-25", // date = 8 digits + "order PO-1234567 shipped", // 7-digit reference + "adjusted opening balance", // no numbers at all + "ratio 3.14 over 2 quarters", // tiny numbers + ] { + let v = serde_json::json!(s); + assert!( + PiiMinimizer::prohibited_in_values(&v).is_empty(), + "{s:?} must NOT be flagged as PII" + ); + } +} + +/// The value scan descends nested objects and array elements, returning distinct +/// categories in canonical order (`email` before `payment_instrument`). +#[test] +fn value_scan_nested_and_distinct_order() { + let v = serde_json::json!({ + "note": "reach me at ada@example.org", + "history": ["ok", "fallback card 4111 1111 1111 1111"], + "count": 3, + }); + assert_eq!( + PiiMinimizer::prohibited_in_values(&v), + vec!["email", "payment_instrument"] + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/policy_version.rs b/gears/bss/ledger/ledger/src/infra/policy_version.rs new file mode 100644 index 000000000..508ec4cab --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/policy_version.rs @@ -0,0 +1,212 @@ +//! [`PolicyVersionGuard`] — the evidence-ref-reuse half of the §4.6 (AC #15) +//! policy-version rule: a correction MUST REUSE the pinned evidence refs of the +//! posting it corrects, never invent fresh ones. +//! +//! ## What "reuse" means here +//! +//! A correction is a post whose header carries `reverses_entry_id` (it points +//! back at a prior posting — a reversal or a `MAPPING_CORRECTION`). Each journal +//! line can carry a tuple of *pinned evidence refs* — the columns that bind the +//! line to immutable upstream evidence: +//! `(pricing_snapshot_ref, po_allocation_group, price_id, sku_or_plan_ref, +//! invoice_item_ref)`. +//! +//! The rule this guard enforces: **every** evidence-ref tuple that appears on +//! the correction's lines MUST also appear on the ORIGINAL's lines. The +//! correction reuses the original's pinned evidence; it may carry fewer tuples +//! (some lines net out with no refs) but never a tuple the original did not +//! have. A correction tuple absent from the original ⇒ +//! [`DomainError::PolicyVersionViolation`]. +//! +//! All-NULL tuples (a line with no pinned evidence at all) are ignored on both +//! sides — they carry no policy-version evidence to reuse or violate. +//! +//! ## What this guard does NOT own +//! +//! §4.6 also requires (A-4/P-3) that a correction re-applies the **note-time +//! policy** (the rounding / GL-mapping / period rules as they stood at the +//! *note's own* time) and respects the **recognition-state-at-note-time**. Those +//! rules belong to the credit-note / refund / recognition handlers landing in +//! Slices 3/4, which are not present yet. This guard implements only the +//! evidence-ref-reuse half — the part the existing reversal / mapping-correction +//! flows can already satisfy — and is the seam those later slices extend. +//! +//! ## How the existing flows interact with this guard +//! +//! `domain::invoice::reversal::flip_line` builds a reversal's lines from the +//! original's read-back `LineView`, and that DTO does NOT carry the pinned +//! evidence-ref columns — so a reversal's lines all have an all-NULL evidence +//! tuple, which this guard ignores. A `MAPPING_CORRECTION` re-books +//! caller-supplied `corrected_lines`; if those reuse the original's refs the +//! guard passes. So on the real flows the guard never fires — reuse is +//! structural. It exists to reject a hand-crafted / buggy correction that +//! invents a pinned ref the original never had (the integration test's crafted +//! case), and as the seam Slices 3/4 extend. +//! +//! Stateless — every method runs inside the caller's posting transaction +//! (`txn`); tenant isolation runs through the `SecureORM` layer +//! (`.secure().scope_with(scope)`), mirroring +//! [`crate::infra::posting::chain::ChainSealer`]. + +use std::collections::HashSet; + +use sea_orm::{ColumnTrait, Condition, EntityTrait}; +use toolkit_db::DbError; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt}; + +use crate::domain::error::DomainError; +use crate::domain::model::NewLine; +use crate::infra::posting::service::{business, infra}; +use crate::infra::storage::entity::journal_line; + +/// The pinned evidence-ref tuple of one journal line: +/// `(pricing_snapshot_ref, po_allocation_group, price_id, sku_or_plan_ref, +/// invoice_item_ref)`. A line with no pinned evidence has an all-`None` tuple. +type EvidenceTuple = ( + Option, + Option, + Option, + Option, + Option, +); + +/// Pure set-check: every non-all-NULL evidence tuple on `correction` MUST also +/// appear in `original`. Returns the FIRST correction tuple absent from the +/// original (the violation), or `None` when every correction tuple is reused. +/// +/// All-NULL tuples are skipped on both sides — a line with no pinned evidence +/// carries no policy-version evidence to reuse or violate. Factored out of +/// [`PolicyVersionGuard::check`] so the set logic is unit-testable without a DB. +#[must_use] +pub(crate) fn first_unreused_tuple( + original: &[EvidenceTuple], + correction: &[EvidenceTuple], +) -> Option { + let original_set: HashSet<&EvidenceTuple> = + original.iter().filter(|t| !is_all_null(t)).collect(); + correction + .iter() + .find(|t| !is_all_null(t) && !original_set.contains(*t)) + .cloned() +} + +/// `true` when a tuple carries no pinned evidence (all five refs are `None`). +fn is_all_null(t: &EvidenceTuple) -> bool { + t.0.is_none() && t.1.is_none() && t.2.is_none() && t.3.is_none() && t.4.is_none() +} + +/// Project one [`NewLine`] to its pinned evidence tuple. +fn line_tuple(l: &NewLine) -> EvidenceTuple { + ( + l.pricing_snapshot_ref.clone(), + l.po_allocation_group.clone(), + l.price_id.clone(), + l.sku_or_plan_ref.clone(), + l.invoice_item_ref.clone(), + ) +} + +/// Project one read-back `journal_line` row to its pinned evidence tuple. +fn row_tuple(r: &journal_line::Model) -> EvidenceTuple { + ( + r.pricing_snapshot_ref.clone(), + r.po_allocation_group.clone(), + r.price_id.clone(), + r.sku_or_plan_ref.clone(), + r.invoice_item_ref.clone(), + ) +} + +/// Posting guard that enforces evidence-ref reuse on a correction (§4.6, AC #15). +/// Runs as a read-only validation step in +/// [`crate::infra::posting::service::PostingService`] AFTER the fiscal-period +/// gate and BEFORE the append-only insert — it only READS the original's lines, +/// so it takes no write lock. +/// +/// Stateless (mirrors [`crate::infra::posting::chain::ChainSealer`] / +/// [`crate::infra::posting::freeze::TamperFreezeGuard`]). +#[derive(Clone, Default)] +pub struct PolicyVersionGuard; + +impl PolicyVersionGuard { + #[must_use] + pub fn new() -> Self { + Self + } + + /// Enforce evidence-ref reuse for `entry`/`lines` inside `txn`. + /// + /// - `entry.reverses_entry_id == None` ⇒ a fresh original posting; nothing to + /// reuse, returns `Ok(())`. + /// - Otherwise (a correction): read the ORIGINAL entry's lines by + /// `(tenant_id, reverses_period_id, reverses_entry_id)` under `scope`, then + /// require every non-all-NULL evidence tuple on `lines` to appear among the + /// original's tuples. A correction tuple absent from the original ⇒ + /// [`DomainError::PolicyVersionViolation`]. + /// + /// # Errors + /// A sentinel [`DbError`] carrying [`DomainError::PolicyVersionViolation`] + /// when the correction carries a pinned evidence ref the original never had; + /// an infrastructure [`DbError`] on a storage / scope failure, or when the + /// referenced original has no lines (an invariant breach — a correction must + /// point at a real prior posting). + pub async fn check( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + entry: &crate::domain::model::NewEntry, + lines: &[NewLine], + ) -> Result<(), DbError> { + // A fresh original posting reuses nothing — fast exit. + let Some(original_entry_id) = entry.reverses_entry_id else { + return Ok(()); + }; + let tenant = entry.tenant_id; + + // The original's lines are keyed by the entry's `reverses_*` pointers. + // `reverses_period_id` pins the original's period; combined with + // `reverses_entry_id` + tenant it selects exactly the original's lines. + let mut cond = Condition::all() + .add(journal_line::Column::TenantId.eq(tenant)) + .add(journal_line::Column::EntryId.eq(original_entry_id)); + if let Some(original_period) = entry.reverses_period_id.clone() { + cond = cond.add(journal_line::Column::PeriodId.eq(original_period)); + } + + let original_rows = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter(cond) + .all(txn) + .await + .map_err(|e| infra(format!("policy-version read original lines: {e}")))?; + + if original_rows.is_empty() { + // A correction must point at a real prior posting; no lines means a + // dangling `reverses_entry_id` — an invariant breach, not a client + // rejection. + return Err(infra(format!( + "policy-version: original entry {original_entry_id} (tenant {tenant}) has no lines" + ))); + } + + let original_tuples: Vec = original_rows.iter().map(row_tuple).collect(); + let correction_tuples: Vec = lines.iter().map(line_tuple).collect(); + + if let Some(bad) = first_unreused_tuple(&original_tuples, &correction_tuples) { + return Err(business(DomainError::PolicyVersionViolation(format!( + "correction reuses no original evidence ref for tuple \ + (pricing_snapshot_ref={:?}, po_allocation_group={:?}, price_id={:?}, \ + sku_or_plan_ref={:?}, invoice_item_ref={:?}); a correction must reuse the \ + original posting's pinned evidence", + bad.0, bad.1, bad.2, bad.3, bad.4 + )))); + } + + Ok(()) + } +} + +#[cfg(test)] +#[path = "policy_version_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/policy_version_tests.rs b/gears/bss/ledger/ledger/src/infra/policy_version_tests.rs new file mode 100644 index 000000000..90bee184b --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/policy_version_tests.rs @@ -0,0 +1,98 @@ +//! Unit tests for the pure evidence-ref-reuse set-check +//! ([`first_unreused_tuple`]) backing [`PolicyVersionGuard`] (§4.6, AC #15). +//! The DB-touching `check` is exercised end-to-end in +//! `tests/postgres_policy_version.rs`. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +/// Build an evidence tuple from a single `pricing_snapshot_ref` (the most common +/// pinned ref); the other four refs are `None`. +fn snapshot(snapshot_ref: &str) -> EvidenceTuple { + (Some(snapshot_ref.to_owned()), None, None, None, None) +} + +/// The all-NULL tuple (a line with no pinned evidence). +fn empty() -> EvidenceTuple { + (None, None, None, None, None) +} + +#[test] +fn correction_reusing_original_refs_passes() { + let original = vec![snapshot("snap-A"), snapshot("snap-B")]; + // The correction reuses a subset of the original's refs. + let correction = vec![snapshot("snap-A")]; + assert_eq!( + first_unreused_tuple(&original, &correction), + None, + "a correction that reuses the original's refs must pass" + ); +} + +#[test] +fn correction_inventing_a_new_ref_is_flagged() { + let original = vec![snapshot("snap-A")]; + // The correction carries a ref the original never had. + let correction = vec![snapshot("snap-A"), snapshot("snap-NEW")]; + assert_eq!( + first_unreused_tuple(&original, &correction), + Some(snapshot("snap-NEW")), + "a correction that invents a new pinned ref must be flagged" + ); +} + +#[test] +fn all_null_tuples_are_ignored_on_both_sides() { + // A reversal's lines (built from a read-back that drops pinned refs) are all + // all-NULL — they must never trip the guard, even when the original carried + // refs. + let original = vec![snapshot("snap-A"), empty()]; + let correction = vec![empty(), empty()]; + assert_eq!( + first_unreused_tuple(&original, &correction), + None, + "all-NULL correction tuples carry no evidence and must be ignored" + ); +} + +#[test] +fn empty_correction_passes() { + let original = vec![snapshot("snap-A")]; + let correction: Vec = Vec::new(); + assert_eq!( + first_unreused_tuple(&original, &correction), + None, + "a correction with no lines reuses nothing and cannot violate" + ); +} + +#[test] +fn multi_field_tuple_must_match_exactly() { + // A tuple that differs in only the second field (po_allocation_group) is a + // distinct pinned-evidence tuple and must not be treated as reused. + let original = vec![( + Some("snap-A".to_owned()), + Some("po-1".to_owned()), + None, + None, + None, + )]; + let correction = vec![( + Some("snap-A".to_owned()), + Some("po-2".to_owned()), + None, + None, + None, + )]; + assert_eq!( + first_unreused_tuple(&original, &correction), + Some(( + Some("snap-A".to_owned()), + Some("po-2".to_owned()), + None, + None, + None, + )), + "a tuple differing in any field is a distinct pinned ref, not a reuse" + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/posting.rs b/gears/bss/ledger/ledger/src/infra/posting.rs new file mode 100644 index 000000000..76ad07eab --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting.rs @@ -0,0 +1,12 @@ +//! Transactional posting-engine components (DB-touching, so under `infra`, +//! never `domain` — dylint DE0301). Each runs inside one passed-in secure +//! transaction (`toolkit_db::secure::DbTx`): the idempotency gate, the +//! fiscal-period guard, and the balance projector. + +pub(crate) mod chain; +pub mod chart; +pub(crate) mod freeze; +pub mod idempotency; +pub mod period; +pub mod projector; +pub mod service; diff --git a/gears/bss/ledger/ledger/src/infra/posting/chain.rs b/gears/bss/ledger/ledger/src/infra/posting/chain.rs new file mode 100644 index 000000000..0385e92f6 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting/chain.rs @@ -0,0 +1,183 @@ +//! `ChainSealer` — the in-transaction tamper-evidence chain step. After the +//! journal header + lines are inserted (still NULL-sealed) and the balances +//! are projected, `seal` reads the tenant's chain tip, computes the entry's +//! `row_hash` over the canonical encoding linked to the tip's `prev_hash`, +//! writes the four chain columns onto the freshly inserted (not-yet-sealed) +//! header in one UPDATE, and advances the tip. It runs inside the caller's +//! posting transaction, so the seal commits atomically with the entry (or the +//! whole post rolls back). +//! +//! ## Append-only seal (migration 000007) +//! +//! The relaxed `journal_entry` trigger permits exactly ONE from-NULL UPDATE +//! that sets only `row_hash` / `prev_hash` / `prev_entry_id` / +//! `prev_period_id`; any other column change, a re-seal of an already-sealed +//! row, or a `DELETE` raises an `append-only` exception. This step is the only +//! writer of those columns; the [`crate::infra::storage::repo::JournalRepo`] +//! insert leaves them NULL. +//! +//! ## Concurrency +//! +//! `seal` reads then advances the tip via [`ChainStateRepo`], which takes no +//! row lock (see its module docs). Two concurrent seals onto the same tenant +//! tip overlap on the `chain_state` row they both read-then-write, so the +//! posting's `SERIALIZABLE` transaction makes Postgres SSI abort the loser +//! (which retries from a fresh tip) — yielding a single linear chain. +//! +//! **CONTRACT:** the tip read is lockless, so this +//! step is correct ONLY when it runs inside a **SERIALIZABLE** transaction — +//! SSI is what serializes two concurrent seals. The sole caller is +//! [`crate::infra::posting::service::PostingService::post`], which opens the txn +//! with `TxConfig::serializable()`. A future caller that advances the tip under +//! a weaker isolation level (e.g. `READ COMMITTED`) would let two seals read the +//! same tip and **fork the chain** (both link the same `prev_hash`), which the +//! daily Verifier then reports as a break → tenant-wide freeze. The design's +//! literal `FOR UPDATE` (§4.2/§7) is deferred until `SecureORM` exposes a locking +//! read; until then SERIALIZABLE is the load-bearing invariant. The +//! `concurrent_posts_form_linear_chain` integration test +//! (`tests/postgres_chain.rs`) locks this no-fork behavior in CI. + +use sea_orm::sea_query::Expr; +use sea_orm::{ColumnTrait, Condition, EntityTrait}; +use toolkit_db::DbError; +use toolkit_db::secure::{AccessScope, DbTx, ScopeError, SecureUpdateExt}; + +use crate::domain::chain::{chain_row_hash, genesis_prev_hash}; +use crate::domain::model::{EntryRef, NewEntry, NewLine}; +use crate::infra::posting::service::infra; +use crate::infra::storage::entity::journal_entry; +use crate::infra::storage::repo::{ChainStateRepo, TipRow}; + +/// Map a [`ScopeError`] to [`DbError`] **preserving the inner `sea_orm::DbErr` +/// variant** (mirrors `infra::audit::store::scope_to_db`). The seal UPDATE runs +/// inside the post's `SERIALIZABLE` txn, so a statement-time serialization +/// abort (SSI 40001) arrives as `ScopeError::Db(DbErr::Exec | DbErr::Query)`; +/// keeping that variant lets the `transaction_with_retry` contention classifier +/// retry the post from a fresh tip rather than burying it in a non-retryable +/// `DbErr::Custom` (the old `infra(format!(…))`) and 500-ing. +fn scope_to_db(e: ScopeError) -> DbError { + match e { + ScopeError::Db(db_err) => DbError::Sea(db_err), + other => DbError::Other(anyhow::anyhow!("chain seal scope: {other}")), + } +} + +/// Seals a posted entry into the per-tenant tamper-evidence hash chain. +/// Stateless — every method runs inside the caller's posting transaction +/// (`txn`), holding only a stateless [`ChainStateRepo`] (mirrors +/// [`crate::infra::posting::idempotency::IdempotencyGate`]). +#[derive(Clone, Default)] +pub struct ChainSealer { + chain_state: ChainStateRepo, +} + +impl ChainSealer { + #[must_use] + pub fn new() -> Self { + Self { + chain_state: ChainStateRepo::new(), + } + } + + /// Seal `entry` (already inserted with NULL chain columns) into the tenant's + /// chain inside `txn`: read the tip, compute `row_hash` linked to the tip's + /// `prev_hash` (or [`genesis_prev_hash`] at genesis), write the four chain + /// columns onto the header, then advance the tip. + /// + /// # Errors + /// [`DbError`] on any storage / scope failure (an infrastructure fault — the + /// seal carries no business rejection): a tip read/advance failure, a seal + /// UPDATE that the append-only trigger rejects, or a tip `row_hash` that is + /// not 32 bytes. The `?`/`Err` rolls the whole post back. + pub async fn seal( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + entry: &NewEntry, + lines: &[NewLine], + entry_ref: &EntryRef, + ) -> Result<(), DbError> { + let tenant = entry.tenant_id; + + // 1. Read the current tip (None at genesis). + let tip = self.chain_state.read_tip(txn, scope, tenant).await?; + + // 2. The prev_hash is the tip's row_hash, or the tenant genesis seed. + let prev_hash: [u8; 32] = match &tip { + Some(t) => t.last_row_hash[..] + .try_into() + .map_err(|_| infra("chain tip row_hash not 32 bytes"))?, + None => genesis_prev_hash(tenant), + }; + + // 3. Compute this entry's row_hash over the canonical encoding. + let row_hash = chain_row_hash(entry, lines, &prev_hash); + + // 4. Seal the four chain columns onto the freshly inserted header. The + // from-NULL, chain-columns-only UPDATE is the single mutation the + // relaxed append-only trigger permits; the prev pointers are NULL at + // genesis (no tip) and the tip's id/period otherwise. + // + // Hardened: the filter requires `row_hash IS NULL` so the + // seal can only ever fire on a not-yet-sealed row, and we assert exactly + // one row matched. In production the append-only trigger (migration + // 000007) already enforces from-NULL, but the SQLite test backend has no + // trigger — so without these guards a re-seal (0 rows) would silently + // advance the tip to a NULL-chained entry → a self-inflicted freeze on + // the next verify. Now a re-seal / missing row is a hard error here. + let sealed = journal_entry::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + journal_entry::Column::RowHash, + Expr::value(Some(row_hash.to_vec())), + ) + .col_expr( + journal_entry::Column::PrevHash, + Expr::value(Some(prev_hash.to_vec())), + ) + .col_expr( + journal_entry::Column::PrevEntryId, + Expr::value(tip.as_ref().map(|t| t.last_entry_id)), + ) + .col_expr( + journal_entry::Column::PrevPeriodId, + Expr::value(tip.as_ref().map(|t| t.last_period_id.clone())), + ) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant)) + .add(journal_entry::Column::PeriodId.eq(entry.period_id.clone())) + .add(journal_entry::Column::EntryId.eq(entry.entry_id)) + .add(journal_entry::Column::RowHash.is_null()), + ) + .exec(txn) + .await + .map_err(scope_to_db)?; + if sealed.rows_affected != 1 { + return Err(infra(format!( + "chain seal affected {} rows (expected exactly 1 from-NULL seal of \ + entry {} period {}); a 0-row result means the row is missing or \ + already sealed", + sealed.rows_affected, entry.entry_id, entry.period_id + ))); + } + + // 5. Advance the tip to this entry's sealed values. + self.chain_state + .advance( + txn, + scope, + tenant, + &TipRow { + last_row_hash: row_hash.to_vec(), + last_entry_id: entry.entry_id, + last_period_id: entry.period_id.clone(), + last_seq: entry_ref.created_seq, + }, + ) + .await?; + + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/posting/chart.rs b/gears/bss/ledger/ledger/src/infra/posting/chart.rs new file mode 100644 index 000000000..6752c04b0 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting/chart.rs @@ -0,0 +1,103 @@ +//! `ChartIndex` — a tenant's chart of accounts indexed by +//! `(account_class, currency, revenue_stream)` for fast `account_id` +//! resolution during posting. Shared by the invoice-post orchestrator and the +//! payment orchestrators (settlement / allocation), which all bind freshly +//! built lines (the pure builders emit a nil placeholder `account_id`) to the +//! provisioned chart before posting. +//! +//! Resolution is intentionally key-based (`resolve(class, currency, stream)`) +//! so non-`PostLine` callers (the payment builders) share the same lookup; a +//! thin `PostLine`-based adapter lives at the call site in `invoice_post.rs`. + +use std::collections::HashMap; + +use bss_ledger_sdk::AccountClass; +use toolkit_db::secure::AccessScope; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::infra::storage::repo::ReferenceRepo; + +/// `(account_class, currency, revenue_stream)` → `account_id` index over a +/// tenant's chart, the key the direct-split lines resolve on. +pub struct ChartIndex { + by_key: HashMap, +} + +impl ChartIndex { + /// Build the index from `(class, currency, stream)` → `account_id` pairs. + /// `class`/`currency` are the stored chart-row strings; `stream` is `None` + /// for the stream-less system classes. + #[must_use] + pub fn from_rows( + rows: impl IntoIterator, Uuid)>, + ) -> Self { + let mut by_key: HashMap = HashMap::new(); + for (account_class, currency, revenue_stream, account_id) in rows { + by_key.insert( + AccountKey { + account_class, + currency, + revenue_stream, + }, + account_id, + ); + } + Self { by_key } + } + + /// Resolve a chart `account_id` from a class + currency + optional stream. + /// Per-stream classes (`REVENUE` / `CONTRACT_LIABILITY`) key on `stream`; + /// the system parking / clearing classes (AR, TAX, SUSPENSE, CASH, + /// UNALLOCATED, …) resolve on `stream = None` regardless of any stream the + /// originating item carried — an unmapped item routed to SUSPENSE still + /// bears its source `revenue_stream`, but the SUSPENSE account is a single + /// stream-less parking row. + #[must_use] + pub fn resolve( + &self, + class: AccountClass, + currency: &str, + stream: Option<&str>, + ) -> Option { + let revenue_stream = if class.is_per_stream() { + stream.map(ToOwned::to_owned) + } else { + None + }; + let key = AccountKey { + account_class: class.as_str().to_owned(), + currency: currency.to_owned(), + revenue_stream, + }; + self.by_key.get(&key).copied() + } +} + +/// Chart lookup key. `account_class`/`currency` are stored strings (the chart +/// row form); `revenue_stream` mirrors the column (`None` for non-revenue rows). +#[derive(Clone, PartialEq, Eq, Hash)] +struct AccountKey { + account_class: String, + currency: String, + revenue_stream: Option, +} + +/// Load the tenant's chart of accounts into a `(class, currency, stream)` +/// lookup. SecureORM-scoped (BOLA): a foreign tenant yields an empty index. +/// +/// # Errors +/// [`DomainError::Internal`] on a storage / connection failure. +pub async fn load_chart( + reference: &ReferenceRepo, + scope: &AccessScope, + tenant: Uuid, +) -> Result { + let rows = reference + .all_accounts(scope, tenant) + .await + .map_err(|e| DomainError::Internal(format!("load chart: {e}")))?; + Ok(ChartIndex::from_rows(rows.into_iter().map(|r| { + (r.account_class, r.currency, r.revenue_stream, r.account_id) + }))) +} diff --git a/gears/bss/ledger/ledger/src/infra/posting/freeze.rs b/gears/bss/ledger/ledger/src/infra/posting/freeze.rs new file mode 100644 index 000000000..d0000deca --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting/freeze.rs @@ -0,0 +1,258 @@ +//! `ScopeFreezeRepo` + [`TamperFreezeGuard`] — the per-tenant tamper-freeze +//! switch over `bss.scope_freeze`. When the integrity verifier finds a broken +//! tamper-evidence chain it `set`s a freeze row; while that row is ACTIVE +//! (`cleared_at IS NULL`) [`TamperFreezeGuard::check`] rejects any fresh post +//! into the frozen scope with [`DomainError::TamperVerificationFailed`], so the +//! ledger STOPS accepting writes until an operator `clear`s the freeze. +//! +//! A freeze row's `period_id` is `'ALL'` for a tenant-wide freeze or a concrete +//! period to freeze just that period; a post into period `P` is blocked iff an +//! ACTIVE row exists for the tenant whose `period_id` is `'ALL'` OR equals `P`. +//! +//! Stateless — every method runs inside the caller's posting transaction +//! (`txn`), so it holds no `DBProvider` (mirrors +//! [`crate::infra::posting::idempotency::IdempotencyGate`] / +//! [`crate::infra::storage::repo::ChainStateRepo`]); tenant isolation runs +//! through the `SecureORM` layer (`.secure().scope_with(scope)` for reads, +//! `.scope_with_model(scope, &am)` for the freeze upsert — the validating +//! variant rejects a mismatched `(scope, tenant)`). + +use sea_orm::sea_query::{Expr, OnConflict}; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::DbError; +use toolkit_db::secure::{ + AccessScope, DbTx, ScopeError, SecureEntityExt, SecureInsertExt, SecureUpdateExt, +}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::infra::posting::service::business; +use crate::infra::storage::entity::scope_freeze; + +/// `period_id` sentinel for a tenant-wide freeze (every period in the tenant). +const PERIOD_ALL: &str = "ALL"; + +/// Map a [`ScopeError`] to [`DbError`] **preserving the inner `sea_orm::DbErr` +/// variant** (mirrors [`crate::infra::storage::repo::ChainStateRepo`]'s +/// `scope_to_db`). This is load-bearing for retry: [`TamperFreezeGuard::check`] +/// runs [`ScopeFreezeRepo::active_freeze`] inside the post's `SERIALIZABLE` +/// transaction, so a statement-time serialization failure (SSI 40001) on the +/// lockless freeze read surfaces as `ScopeError::Db(DbErr::Exec | DbErr::Query)`. +/// Keeping that variant lets the posting's `transaction_with_retry` contention +/// classifier recognise it and retry the post; stringifying it (the old +/// `RepoError::Db(format!(…))`) buried it in a `DbErr::Custom`, which the +/// classifier treats as the NON-retryable business sentinel — so a transient +/// abort on this read surfaced as a 500 instead of a retry. +fn scope_to_db(e: ScopeError) -> DbError { + match e { + ScopeError::Db(db_err) => DbError::Sea(db_err), + other => DbError::Other(anyhow::anyhow!("scope-freeze scope: {other}")), + } +} + +/// Scope-freeze repository. Stateless — every method runs inside the caller's +/// posting transaction (`txn`). +#[derive(Clone, Default)] +pub struct ScopeFreezeRepo; + +impl ScopeFreezeRepo { + #[must_use] + pub fn new() -> Self { + Self + } + + /// Return `true` iff an ACTIVE (`cleared_at IS NULL`) freeze row exists for + /// `tenant` whose `period_id` is `'ALL'` (tenant-wide) OR equals `period_id` + /// (that period). The read takes no row lock (mirrors + /// [`crate::infra::storage::repo::ChainStateRepo::read_tip`]); the posting's + /// `SERIALIZABLE` transaction is the concurrency backstop. + /// + /// # Errors + /// [`DbError`] on a storage / scope failure, with the inner `sea_orm::DbErr` + /// variant preserved (see [`scope_to_db`]) so a serialization abort on this + /// lockless read stays retryable on the post path. + pub async fn active_freeze( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + ) -> Result { + let row = scope_freeze::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(scope_freeze::Column::TenantId.eq(tenant)) + .add(scope_freeze::Column::ClearedAt.is_null()) + .add(scope_freeze::Column::PeriodId.is_in([PERIOD_ALL, period_id])), + ) + .one(txn) + .await + .map_err(scope_to_db)?; + + Ok(row.is_some()) + } + + /// Set (insert) a freeze row for `(tenant, scope_kind, period_id)` inside + /// `txn`: `INSERT … ON CONFLICT (tenant_id, scope, period_id) DO NOTHING` — + /// re-setting an existing freeze is a no-op (the original `frozen_at` / + /// `reason` / `set_by` stand). `cleared_by` / `cleared_at` are NULL (the row + /// is ACTIVE); `frozen_at` is `now()`. + /// + /// Returns `true` iff a row was newly inserted (the freeze actually + /// transitioned from absent to active), and `false` if an active freeze + /// already existed (the `ON CONFLICT DO NOTHING` no-op). The caller uses this + /// to write the §5.2 `freeze-set-clear` secured-audit record ONLY on a real + /// transition — so the daily Verifier re-freezing an already-frozen tenant + /// does not flood the audit chain with duplicate set records. + /// + /// # Errors + /// [`DbError`] on a storage / scope failure, with the inner `sea_orm::DbErr` + /// variant preserved (see [`scope_to_db`]). + #[allow( + clippy::too_many_arguments, + reason = "freeze identity (tenant/scope/period) + audit (reason/set_by) over the caller's txn/scope" + )] + pub async fn set( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + scope_kind: &str, + period_id: &str, + reason: &str, + set_by: &str, + ) -> Result { + let am = scope_freeze::ActiveModel { + tenant_id: Set(tenant), + scope: Set(scope_kind.to_owned()), + period_id: Set(period_id.to_owned()), + reason: Set(reason.to_owned()), + frozen_at: Set(chrono::Utc::now()), + set_by: Set(set_by.to_owned()), + cleared_by: Set(None), + cleared_at: Set(None), + }; + let on_conflict = OnConflict::columns([ + scope_freeze::Column::TenantId, + scope_freeze::Column::Scope, + scope_freeze::Column::PeriodId, + ]) + .do_nothing() + .to_owned(); + + match scope_freeze::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(scope_to_db)? + .on_conflict_raw(on_conflict) + .exec(txn) + .await + { + // A fresh insert — the freeze transitioned from absent to active. + Ok(_) => Ok(true), + // The freeze row already existed (the conflict swallowed the insert — + // re-setting an existing freeze is a no-op, no state transition). + Err(ScopeError::Db(sea_orm::DbErr::RecordNotInserted)) => Ok(false), + Err(e) => Err(scope_to_db(e)), + } + } + + /// Clear an ACTIVE freeze row for `(tenant, scope_kind, period_id)` inside + /// `txn`: `UPDATE … SET cleared_at = now(), cleared_by = $cleared_by` WHERE + /// the PK matches AND `cleared_at IS NULL`. A no-op if the row is absent or + /// already cleared (`rows_affected == 0`). + /// + /// # Errors + /// [`DbError`] on a storage / scope failure, with the inner `sea_orm::DbErr` + /// variant preserved (see [`scope_to_db`]). + #[allow( + dead_code, + reason = "manual/Audit freeze-clear path (wired in a later slice)" + )] + pub async fn clear( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + scope_kind: &str, + period_id: &str, + cleared_by: &str, + ) -> Result<(), DbError> { + scope_freeze::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + scope_freeze::Column::ClearedAt, + Expr::value(Some(chrono::Utc::now())), + ) + .col_expr( + scope_freeze::Column::ClearedBy, + Expr::value(Some(cleared_by.to_owned())), + ) + .filter( + Condition::all() + .add(scope_freeze::Column::TenantId.eq(tenant)) + .add(scope_freeze::Column::Scope.eq(scope_kind)) + .add(scope_freeze::Column::PeriodId.eq(period_id)) + .add(scope_freeze::Column::ClearedAt.is_null()), + ) + .exec(txn) + .await + .map_err(scope_to_db)?; + Ok(()) + } +} + +/// Posting guard that rejects a fresh post into a tamper-frozen scope. Runs as +/// a fail-fast step in [`crate::infra::posting::service::PostingService`]: a +/// fresh post (Claimed) into a frozen scope is rejected BEFORE any write; an +/// idempotent replay is unaffected (it returns earlier in the posting body). +/// +/// Stateless — holds only a stateless [`ScopeFreezeRepo`] (mirrors +/// [`crate::infra::posting::chain::ChainSealer`]). +#[derive(Clone, Default)] +pub struct TamperFreezeGuard { + freeze: ScopeFreezeRepo, +} + +impl TamperFreezeGuard { + #[must_use] + pub fn new() -> Self { + Self { + freeze: ScopeFreezeRepo::new(), + } + } + + /// Reject the post if an ACTIVE freeze covers `(tenant, period_id)`. + /// + /// Returns the sentinel-encoded [`DomainError::TamperVerificationFailed`] + /// (a `DbError` that forces rollback and is NON-retryable) when the scope is + /// frozen, so the post fails fast before any write; `Ok(())` otherwise. + /// + /// # Errors + /// A sentinel [`DbError`] carrying [`DomainError::TamperVerificationFailed`] + /// when the scope is frozen; an infrastructure [`DbError`] on a storage / + /// scope failure, with the inner `sea_orm::DbErr` variant preserved (see + /// [`scope_to_db`]) so a serialization abort on the freeze read stays + /// retryable on the post path. + pub async fn check( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + ) -> Result<(), DbError> { + let frozen = self + .freeze + .active_freeze(txn, scope, tenant, period_id) + .await?; + if frozen { + return Err(business(DomainError::TamperVerificationFailed(format!( + "scope frozen for tenant {tenant} period {period_id}" + )))); + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/posting/idempotency.rs b/gears/bss/ledger/ledger/src/infra/posting/idempotency.rs new file mode 100644 index 000000000..6a8c666da --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting/idempotency.rs @@ -0,0 +1,376 @@ +//! `IdempotencyGate` — the at-most-once posting gate over +//! `bss.ledger_idempotency_dedup`. `claim` performs an `INSERT … ON CONFLICT +//! (tenant_id, flow, business_id) DO NOTHING` inside the caller's +//! transaction, then reads the row back: a fresh insert yields +//! [`ClaimOutcome::Claimed`], a conflict yields [`ClaimOutcome::Replay`] +//! carrying the stored row (incl. `payload_hash`, so the caller can map a +//! differing hash to `IDEMPOTENCY_PAYLOAD_CONFLICT`). `finalize` stamps the +//! result entry + sequence and flips the status to `POSTED` before COMMIT, +//! so a concurrent replay reads a complete reference. +//! +//! `payload_hash` uses the FIPS-validated `aws-lc-rs` SHA-256 — the same +//! crypto provider the platform installs at bootstrap — so no non-FIPS +//! hasher enters the graph (DE0708 clean). + +use aws_lc_rs::digest::{SHA256, digest as sha256}; +use sea_orm::sea_query::{Expr, OnConflict}; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, DbErr, EntityTrait}; +use toolkit_db::secure::{ + AccessScope, DbTx, ScopeError, SecureEntityExt, SecureInsertExt, SecureUpdateExt, +}; +use uuid::Uuid; + +use crate::domain::model::{NewEntry, NewLine, RepoError}; +use crate::infra::storage::entity::idempotency_dedup; + +/// Status literal written when a posting first claims a key. +const STATUS_CLAIMED: &str = "CLAIMED"; +/// Status literal written once the posting is durably recorded. +pub(crate) const STATUS_POSTED: &str = "POSTED"; +/// Idempotency-dedup status for a request whose effect was durably enqueued +/// onto `ledger_pending_event_queue` for a later (separate-transaction) apply, +/// rather than posted inline. The dedup column is `text` with no CHECK, so this +/// literal needs no migration. Consumed by `claim_queued` (this module) and the +/// queue repo's intake insert. +pub(crate) const STATUS_QUEUED: &str = "QUEUED"; + +/// The stored dedup row, returned on a replay so the caller can compare the +/// recorded `payload_hash` and read the prior posting reference. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PostingRefRow { + pub payload_hash: String, + pub result_entry_id: Option, + pub status: String, +} + +/// Result of a [`IdempotencyGate::claim`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ClaimOutcome { + /// This call won the race and now owns the posting. + Claimed, + /// The key was already present; the caller must compare `payload_hash` + /// and, if it matches, return the prior reference as a replay. + Replay(PostingRefRow), +} + +/// At-most-once posting gate over `idempotency_dedup`. +#[derive(Clone, Default)] +pub struct IdempotencyGate; + +impl IdempotencyGate { + #[must_use] + pub fn new() -> Self { + Self + } + + /// Claim `(tenant, flow, business_id)` inside `txn` for an **inline post** + /// (seed status `CLAIMED`) via an `INSERT … ON CONFLICT DO NOTHING`, then + /// read the row back. Delegates to [`Self::claim_with_status`]. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure, or [`RepoError::RowVanished`] + /// if the dedup row cannot be read back inside the same transaction. + pub async fn claim( + &self, + txn: &DbTx<'_>, + tenant: Uuid, + flow: &str, + business_id: &str, + payload_hash: &str, + ) -> Result { + self.claim_with_status(txn, tenant, flow, business_id, payload_hash, STATUS_CLAIMED) + .await + } + + /// Claim `(tenant, flow, business_id)` inside `txn` for a **deferred-apply + /// enqueue** (seed status `QUEUED`) — the work is durably queued onto + /// `ledger_pending_event_queue` for a later, separate-transaction apply + /// rather than posted inline. Same `INSERT … ON CONFLICT DO NOTHING` + + /// read-back as [`Self::claim`]; only the seeded status differs. On a + /// replay the returned [`PostingRefRow::status`] lets the caller tell a + /// still-`QUEUED` intake from a `CLAIMED` (in-flight inline) or `POSTED` + /// (finalized) one. Delegates to [`Self::claim_with_status`]. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure, or [`RepoError::RowVanished`] + /// if the dedup row cannot be read back inside the same transaction. + pub async fn claim_queued( + &self, + txn: &DbTx<'_>, + tenant: Uuid, + flow: &str, + business_id: &str, + payload_hash: &str, + ) -> Result { + self.claim_with_status(txn, tenant, flow, business_id, payload_hash, STATUS_QUEUED) + .await + } + + /// Shared body for [`Self::claim`] / [`Self::claim_queued`]: an + /// `INSERT … ON CONFLICT (tenant_id, flow, business_id) DO NOTHING` inside + /// `txn` seeding `status = seed_status`, then a read-back. A fresh insert + /// yields [`ClaimOutcome::Claimed`]; a conflict yields + /// [`ClaimOutcome::Replay`] carrying the stored row (the caller compares + /// `payload_hash` and reads `status` / `result_entry_id`). + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure, or [`RepoError::RowVanished`] + /// if the dedup row cannot be read back inside the same transaction. + async fn claim_with_status( + &self, + txn: &DbTx<'_>, + tenant: Uuid, + flow: &str, + business_id: &str, + payload_hash: &str, + seed_status: &str, + ) -> Result { + let scope = AccessScope::for_tenant(tenant); + + let am = idempotency_dedup::ActiveModel { + tenant_id: Set(tenant), + flow: Set(flow.to_owned()), + business_id: Set(business_id.to_owned()), + payload_hash: Set(payload_hash.to_owned()), + result_entry_id: Set(None), + posted_at_utc: Set(None), + status: Set(seed_status.to_owned()), + retain_until: Set(None), + }; + let on_conflict = OnConflict::columns([ + idempotency_dedup::Column::TenantId, + idempotency_dedup::Column::Flow, + idempotency_dedup::Column::BusinessId, + ]) + .do_nothing() + .to_owned(); + + let inserted = match idempotency_dedup::Entity::insert(am.clone()) + .secure() + .scope_with_model(&scope, &am) + .map_err(|e| RepoError::Db(format!("idempotency claim scope: {e}")))? + .on_conflict_raw(on_conflict) + .exec(txn) + .await + { + Ok(_) => true, + // The key already existed; the conflict swallowed the insert. + Err(ScopeError::Db(DbErr::RecordNotInserted)) => false, + Err(e) => return Err(RepoError::Db(format!("idempotency claim: {e}"))), + }; + + if inserted { + return Ok(ClaimOutcome::Claimed); + } + + let row = idempotency_dedup::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(idempotency_dedup::Column::TenantId.eq(tenant)) + .add(idempotency_dedup::Column::Flow.eq(flow)) + .add(idempotency_dedup::Column::BusinessId.eq(business_id)), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("idempotency read-back: {e}")))? + .ok_or_else(|| { + RepoError::RowVanished(format!("idempotency_dedup {tenant}/{flow}/{business_id}")) + })?; + + Ok(ClaimOutcome::Replay(PostingRefRow { + payload_hash: row.payload_hash, + result_entry_id: row.result_entry_id, + status: row.status, + })) + } + + /// In-transaction scoped read of the dedup row for `(tenant, flow, + /// business_id)`, or `None` when absent — the read-back half of + /// [`Self::claim_with_status`] WITHOUT the preceding insert. The + /// queued-apply post path ([`crate::infra::posting::service::PostingService`] + /// `ClaimMode::QueuedApply`) calls this instead of `claim`: the dedup row was + /// already claimed `QUEUED` at intake, so a re-claim here would either be a + /// no-op (the `INSERT … ON CONFLICT DO NOTHING` collides) and waste a round + /// trip, or — worse — mis-read the row as a fresh `Claimed`. Reading lets the + /// engine distinguish a still-`QUEUED` row (proceed to post + finalize) from + /// an already-`POSTED` one (idempotent replay) inside the same serializable + /// transaction that will finalize it. Scoped (`.secure().scope_with`) for + /// SQL-level BOLA, exactly like the `claim` read-back. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read( + &self, + txn: &DbTx<'_>, + tenant: Uuid, + flow: &str, + business_id: &str, + ) -> Result, RepoError> { + let scope = AccessScope::for_tenant(tenant); + let row = idempotency_dedup::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(idempotency_dedup::Column::TenantId.eq(tenant)) + .add(idempotency_dedup::Column::Flow.eq(flow)) + .add(idempotency_dedup::Column::BusinessId.eq(business_id)), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("idempotency read: {e}")))?; + Ok(row.map(|row| PostingRefRow { + payload_hash: row.payload_hash, + result_entry_id: row.result_entry_id, + status: row.status, + })) + } + + /// Stamp the result entry id + posted timestamp and flip the status to + /// `POSTED` before COMMIT, so a concurrent replay reads a complete + /// reference. `created_seq` is the journal header's monotonic sequence + /// (owned by `journal_entry`); it is threaded through for the public + /// contract and asserted positive — the dedup row keys the posting by + /// `result_entry_id`, not by the sequence. + /// + /// # Panics + /// In debug builds if `created_seq` is not positive (the header must be + /// sequenced before finalize). + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure, or [`RepoError::RowVanished`] + /// if the claimed dedup row could not be updated inside the transaction. + pub async fn finalize( + &self, + txn: &DbTx<'_>, + tenant: Uuid, + flow: &str, + business_id: &str, + entry_id: Uuid, + created_seq: i64, + ) -> Result<(), RepoError> { + debug_assert!(created_seq > 0, "finalize before the header was sequenced"); + let scope = AccessScope::for_tenant(tenant); + + let result = idempotency_dedup::Entity::update_many() + .secure() + .scope_with(&scope) + .col_expr( + idempotency_dedup::Column::ResultEntryId, + Expr::value(Some(entry_id)), + ) + .col_expr( + idempotency_dedup::Column::PostedAtUtc, + Expr::value(Some(chrono::Utc::now())), + ) + .col_expr( + idempotency_dedup::Column::Status, + Expr::value(STATUS_POSTED.to_owned()), + ) + .filter( + Condition::all() + .add(idempotency_dedup::Column::TenantId.eq(tenant)) + .add(idempotency_dedup::Column::Flow.eq(flow)) + .add(idempotency_dedup::Column::BusinessId.eq(business_id)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("idempotency finalize: {e}")))?; + + if result.rows_affected == 0 { + return Err(RepoError::RowVanished(format!( + "idempotency_dedup {tenant}/{flow}/{business_id}" + ))); + } + Ok(()) + } + + /// FIPS-validated (`aws-lc-rs`) SHA-256 hex digest over the canonical + /// financial content of an entry — the business key, effective date, + /// period, and every per-line financial dimension (amount + scale, + /// functional amount/currency, currency, side, payer/seller, invoice, + /// due date, revenue stream, tax dims, credit-grant event type, AR dispute + /// status), sorted for order-independence. + /// Transport/envelope fields (correlation id, actor, posted-at, internal + /// ids) are excluded so the same financial intent always hashes + /// identically; a change in any financial dimension flips the hash so a + /// conflicting reuse of the business key is caught, not swallowed. + #[must_use] + pub fn payload_hash(entry: &NewEntry, lines: &[NewLine]) -> String { + let mut canon = String::new(); + canon.push_str(entry.source_doc_type.as_str()); + canon.push('\u{1f}'); + canon.push_str(&entry.source_business_id); + canon.push('\u{1f}'); + canon.push_str(&entry.entry_currency); + canon.push('\u{1f}'); + canon.push_str(&entry.effective_at.to_string()); + canon.push('\u{1f}'); + canon.push_str(&entry.period_id); + canon.push('\u{1e}'); + + let mut line_keys: Vec = lines + .iter() + .map(|l| { + [ + l.account_id.to_string(), + l.account_class.as_str().to_owned(), + l.amount_minor.to_string(), + l.currency.clone(), + l.currency_scale.to_string(), + l.side.as_str().to_owned(), + l.payer_tenant_id.to_string(), + l.seller_tenant_id + .map(|u| u.to_string()) + .unwrap_or_default(), + l.invoice_id.clone().unwrap_or_default(), + l.due_date.map(|d| d.to_string()).unwrap_or_default(), + l.revenue_stream.clone().unwrap_or_default(), + l.functional_amount_minor + .map(|a| a.to_string()) + .unwrap_or_default(), + l.functional_currency.clone().unwrap_or_default(), + l.tax_jurisdiction.clone().unwrap_or_default(), + l.tax_filing_period.clone().unwrap_or_default(), + // As-posted financial dimensions: the wallet sub-grain bucket + // (REUSABLE_CREDIT lines) and the AR dispute sub-class + // (chargeback reclass). A business-key reuse differing only in + // these is a conflict, not a replay — include them so the hash + // flips and `IDEMPOTENCY_PAYLOAD_CONFLICT` fires. + l.credit_grant_event_type.clone().unwrap_or_default(), + l.ar_status.clone().unwrap_or_default(), + ] + .join("\u{1f}") + }) + .collect(); + line_keys.sort(); + canon.push_str(&line_keys.join("\u{1e}")); + + Self::content_hash(&canon) + } + + /// FIPS-validated (`aws-lc-rs`) SHA-256 hex digest over an arbitrary + /// already-canonical string — the same crypto provider [`Self::payload_hash`] + /// runs, factored out so a caller that hashes a *request* payload (not an + /// entry) can reuse it. A queued allocation hashes its request payload here: + /// the per-invoice split is only decided at apply (Group D), so the entry — + /// and thus `payload_hash`'s entry-based signature — does not yet exist at + /// intake. The caller is responsible for canonicalizing `canonical` stably + /// (e.g. a fixed field order) so the same intent always hashes identically. + #[must_use] + pub(crate) fn content_hash(canonical: &str) -> String { + let digest = sha256(&SHA256, canonical.as_bytes()); + let mut hex = String::with_capacity(64); + for byte in digest.as_ref() { + use std::fmt::Write as _; + let _ = write!(hex, "{byte:02x}"); + } + hex + } +} + +#[cfg(test)] +#[path = "idempotency_tests.rs"] +mod idempotency_tests; diff --git a/gears/bss/ledger/ledger/src/infra/posting/idempotency_tests.rs b/gears/bss/ledger/ledger/src/infra/posting/idempotency_tests.rs new file mode 100644 index 000000000..b15b080a4 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting/idempotency_tests.rs @@ -0,0 +1,130 @@ +//! Tests for [`super::IdempotencyGate::payload_hash`] — the canonical financial- +//! content hash MUST flip on any per-line financial dimension, so a business-key +//! reuse with a differing payload surfaces as a conflict, not a silent replay. + +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use serde_json::json; +use uuid::Uuid; + +use super::*; + +fn entry() -> NewEntry { + NewEntry { + entry_id: Uuid::from_u128(1), + tenant_id: Uuid::from_u128(2), + legal_entity_id: Uuid::from_u128(3), + period_id: "2026-06".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "biz-1".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: Uuid::from_u128(4), + correlation_id: Uuid::from_u128(5), + rounding_evidence: json!({}), + rate_snapshot_ref: None, + } +} + +fn line() -> NewLine { + NewLine { + line_id: Uuid::from_u128(10), + payer_tenant_id: Uuid::from_u128(2), + seller_tenant_id: None, + resource_tenant_id: None, + account_id: Uuid::from_u128(20), + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: 0, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: Some(100), + functional_currency: Some("USD".to_owned()), + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// A functional-only line (`amount_minor == 0`) differing only in its +/// `functional_amount_minor` must change the hash — otherwise a corrected +/// re-post is silently swallowed as a replay instead of flagged as an +/// `IDEMPOTENCY_PAYLOAD_CONFLICT`. +#[test] +fn payload_hash_distinguishes_functional_amount() { + let e = entry(); + let base = line(); + let mut other = base.clone(); + other.functional_amount_minor = Some(200); + assert_ne!( + IdempotencyGate::payload_hash(&e, std::slice::from_ref(&base)), + IdempotencyGate::payload_hash(&e, std::slice::from_ref(&other)), + "differing functional_amount_minor must change the payload hash" + ); +} + +/// A differing per-line `revenue_stream` must change the hash. +#[test] +fn payload_hash_distinguishes_revenue_stream() { + let e = entry(); + let mut a = line(); + a.amount_minor = 100; + let mut b = a.clone(); + a.revenue_stream = Some("stream-a".to_owned()); + b.revenue_stream = Some("stream-b".to_owned()); + assert_ne!( + IdempotencyGate::payload_hash(&e, std::slice::from_ref(&a)), + IdempotencyGate::payload_hash(&e, std::slice::from_ref(&b)), + "differing revenue_stream must change the payload hash" + ); +} + +/// A differing per-line `credit_grant_event_type` (the wallet sub-grain +/// bucket) must change the hash — else a business-key reuse differing only in +/// the bucket is a silent replay instead of an `IDEMPOTENCY_PAYLOAD_CONFLICT`. +#[test] +fn payload_hash_distinguishes_credit_grant_event_type() { + let e = entry(); + let mut a = line(); + let mut b = a.clone(); + a.credit_grant_event_type = Some("promo".to_owned()); + b.credit_grant_event_type = Some("referral".to_owned()); + assert_ne!( + IdempotencyGate::payload_hash(&e, std::slice::from_ref(&a)), + IdempotencyGate::payload_hash(&e, std::slice::from_ref(&b)), + "differing credit_grant_event_type must change the payload hash" + ); +} + +/// A differing per-line `ar_status` (the dispute reclass sub-class) must +/// change the hash. +#[test] +fn payload_hash_distinguishes_ar_status() { + let e = entry(); + let mut a = line(); + let mut b = a.clone(); + a.ar_status = Some("ACTIVE".to_owned()); + b.ar_status = Some("DISPUTED".to_owned()); + assert_ne!( + IdempotencyGate::payload_hash(&e, std::slice::from_ref(&a)), + IdempotencyGate::payload_hash(&e, std::slice::from_ref(&b)), + "differing ar_status must change the payload hash" + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/posting/period.rs b/gears/bss/ledger/ledger/src/infra/posting/period.rs new file mode 100644 index 000000000..dd39e980f --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting/period.rs @@ -0,0 +1,169 @@ +//! `FiscalPeriodGuard` — reads the target `(tenant, legal_entity, period)` +//! row inside the posting transaction and asserts it is `OPEN`; a missing or +//! non-`OPEN` period is [`PeriodError::Closed`]. +//! +//! Concurrent close vs post is guarded by `SERIALIZABLE` isolation, not a row +//! lock (`SecureORM` exposes none, and gears can't issue raw `FOR SHARE`). Both +//! [`crate::infra::posting::service::PostingService::post`] and +//! [`crate::infra::period_close::PeriodCloseService::close`] run under a +//! `SERIALIZABLE` transaction with retry: close's pre-close tie-out reads the +//! journal lines a concurrent post writes, so Postgres SSI detects the +//! overlap and aborts the loser, which retries. A close therefore can't certify +//! a period an in-flight entry is landing in — and it's distributed (SSI is +//! DB-enforced across replicas), the same shape AM uses for its workers. + +use chrono::{DateTime, Duration, Utc}; +use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter}; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt}; +use uuid::Uuid; + +use crate::domain::status::PERIOD_STATUS_OPEN; +use crate::infra::storage::entity::fiscal_period; + +/// Warn band for clock skew between a post's `posted_at_utc` and the server +/// wall clock (design §3.2 `FiscalPeriodGuard`): skew beyond ±15 min raises a +/// `CLOCK_SKEW` Warn alarm but still posts. +const CLOCK_SKEW_WARN_MINUTES: i64 = 15; +/// Reject band (design §3.2): skew beyond ±24 h is quarantined +/// (`CLOCK_SKEW_QUARANTINE`) and must re-submit via the material-backdating +/// exception path. +const CLOCK_SKEW_REJECT_HOURS: i64 = 24; + +/// Outcome of the clock-skew gate: how far a post's `posted_at_utc` is from the +/// server wall clock (design §3.2). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ClockSkewVerdict { + /// Within ±15 min — post normally. + Ok, + /// Beyond ±15 min but within ±24 h — post, but raise a `CLOCK_SKEW` Warn + /// alarm (no rollback). + Warn, + /// Beyond ±24 h — reject with `CLOCK_SKEW_QUARANTINE`. + Reject, +} + +/// Classify the clock skew between a post's `posted_at_utc` and `now` (design +/// §3.2): `> ±24 h` rejects, `> ±15 min` warns, otherwise OK. Pure and +/// symmetric (a future- or past-skewed clock is treated identically), so it is +/// unit-tested without a clock. +#[must_use] +pub fn classify_clock_skew(posted_at_utc: DateTime, now: DateTime) -> ClockSkewVerdict { + let skew = (now - posted_at_utc).abs(); + if skew > Duration::hours(CLOCK_SKEW_REJECT_HOURS) { + ClockSkewVerdict::Reject + } else if skew > Duration::minutes(CLOCK_SKEW_WARN_MINUTES) { + ClockSkewVerdict::Warn + } else { + ClockSkewVerdict::Ok + } +} + +/// Period-gate outcome error. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PeriodError { + /// The period is missing or not `OPEN` — posting is refused. + #[error("fiscal period is closed or absent")] + Closed, + /// Underlying storage failure. + #[error("fiscal period guard db error: {0}")] + Db(String), +} + +/// Guards posting against a closed/absent fiscal period. +#[derive(Clone, Default)] +pub struct FiscalPeriodGuard; + +impl FiscalPeriodGuard { + #[must_use] + pub fn new() -> Self { + Self + } + + /// Read the `(tenant, legal_entity, period_id)` row and assert it is + /// `OPEN`. + /// + /// # Errors + /// [`PeriodError::Closed`] if the row is absent or its status is not + /// `OPEN`; [`PeriodError::Db`] on a storage failure. + pub async fn pin_open( + &self, + txn: &DbTx<'_>, + tenant: Uuid, + legal_entity: Uuid, + period_id: &str, + ) -> Result<(), PeriodError> { + let scope = AccessScope::for_tenant(tenant); + + let row = fiscal_period::Entity::find() + .filter( + Condition::all() + .add(fiscal_period::Column::TenantId.eq(tenant)) + .add(fiscal_period::Column::LegalEntityId.eq(legal_entity)) + .add(fiscal_period::Column::PeriodId.eq(period_id)), + ) + .secure() + .scope_with(&scope) + .one(txn) + .await + .map_err(|e| PeriodError::Db(format!("pin_open: {e}")))?; + + match row { + Some(p) if p.status == PERIOD_STATUS_OPEN => Ok(()), + _ => Err(PeriodError::Closed), + } + } +} + +#[cfg(test)] +mod tests { + use chrono::{Duration, Utc}; + + use super::{ClockSkewVerdict, classify_clock_skew}; + + #[test] + fn within_fifteen_minutes_is_ok() { + let now = Utc::now(); + assert_eq!(classify_clock_skew(now, now), ClockSkewVerdict::Ok); + assert_eq!( + classify_clock_skew(now - Duration::minutes(14), now), + ClockSkewVerdict::Ok + ); + // Boundary: exactly 15 min is still OK (strictly-greater warns). + assert_eq!( + classify_clock_skew(now - Duration::minutes(15), now), + ClockSkewVerdict::Ok + ); + } + + #[test] + fn beyond_fifteen_minutes_warns_either_direction() { + let now = Utc::now(); + assert_eq!( + classify_clock_skew(now - Duration::minutes(16), now), + ClockSkewVerdict::Warn, + ); + // A future-skewed clock is treated identically to a past-skewed one. + assert_eq!( + classify_clock_skew(now + Duration::hours(3), now), + ClockSkewVerdict::Warn, + ); + // Boundary: exactly 24 h is still Warn (strictly-greater rejects). + assert_eq!( + classify_clock_skew(now - Duration::hours(24), now), + ClockSkewVerdict::Warn, + ); + } + + #[test] + fn beyond_twentyfour_hours_rejects_either_direction() { + let now = Utc::now(); + assert_eq!( + classify_clock_skew(now - Duration::hours(25), now), + ClockSkewVerdict::Reject, + ); + assert_eq!( + classify_clock_skew(now + Duration::hours(25), now), + ClockSkewVerdict::Reject, + ); + } +} diff --git a/gears/bss/ledger/ledger/src/infra/posting/projector.rs b/gears/bss/ledger/ledger/src/infra/posting/projector.rs new file mode 100644 index 000000000..70f82e75e --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting/projector.rs @@ -0,0 +1,1167 @@ +//! `BalanceProjector` — derives per-grain signed deltas from the posted +//! lines and upserts the derived balance caches inside the posting +//! transaction, in a fixed lock-key order (deadlock-freedom), re-asserting +//! the no-negative invariant on the guarded account classes (the DB +//! conditional CHECK from P1 is the backstop). +//! +//! Grains: +//! - `account_balance` `(tenant, account, currency)` — every line; +//! - `ar_payer_balance` `(tenant, payer, account, currency)` — `AR` lines; +//! - `ar_invoice_balance` `(tenant, payer, account, invoice)` — `AR` lines +//! carrying an `invoice_id`; +//! - `reusable_credit_subbalance` +//! `(tenant, payer, account, currency, credit_grant_event_type)` — +//! `REUSABLE_CREDIT` lines (the wallet sub-grain); +//! - `tax_subbalance` `(tenant, account, jurisdiction, filing)` — +//! `TAX_PAYABLE` lines carrying both tax dims. +//! +//! A line's signed delta is `+amount` when its side equals the account's +//! normal side, else `-amount`. + +use std::collections::HashMap; + +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::{DateTime, NaiveDate, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait, QueryFilter}; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt, SecureInsertExt, SecureOnConflict}; +use uuid::Uuid; + +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::status::AR_STATUS_DISPUTED; +use crate::infra::storage::entity::{ + account_balance, ar_invoice_balance, ar_payer_balance, reusable_credit_subbalance, + tax_subbalance, unallocated_balance, +}; + +/// Projection error. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ProjectError { + /// A guarded balance would go negative after applying a delta. + #[error("balance for account {account_id} would go negative ({balance_minor})")] + NegativeBalance { + account_id: Uuid, + balance_minor: i64, + }, + /// An account's `normal_side` was not supplied in the lookup map. + #[error("missing normal_side for account {0}")] + MissingNormalSide(Uuid), + /// A `REUSABLE_CREDIT` line reached projection without its wallet sub-grain + /// bucket (`credit_grant_event_type`). The domain builders always set it, so + /// this is an invariant breach — projecting it would key a phantom "" sub- + /// balance, which the DB NOT-NULL CHECK does not catch (it tests NULL, not ""). + #[error("REUSABLE_CREDIT line {0} missing credit_grant_event_type")] + MissingCreditEventType(Uuid), + /// Underlying storage failure. + #[error("balance projector db error: {0}")] + Db(String), + /// A coalesced money delta exceeded `i64` while summing an entry's same-grain + /// legs — the one money path that previously saturated. Surfaced as a clean + /// amount-class rejection rather than a silently-wrong saturated balance. + #[error("coalesced money delta overflowed i64 for account {account_id} ({currency}, {field})")] + Overflow { + account_id: Uuid, + currency: String, + field: &'static str, + }, +} + +/// One derived cache mutation, keyed by its grain. `table_rank` orders the +/// four cache tables; the remaining key parts order rows within a table so +/// concurrent posts acquire row locks in a single global order. +#[derive(Clone, Debug, PartialEq, Eq)] +struct GrainDelta { + table_rank: GrainTable, + tenant_id: Uuid, + account_id: Uuid, + currency: String, + payer_tenant_id: Uuid, + invoice_id: String, + tax_jurisdiction: String, + tax_filing_period: String, + account_class: AccountClass, + normal_side: Side, + delta: i64, + /// FX functional-column delta (Slice 5): the signed `functional_amount_minor` + /// summed in parallel with `delta`, projected onto `functional_balance_minor`. + /// `0` with `functional_currency = None` on single-currency grains, where the + /// functional cache column stays NULL (functional ≡ transaction by identity). + functional_delta: i64, + /// The grain's functional (legal-entity reporting) currency; `Some` only on + /// cross-currency grains. Drives `functional_currency` on the cache row. + functional_currency: Option, + /// AR-invoice grain only (chargeback `ar_status` seam): the signed disputed + /// sub-delta routed to `ar_invoice_balance.disputed_minor`, parallel to + /// `delta` (which nets `balance_minor`). It is the line's signed amount + /// (`delta`) when the AR line carries `ar_status = DISPUTED`, else `0`. A + /// balanced reclass (`DR AR DISPUTED` + `CR AR ACTIVE`, same grain) thus nets + /// ZERO on `balance_minor` (AR-class-neutral) while moving `+amount` onto + /// `disputed_minor`; a `won` reversal (`DR AR ACTIVE` + `CR AR DISPUTED`) + /// nets `-amount`. Every non-AR-invoice grain carries `0`. + disputed_delta: i64, + /// AR-invoice grain only (decision P): the entry's posted-at and the line's + /// due date, stamped first-write-wins onto `ar_invoice_balance` so the + /// oldest-first allocation precedence has a stable post date. Other grains + /// carry the defaults (`posted_at` is unused, `due_date` is `None`). + posted_at: DateTime, + due_date: Option, + /// Reusable-credit grain only: the credit-grant event type that sub-divides + /// the wallet balance (a PK dim), and the entry's posted-at stamped + /// first-write-wins onto `reusable_credit_subbalance.first_granted_at` as a + /// recency marker. Other grains carry the defaults (empty event type, + /// `first_granted_at` is `None`). + credit_grant_event_type: String, + first_granted_at: Option>, +} + +/// The canonical lock-order sort key for a [`GrainDelta`]: `(table_rank, tenant, +/// account, currency, payer, invoice, tax_juris, tax_filing, credit_grant_event_type)`. +/// Borrows the row's string dims, so it carries the delta's lifetime. +type GrainSortKey<'a> = ( + GrainTable, + Uuid, + Uuid, + &'a str, + Uuid, + &'a str, + &'a str, + &'a str, + &'a str, +); + +impl GrainDelta { + /// The canonical ordering key: `(table_rank, tenant, account, currency, + /// payer, invoice)` — extended with the tax dims and the credit-grant event + /// type for total order. + fn sort_key(&self) -> GrainSortKey<'_> { + ( + self.table_rank, + self.tenant_id, + self.account_id, + &self.currency, + self.payer_tenant_id, + &self.invoice_id, + &self.tax_jurisdiction, + &self.tax_filing_period, + &self.credit_grant_event_type, + ) + } +} + +/// The cache table a [`GrainDelta`] targets, in canonical **lock order**: +/// `derive(Ord)` ranks by declaration order, so concurrent posts acquire row +/// locks in one global order (design §4.3 / §7) and the `match` in `project` is +/// exhaustive by construction — a new grain kind cannot be added without also +/// adding its upsert arm (the compiler enforces it; there is no wildcard arm). +/// +/// The recognition tables sit just below the balance caches: a recognition post +/// (Slice 4) locks the `CONTRACT_LIABILITY` + `REVENUE` `account_balance` rows +/// first, then the schedule, then the segment, by `(tenant_id, schedule_id, +/// segment_no)`. Those two variants (and their `match` arms) are added here, +/// after `Tax`, when the `RecognitionRunner` starts projecting recognition grains. +/// +/// **Canonical procedural lock order (design §4.7) — the full chain a Slice-3 +/// adjustment handler observes, of which only the balance-cache ranks below are +/// `GrainTable` variants:** +/// `payment_settlement → account_balance → ar_invoice_balance → ar_payer_balance +/// → unallocated_balance → reusable_credit_subbalance → tax_subbalance → +/// recognition_schedule → recognition_segment → invoice_exposure → +/// payment_allocation_refund`, then by `(tenant_id, …key…)`. +/// +/// The tail four — `recognition_schedule`/`recognition_segment` (Slice 4) and +/// `invoice_exposure`/`payment_allocation_refund` (Slice 3) — are NOT +/// `BalanceProjector` balance grains: they are single-row counter/stamp grains +/// touched by an in-place delta in the respective handler (e.g. +/// `CreditNoteHandler` bumps `invoice_exposure.credit_note_total_minor` then +/// `payment_allocation_refund.refunded_minor`), so they carry no `GrainTable` +/// rank and the projector ranks stay balance-only (`grain_lock_order_ranks_are_pinned` +/// pins exactly the balance set). The cross-table order among these tail tables +/// is enforced PROCEDURALLY by each handler's acquisition order — the same +/// discipline recognition uses (m11 docstring), extended with the two Slice-3 +/// ranks appended last so there is no inversion vs Slices 1/2/4. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum GrainTable { + Account, + ArPayer, + ArInvoice, + Unallocated, + ReusableCredit, + Tax, +} + +/// Projects posted lines into the derived balance caches. +#[derive(Clone, Default)] +pub struct BalanceProjector; + +impl BalanceProjector { + #[must_use] + pub fn new() -> Self { + Self + } + + /// Derive per-grain signed deltas, sort them into the canonical lock + /// order, and upsert each cache row, re-asserting no-negative on the + /// guarded classes. `created_seq` stamps `last_entry_seq` on every + /// touched row. + /// + /// # Errors + /// [`ProjectError::MissingNormalSide`] if a line's account is absent from + /// `normal_sides`; [`ProjectError::NegativeBalance`] if a guarded balance + /// would go negative; [`ProjectError::Db`] on a storage failure. + /// + /// # Panics + /// Never in practice — the internal `unreachable!` guards against a grain + /// carrying an unknown table rank, which the derivation cannot produce. + pub async fn project( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + entry: &NewEntry, + lines: &[NewLine], + normal_sides: &HashMap, + created_seq: i64, + ) -> Result<(), ProjectError> { + let grains = derive_grains(entry, lines, normal_sides)?; + + for g in grains { + match g.table_rank { + GrainTable::Account => { + self.upsert_account_balance(txn, scope, &g, created_seq) + .await?; + } + GrainTable::ArPayer => self.upsert_ar_payer(txn, scope, &g, created_seq).await?, + GrainTable::ArInvoice => { + self.upsert_ar_invoice(txn, scope, &g, created_seq).await?; + } + GrainTable::Unallocated => { + self.upsert_unallocated(txn, scope, &g, created_seq).await?; + } + GrainTable::ReusableCredit => { + self.upsert_reusable_credit(txn, scope, &g, created_seq) + .await?; + } + GrainTable::Tax => self.upsert_tax(txn, scope, &g, created_seq).await?, + } + } + Ok(()) + } + + async fn upsert_account_balance( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + g: &GrainDelta, + seq: i64, + ) -> Result<(), ProjectError> { + // Pre-check the projected balance for guarded classes BEFORE the + // upsert: the DB no-negative CHECK would otherwise abort the whole + // transaction, so the app-level guard fires first for a clean error. + // The read takes no row lock (none used anywhere in this codebase); + // under a concurrent race the P1 conditional CHECK is the backstop — + // it aborts the txn so a negative balance can never persist. + let guarded = g.account_class.is_guarded(); + let seed = if guarded { + let current = account_balance::Entity::find() + .filter( + Condition::all() + .add(account_balance::Column::TenantId.eq(g.tenant_id)) + .add(account_balance::Column::AccountId.eq(g.account_id)) + .add(account_balance::Column::Currency.eq(g.currency.clone())), + ) + .secure() + .scope_with(scope) + .one(txn) + .await + .map_err(|e| ProjectError::Db(format!("account_balance pre-read: {e}")))? + .map_or(0_i64, |r| r.balance_minor); + let projected = current.saturating_add(g.delta); + if projected < 0 { + return Err(ProjectError::NegativeBalance { + account_id: g.account_id, + balance_minor: projected, + }); + } + // Seed the INSERT tuple with the projected (post-state) balance, not + // the bare delta: Postgres evaluates the no-negative CHECK against the + // INSERT VALUES tuple during ON CONFLICT arbitration, so a negative + // delta (a legitimate net-down of a guarded balance, e.g. a reversal) + // would be rejected on the arbiter tuple even though the DO UPDATE + // path nets a non-negative result. The fresh-insert seed equals the + // delta (current == 0), preserving first-post semantics; the conflict + // path discards this seed and applies the atomic `+ delta` below. + projected + } else { + g.delta + }; + + let am = account_balance::ActiveModel { + tenant_id: Set(g.tenant_id), + account_id: Set(g.account_id), + currency: Set(g.currency.clone()), + account_class: Set(g.account_class.as_str().to_owned()), + normal_side: Set(g.normal_side.as_str().to_owned()), + balance_minor: Set(seed), + // Slice 5: functional columns are populated ONLY on cross-currency + // posts (functional_currency = Some); single-currency posts leave them + // NULL (functional ≡ transaction by identity). A plain `.add` on the + // conflict path keeps NULL = NULL (no COALESCE). + functional_balance_minor: Set(g + .functional_currency + .as_ref() + .map(|_| g.functional_delta)), + functional_currency: Set(g.functional_currency.clone()), + last_entry_seq: Set(Some(seq)), + version: Set(0), + }; + let mut on_conflict = SecureOnConflict::::columns([ + account_balance::Column::TenantId, + account_balance::Column::AccountId, + account_balance::Column::Currency, + ]); + // The conflict path nets atomically with `existing + delta` (NOT the + // seed): two racing posts then serialize at the row and the no-negative + // CHECK on the resulting row is the backstop against a concurrent + // overdraw the lockless pre-read could not see. + on_conflict = on_conflict + .value( + account_balance::Column::BalanceMinor, + Expr::col(( + account_balance::Entity, + account_balance::Column::BalanceMinor, + )) + .add(g.delta), + ) + .and_then(|oc| { + oc.value( + account_balance::Column::Version, + Expr::col((account_balance::Entity, account_balance::Column::Version)).add(1), + ) + }) + .and_then(|oc| { + oc.value( + account_balance::Column::LastEntrySeq, + Expr::value(Some(seq)), + ) + }) + // Slice 5: net the functional column with a PLAIN `+ functional_delta` + // (NOT COALESCE) so a single-currency row's NULL stays NULL. + .and_then(|oc| { + oc.value( + account_balance::Column::FunctionalBalanceMinor, + Expr::col(( + account_balance::Entity, + account_balance::Column::FunctionalBalanceMinor, + )) + .add(g.functional_delta), + ) + }) + .and_then(|oc| { + oc.value( + account_balance::Column::FunctionalCurrency, + Expr::value(g.functional_currency.clone()), + ) + }) + .map_err(|e| ProjectError::Db(format!("account_balance on_conflict: {e}")))?; + + account_balance::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| ProjectError::Db(format!("account_balance scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| ProjectError::Db(format!("account_balance upsert: {e}")))?; + Ok(()) + } + + async fn upsert_ar_payer( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + g: &GrainDelta, + seq: i64, + ) -> Result<(), ProjectError> { + // AR is a guarded class — pre-check the projected balance (see + // `upsert_account_balance` for why this precedes the upsert). The seed + // is the projected post-state, not the bare delta, so a net-down does + // not trip the no-negative CHECK on the INSERT arbiter tuple. + let current = ar_payer_balance::Entity::find() + .filter( + Condition::all() + .add(ar_payer_balance::Column::TenantId.eq(g.tenant_id)) + .add(ar_payer_balance::Column::PayerTenantId.eq(g.payer_tenant_id)) + .add(ar_payer_balance::Column::AccountId.eq(g.account_id)) + .add(ar_payer_balance::Column::Currency.eq(g.currency.clone())), + ) + .secure() + .scope_with(scope) + .one(txn) + .await + .map_err(|e| ProjectError::Db(format!("ar_payer_balance pre-read: {e}")))? + .map_or(0_i64, |r| r.balance_minor); + let projected = current.saturating_add(g.delta); + if projected < 0 { + return Err(ProjectError::NegativeBalance { + account_id: g.account_id, + balance_minor: projected, + }); + } + + let am = ar_payer_balance::ActiveModel { + tenant_id: Set(g.tenant_id), + payer_tenant_id: Set(g.payer_tenant_id), + account_id: Set(g.account_id), + currency: Set(g.currency.clone()), + balance_minor: Set(projected), + // Slice 5: functional columns populated only on cross-currency posts + // (Some); single-currency leaves them NULL. Plain `.add` on conflict + // keeps NULL = NULL. + functional_balance_minor: Set(g + .functional_currency + .as_ref() + .map(|_| g.functional_delta)), + functional_currency: Set(g.functional_currency.clone()), + last_entry_seq: Set(Some(seq)), + version: Set(0), + }; + let mut on_conflict = SecureOnConflict::::columns([ + ar_payer_balance::Column::TenantId, + ar_payer_balance::Column::PayerTenantId, + ar_payer_balance::Column::AccountId, + ar_payer_balance::Column::Currency, + ]); + on_conflict = on_conflict + .value( + ar_payer_balance::Column::BalanceMinor, + Expr::col(( + ar_payer_balance::Entity, + ar_payer_balance::Column::BalanceMinor, + )) + .add(g.delta), + ) + .and_then(|oc| { + oc.value( + ar_payer_balance::Column::Version, + Expr::col((ar_payer_balance::Entity, ar_payer_balance::Column::Version)).add(1), + ) + }) + .and_then(|oc| { + oc.value( + ar_payer_balance::Column::LastEntrySeq, + Expr::value(Some(seq)), + ) + }) + // Slice 5: plain `+ functional_delta` (NOT COALESCE) — single-currency + // NULL stays NULL. + .and_then(|oc| { + oc.value( + ar_payer_balance::Column::FunctionalBalanceMinor, + Expr::col(( + ar_payer_balance::Entity, + ar_payer_balance::Column::FunctionalBalanceMinor, + )) + .add(g.functional_delta), + ) + }) + .and_then(|oc| { + oc.value( + ar_payer_balance::Column::FunctionalCurrency, + Expr::value(g.functional_currency.clone()), + ) + }) + .map_err(|e| ProjectError::Db(format!("ar_payer_balance on_conflict: {e}")))?; + + ar_payer_balance::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| ProjectError::Db(format!("ar_payer_balance scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| ProjectError::Db(format!("ar_payer_balance upsert: {e}")))?; + Ok(()) + } + + async fn upsert_ar_invoice( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + g: &GrainDelta, + seq: i64, + ) -> Result<(), ProjectError> { + // AR invoice rows are guarded — pre-check the projected balance. The + // seed is the projected post-state, not the bare delta, so a net-down + // (e.g. a reversal or payment) does not trip the no-negative CHECK on + // the INSERT arbiter tuple. The same pre-read also yields the current + // `disputed_minor` so the INSERT tuple seeds its projected post-state + // (the chargeback `ar_status` sub-balance; see below). + let existing = ar_invoice_balance::Entity::find() + .filter( + Condition::all() + .add(ar_invoice_balance::Column::TenantId.eq(g.tenant_id)) + .add(ar_invoice_balance::Column::PayerTenantId.eq(g.payer_tenant_id)) + .add(ar_invoice_balance::Column::AccountId.eq(g.account_id)) + .add(ar_invoice_balance::Column::InvoiceId.eq(g.invoice_id.clone())), + ) + .secure() + .scope_with(scope) + .one(txn) + .await + .map_err(|e| ProjectError::Db(format!("ar_invoice_balance pre-read: {e}")))?; + let current = existing.as_ref().map_or(0_i64, |r| r.balance_minor); + let current_disputed = existing.as_ref().map_or(0_i64, |r| r.disputed_minor); + let projected = current.saturating_add(g.delta); + if projected < 0 { + return Err(ProjectError::NegativeBalance { + account_id: g.account_id, + balance_minor: projected, + }); + } + // The disputed sub-balance moves by `disputed_delta` and stays the + // disputed slice of the (unchanged-by-a-reclass) open AR. Its own DB + // CHECKs are the guard — `disputed_minor >= 0` and + // `disputed_minor <= balance_minor` — NOT the balance_minor no-negative + // path; we seed the INSERT tuple with the projected post-state so a + // net-down (a `won` reversal) does not trip those CHECKs on the arbiter + // tuple, exactly as `balance_minor` does above. + let projected_disputed = current_disputed.saturating_add(g.disputed_delta); + + let am = ar_invoice_balance::ActiveModel { + tenant_id: Set(g.tenant_id), + payer_tenant_id: Set(g.payer_tenant_id), + account_id: Set(g.account_id), + invoice_id: Set(g.invoice_id.clone()), + currency: Set(g.currency.clone()), + balance_minor: Set(projected), + disputed_minor: Set(projected_disputed), + // Slice 5: functional columns populated only on cross-currency posts + // (Some); single-currency leaves them NULL. Plain `.add` on conflict + // keeps NULL = NULL. + functional_balance_minor: Set(g + .functional_currency + .as_ref() + .map(|_| g.functional_delta)), + functional_currency: Set(g.functional_currency.clone()), + // Decision P (first-write-wins): the INSERT tuple stamps the + // original post date + due date; the `on_conflict` builder below + // deliberately omits both columns, so a later net-down (payment / + // reversal) never overwrites them. + original_posted_at: Set(Some(g.posted_at)), + due_date: Set(g.due_date), + last_entry_seq: Set(Some(seq)), + version: Set(0), + }; + let mut on_conflict = SecureOnConflict::::columns([ + ar_invoice_balance::Column::TenantId, + ar_invoice_balance::Column::PayerTenantId, + ar_invoice_balance::Column::AccountId, + ar_invoice_balance::Column::InvoiceId, + ]); + on_conflict = on_conflict + .value( + ar_invoice_balance::Column::BalanceMinor, + Expr::col(( + ar_invoice_balance::Entity, + ar_invoice_balance::Column::BalanceMinor, + )) + .add(g.delta), + ) + // The conflict path nets `disputed_minor` atomically with + // `existing + disputed_delta` (parallel to `balance_minor`): two + // racing posts serialize at the row and the `disputed_minor >= 0` / + // `<= balance_minor` CHECKs on the resulting row are the backstop. + .and_then(|oc| { + oc.value( + ar_invoice_balance::Column::DisputedMinor, + Expr::col(( + ar_invoice_balance::Entity, + ar_invoice_balance::Column::DisputedMinor, + )) + .add(g.disputed_delta), + ) + }) + .and_then(|oc| { + oc.value( + ar_invoice_balance::Column::Version, + Expr::col(( + ar_invoice_balance::Entity, + ar_invoice_balance::Column::Version, + )) + .add(1), + ) + }) + .and_then(|oc| { + oc.value( + ar_invoice_balance::Column::LastEntrySeq, + Expr::value(Some(seq)), + ) + }) + // Slice 5: plain `+ functional_delta` (NOT COALESCE) — single-currency + // NULL stays NULL. + .and_then(|oc| { + oc.value( + ar_invoice_balance::Column::FunctionalBalanceMinor, + Expr::col(( + ar_invoice_balance::Entity, + ar_invoice_balance::Column::FunctionalBalanceMinor, + )) + .add(g.functional_delta), + ) + }) + .and_then(|oc| { + oc.value( + ar_invoice_balance::Column::FunctionalCurrency, + Expr::value(g.functional_currency.clone()), + ) + }) + .map_err(|e| ProjectError::Db(format!("ar_invoice_balance on_conflict: {e}")))?; + + ar_invoice_balance::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| ProjectError::Db(format!("ar_invoice_balance scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| ProjectError::Db(format!("ar_invoice_balance upsert: {e}")))?; + Ok(()) + } + + async fn upsert_unallocated( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + g: &GrainDelta, + seq: i64, + ) -> Result<(), ProjectError> { + // UNALLOCATED is a guarded class — pre-check the projected balance (see + // `upsert_account_balance` for why this precedes the upsert). The seed is + // the projected post-state, not the bare delta, so a net-down (an + // allocation DR against unapplied cash) does not trip the no-negative + // CHECK on the INSERT arbiter tuple. + let current = unallocated_balance::Entity::find() + .filter( + Condition::all() + .add(unallocated_balance::Column::TenantId.eq(g.tenant_id)) + .add(unallocated_balance::Column::PayerTenantId.eq(g.payer_tenant_id)) + .add(unallocated_balance::Column::AccountId.eq(g.account_id)) + .add(unallocated_balance::Column::Currency.eq(g.currency.clone())), + ) + .secure() + .scope_with(scope) + .one(txn) + .await + .map_err(|e| ProjectError::Db(format!("unallocated_balance pre-read: {e}")))? + .map_or(0_i64, |r| r.balance_minor); + let projected = current.saturating_add(g.delta); + if projected < 0 { + return Err(ProjectError::NegativeBalance { + account_id: g.account_id, + balance_minor: projected, + }); + } + + let am = unallocated_balance::ActiveModel { + tenant_id: Set(g.tenant_id), + payer_tenant_id: Set(g.payer_tenant_id), + account_id: Set(g.account_id), + currency: Set(g.currency.clone()), + balance_minor: Set(projected), + // Slice 5: cross-currency only (Some); single-currency stays NULL. + // Plain `.add` on conflict keeps NULL = NULL. + functional_balance_minor: Set(g + .functional_currency + .as_ref() + .map(|_| g.functional_delta)), + functional_currency: Set(g.functional_currency.clone()), + last_entry_seq: Set(Some(seq)), + version: Set(0), + }; + let mut on_conflict = SecureOnConflict::::columns([ + unallocated_balance::Column::TenantId, + unallocated_balance::Column::PayerTenantId, + unallocated_balance::Column::Currency, + ]); + on_conflict = on_conflict + .value( + unallocated_balance::Column::BalanceMinor, + Expr::col(( + unallocated_balance::Entity, + unallocated_balance::Column::BalanceMinor, + )) + .add(g.delta), + ) + .and_then(|oc| { + oc.value( + unallocated_balance::Column::Version, + Expr::col(( + unallocated_balance::Entity, + unallocated_balance::Column::Version, + )) + .add(1), + ) + }) + .and_then(|oc| { + oc.value( + unallocated_balance::Column::LastEntrySeq, + Expr::value(Some(seq)), + ) + }) + // Slice 5: plain `+ functional_delta` (NOT COALESCE) — single-currency + // NULL stays NULL. + .and_then(|oc| { + oc.value( + unallocated_balance::Column::FunctionalBalanceMinor, + Expr::col(( + unallocated_balance::Entity, + unallocated_balance::Column::FunctionalBalanceMinor, + )) + .add(g.functional_delta), + ) + }) + .and_then(|oc| { + oc.value( + unallocated_balance::Column::FunctionalCurrency, + Expr::value(g.functional_currency.clone()), + ) + }) + .map_err(|e| ProjectError::Db(format!("unallocated_balance on_conflict: {e}")))?; + + unallocated_balance::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| ProjectError::Db(format!("unallocated_balance scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| ProjectError::Db(format!("unallocated_balance upsert: {e}")))?; + Ok(()) + } + + async fn upsert_reusable_credit( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + g: &GrainDelta, + seq: i64, + ) -> Result<(), ProjectError> { + // REUSABLE_CREDIT is NOT an `AccountClass::GUARDED` class, so + // `account_balance` does not pre-check it — the wallet-overdraw invariant + // (a balance may never be spent below zero) lives ONLY on this sub-grain: + // here as the app-level pre-check, and the `chk_reusable_credit_subbalance_no_negative` + // DB CHECK as the serializable backstop against a concurrent overdraw the + // lockless pre-read cannot see. The seed is the projected post-state, not + // the bare delta, so a net-down (a spend against an existing wallet + // balance) does not trip the no-negative CHECK on the INSERT arbiter + // tuple (see `upsert_account_balance` for the full arbitration rationale). + let current = reusable_credit_subbalance::Entity::find() + .filter( + Condition::all() + .add(reusable_credit_subbalance::Column::TenantId.eq(g.tenant_id)) + .add(reusable_credit_subbalance::Column::PayerTenantId.eq(g.payer_tenant_id)) + .add(reusable_credit_subbalance::Column::AccountId.eq(g.account_id)) + .add(reusable_credit_subbalance::Column::Currency.eq(g.currency.clone())) + .add( + reusable_credit_subbalance::Column::CreditGrantEventType + .eq(g.credit_grant_event_type.clone()), + ), + ) + .secure() + .scope_with(scope) + .one(txn) + .await + .map_err(|e| ProjectError::Db(format!("reusable_credit_subbalance pre-read: {e}")))? + .map_or(0_i64, |r| r.balance_minor); + let projected = current.saturating_add(g.delta); + if projected < 0 { + return Err(ProjectError::NegativeBalance { + account_id: g.account_id, + balance_minor: projected, + }); + } + + let am = reusable_credit_subbalance::ActiveModel { + tenant_id: Set(g.tenant_id), + payer_tenant_id: Set(g.payer_tenant_id), + account_id: Set(g.account_id), + currency: Set(g.currency.clone()), + credit_grant_event_type: Set(g.credit_grant_event_type.clone()), + // First-write-wins recency stamp: the INSERT tuple records the first + // grant's posted-at; the `on_conflict` builder below deliberately + // omits this column, so later grants/spends never overwrite it + // (exactly like `ar_invoice_balance.original_posted_at`). + first_granted_at: Set(g.first_granted_at), + balance_minor: Set(projected), + // Slice 5: cross-currency only (Some); single-currency stays NULL. + // Plain `.add` on conflict keeps NULL = NULL. + functional_balance_minor: Set(g + .functional_currency + .as_ref() + .map(|_| g.functional_delta)), + functional_currency: Set(g.functional_currency.clone()), + last_entry_seq: Set(Some(seq)), + version: Set(0), + }; + let mut on_conflict = SecureOnConflict::::columns([ + reusable_credit_subbalance::Column::TenantId, + reusable_credit_subbalance::Column::PayerTenantId, + reusable_credit_subbalance::Column::Currency, + reusable_credit_subbalance::Column::CreditGrantEventType, + ]); + on_conflict = on_conflict + .value( + reusable_credit_subbalance::Column::BalanceMinor, + Expr::col(( + reusable_credit_subbalance::Entity, + reusable_credit_subbalance::Column::BalanceMinor, + )) + .add(g.delta), + ) + .and_then(|oc| { + oc.value( + reusable_credit_subbalance::Column::Version, + Expr::col(( + reusable_credit_subbalance::Entity, + reusable_credit_subbalance::Column::Version, + )) + .add(1), + ) + }) + .and_then(|oc| { + oc.value( + reusable_credit_subbalance::Column::LastEntrySeq, + Expr::value(Some(seq)), + ) + }) + // Slice 5: plain `+ functional_delta` (NOT COALESCE) — single-currency + // NULL stays NULL. + .and_then(|oc| { + oc.value( + reusable_credit_subbalance::Column::FunctionalBalanceMinor, + Expr::col(( + reusable_credit_subbalance::Entity, + reusable_credit_subbalance::Column::FunctionalBalanceMinor, + )) + .add(g.functional_delta), + ) + }) + .and_then(|oc| { + oc.value( + reusable_credit_subbalance::Column::FunctionalCurrency, + Expr::value(g.functional_currency.clone()), + ) + }) + .map_err(|e| { + ProjectError::Db(format!("reusable_credit_subbalance on_conflict: {e}")) + })?; + + reusable_credit_subbalance::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| ProjectError::Db(format!("reusable_credit_subbalance scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| ProjectError::Db(format!("reusable_credit_subbalance upsert: {e}")))?; + Ok(()) + } + + async fn upsert_tax( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + g: &GrainDelta, + seq: i64, + ) -> Result<(), ProjectError> { + let am = tax_subbalance::ActiveModel { + tenant_id: Set(g.tenant_id), + account_id: Set(g.account_id), + tax_jurisdiction: Set(g.tax_jurisdiction.clone()), + tax_filing_period: Set(g.tax_filing_period.clone()), + balance_minor: Set(g.delta), + last_entry_seq: Set(Some(seq)), + version: Set(0), + }; + let mut on_conflict = SecureOnConflict::::columns([ + tax_subbalance::Column::TenantId, + tax_subbalance::Column::AccountId, + tax_subbalance::Column::TaxJurisdiction, + tax_subbalance::Column::TaxFilingPeriod, + ]); + on_conflict = on_conflict + .value( + tax_subbalance::Column::BalanceMinor, + Expr::col((tax_subbalance::Entity, tax_subbalance::Column::BalanceMinor)) + .add(g.delta), + ) + .and_then(|oc| { + oc.value( + tax_subbalance::Column::Version, + Expr::col((tax_subbalance::Entity, tax_subbalance::Column::Version)).add(1), + ) + }) + .and_then(|oc| oc.value(tax_subbalance::Column::LastEntrySeq, Expr::value(Some(seq)))) + .map_err(|e| ProjectError::Db(format!("tax_subbalance on_conflict: {e}")))?; + + // tax_subbalance has no no-negative CHECK and is not a guarded class. + tax_subbalance::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| ProjectError::Db(format!("tax_subbalance scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| ProjectError::Db(format!("tax_subbalance upsert: {e}")))?; + Ok(()) + } +} + +/// Derive the sorted per-grain deltas for an entry's lines (pure; +/// unit-testable). Each line always touches `account_balance`; `AR` lines +/// additionally touch the payer (always) and invoice (when present) grains; +/// `TAX_PAYABLE` lines with both tax dims touch the tax grain. +fn derive_grains( + entry: &NewEntry, + lines: &[NewLine], + normal_sides: &HashMap, +) -> Result, ProjectError> { + let tenant = entry.tenant_id; + let posted_at = entry.posted_at_utc; + let mut grains: Vec = Vec::new(); + + for line in lines { + let normal_side = *normal_sides + .get(&line.account_id) + .ok_or(ProjectError::MissingNormalSide(line.account_id))?; + let delta = if line.side == normal_side { + line.amount_minor + } else { + -line.amount_minor + }; + // FX (Slice 5): mirror the transaction sign onto the functional column. + // Present only on cross-currency lines; single-currency lines contribute + // 0 / None so the functional cache column stays NULL. + let functional_delta = line + .functional_amount_minor + .map_or(0, |f| if line.side == normal_side { f } else { -f }); + let functional_currency = line.functional_currency.clone(); + + grains.push(GrainDelta { + table_rank: GrainTable::Account, + tenant_id: tenant, + account_id: line.account_id, + currency: line.currency.clone(), + payer_tenant_id: line.payer_tenant_id, + invoice_id: String::new(), + tax_jurisdiction: String::new(), + tax_filing_period: String::new(), + account_class: line.account_class, + normal_side, + delta, + functional_delta, + functional_currency: functional_currency.clone(), + disputed_delta: 0, + posted_at, + due_date: None, + credit_grant_event_type: String::new(), + first_granted_at: None, + }); + + if line.account_class == AccountClass::Ar { + // Chargeback `ar_status` seam: a `DISPUTED` AR line routes its signed + // amount (`delta`: DR +, CR −) onto `disputed_minor`; every other AR + // line leaves it untouched. The disputed sub-balance is tracked at + // the invoice grain only (`ar_invoice_balance.disputed_minor`), so + // the payer grain carries `0`. + let disputed_delta = if line.ar_status.as_deref() == Some(AR_STATUS_DISPUTED) { + delta + } else { + 0 + }; + grains.push(GrainDelta { + table_rank: GrainTable::ArPayer, + tenant_id: tenant, + account_id: line.account_id, + currency: line.currency.clone(), + payer_tenant_id: line.payer_tenant_id, + invoice_id: String::new(), + tax_jurisdiction: String::new(), + tax_filing_period: String::new(), + account_class: line.account_class, + normal_side, + delta, + functional_delta, + functional_currency: functional_currency.clone(), + disputed_delta: 0, + posted_at, + due_date: None, + credit_grant_event_type: String::new(), + first_granted_at: None, + }); + if let Some(invoice_id) = &line.invoice_id { + grains.push(GrainDelta { + table_rank: GrainTable::ArInvoice, + tenant_id: tenant, + account_id: line.account_id, + currency: line.currency.clone(), + payer_tenant_id: line.payer_tenant_id, + invoice_id: invoice_id.clone(), + tax_jurisdiction: String::new(), + tax_filing_period: String::new(), + account_class: line.account_class, + normal_side, + delta, + functional_delta, + functional_currency: functional_currency.clone(), + disputed_delta, + // Decision P: stamp the entry's posted-at + the line's due + // date; `upsert_ar_invoice` writes them first-write-wins. + posted_at, + due_date: line.due_date, + credit_grant_event_type: String::new(), + first_granted_at: None, + }); + } + } + + if line.account_class == AccountClass::Unallocated { + grains.push(GrainDelta { + table_rank: GrainTable::Unallocated, + tenant_id: tenant, + account_id: line.account_id, + currency: line.currency.clone(), + payer_tenant_id: line.payer_tenant_id, + invoice_id: String::new(), + tax_jurisdiction: String::new(), + tax_filing_period: String::new(), + account_class: line.account_class, + normal_side, + delta, + functional_delta, + functional_currency: functional_currency.clone(), + disputed_delta: 0, + posted_at, + due_date: None, + credit_grant_event_type: String::new(), + first_granted_at: None, + }); + } + + if line.account_class == AccountClass::ReusableCredit { + // The credit-grant event type sub-divides the wallet (a PK dim). A + // missing/empty value would key a phantom "" sub-balance, so reject + // rather than default — the DB NOT-NULL CHECK tests NULL, not "", so + // it would not catch the empty string. `first_granted_at` is stamped + // first-write-wins by the upsert. + let credit_grant_event_type = line + .credit_grant_event_type + .clone() + .filter(|s| !s.is_empty()) + .ok_or(ProjectError::MissingCreditEventType(line.line_id))?; + grains.push(GrainDelta { + table_rank: GrainTable::ReusableCredit, + tenant_id: tenant, + account_id: line.account_id, + currency: line.currency.clone(), + payer_tenant_id: line.payer_tenant_id, + invoice_id: String::new(), + tax_jurisdiction: String::new(), + tax_filing_period: String::new(), + account_class: line.account_class, + normal_side, + delta, + functional_delta, + functional_currency: functional_currency.clone(), + disputed_delta: 0, + posted_at, + due_date: None, + credit_grant_event_type, + first_granted_at: Some(posted_at), + }); + } + + if line.account_class == AccountClass::TaxPayable + && let (Some(juris), Some(filing)) = (&line.tax_jurisdiction, &line.tax_filing_period) + { + grains.push(GrainDelta { + table_rank: GrainTable::Tax, + tenant_id: tenant, + account_id: line.account_id, + currency: line.currency.clone(), + payer_tenant_id: line.payer_tenant_id, + invoice_id: String::new(), + tax_jurisdiction: juris.clone(), + tax_filing_period: filing.clone(), + account_class: line.account_class, + normal_side, + delta, + functional_delta, + functional_currency: functional_currency.clone(), + disputed_delta: 0, + posted_at, + due_date: None, + credit_grant_event_type: String::new(), + first_granted_at: None, + }); + } + } + + grains.sort_by(|a, b| a.sort_key().cmp(&b.sort_key())); + + // Coalesce grains that map to the SAME cache row (equal sort key) by summing + // their deltas, so the no-negative guard and the upsert see the entry's NET + // effect per grain. Without this, a balanced multi-line entry touching one + // guarded account (e.g. a -100 leg then a +150 leg, net +50) would be + // rejected on the intermediate running balance, order-dependently. + let mut coalesced: Vec = Vec::with_capacity(grains.len()); + for g in grains { + if let Some(last) = coalesced.last_mut() + && last.sort_key() == g.sort_key() + { + // Checked, not saturating: a money delta that overflows `i64` is a + // hard rejection (surfaced as AmountOutOfRange / 422 at the call-site), + // never a silently-clamped balance. Same grain (equal sort key) ⇒ same + // account/currency, captured once for the error. + let acc = last.account_id; + let ccy = last.currency.clone(); + last.delta = last + .delta + .checked_add(g.delta) + .ok_or_else(|| ProjectError::Overflow { + account_id: acc, + currency: ccy.clone(), + field: "delta", + })?; + // The disputed sub-delta coalesces in parallel: an AR reclass's two + // same-grain legs net ZERO on `delta` (balance_minor) while summing + // their `disputed_delta` to the net disputed move (`+D` opened, `-D` + // won). + last.disputed_delta = last + .disputed_delta + .checked_add(g.disputed_delta) + .ok_or_else(|| ProjectError::Overflow { + account_id: acc, + currency: ccy.clone(), + field: "disputed_delta", + })?; + last.functional_delta = last + .functional_delta + .checked_add(g.functional_delta) + .ok_or(ProjectError::Overflow { + account_id: acc, + currency: ccy, + field: "functional_delta", + })?; + if last.functional_currency.is_none() { + last.functional_currency.clone_from(&g.functional_currency); + } + continue; + } + coalesced.push(g); + } + Ok(coalesced) +} + +#[cfg(test)] +#[path = "projector_tests.rs"] +mod projector_tests; diff --git a/gears/bss/ledger/ledger/src/infra/posting/projector_tests.rs b/gears/bss/ledger/ledger/src/infra/posting/projector_tests.rs new file mode 100644 index 000000000..055ff89e5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting/projector_tests.rs @@ -0,0 +1,424 @@ +//! Tests for [`super`]'s pure `derive_grains` — grain fan-out, delta sign, +//! and the missing-`normal_side` error. (DE1101: kept out of the impl file.) + +use super::*; +use bss_ledger_sdk::{MappingStatus, SourceDocType}; +use chrono::Utc; + +fn entry(tenant: Uuid) -> NewEntry { + NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + legal_entity_id: tenant, + period_id: "202606".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: "biz-1".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: tenant, + correlation_id: tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + } +} + +fn line(account: Uuid, class: AccountClass, side: Side, amount: i64, payer: Uuid) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: payer, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +#[test] +fn ar_line_with_invoice_yields_three_grains() { + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let ar = Uuid::now_v7(); + let mut l = line(ar, AccountClass::Ar, Side::Debit, 1000, payer); + l.invoice_id = Some("INV-1".to_owned()); + let mut normal_sides = HashMap::new(); + normal_sides.insert(ar, Side::Debit); + + let grains = derive_grains(&entry(tenant), &[l], &normal_sides).unwrap(); + assert_eq!(grains.len(), 3); + // Sorted: account(0), ar_payer(1), ar_invoice(2). + assert_eq!(grains[0].table_rank, GrainTable::Account); + assert_eq!(grains[1].table_rank, GrainTable::ArPayer); + assert_eq!(grains[2].table_rank, GrainTable::ArInvoice); + // DR on a DR-normal account → positive delta. + assert_eq!(grains[0].delta, 1000); +} + +#[test] +fn credit_against_dr_normal_is_negative_delta() { + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let ar = Uuid::now_v7(); + let l = line(ar, AccountClass::Ar, Side::Credit, 1500, payer); + let mut normal_sides = HashMap::new(); + normal_sides.insert(ar, Side::Debit); + + let grains = derive_grains(&entry(tenant), &[l], &normal_sides).unwrap(); + assert_eq!(grains[0].delta, -1500); +} + +#[test] +fn two_lines_on_one_account_coalesce_into_a_single_net_grain() { + // A balanced entry with two legs on the SAME guarded (AR) account: a credit + // (−100) then a debit (+150). They share a cache-row identity, so they must + // collapse to ONE grain carrying the net +50 — not two grains whose first + // (−100) would falsely trip the no-negative guard on an intermediate state. + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let ar = Uuid::now_v7(); + let credit = line(ar, AccountClass::Ar, Side::Credit, 100, payer); + let debit = line(ar, AccountClass::Ar, Side::Debit, 150, payer); + let mut normal_sides = HashMap::new(); + normal_sides.insert(ar, Side::Debit); + + let grains = derive_grains(&entry(tenant), &[credit, debit], &normal_sides).unwrap(); + // One account grain + one ar_payer grain (both AR), each netted — not four. + let account_grains: Vec<_> = grains + .iter() + .filter(|g| g.table_rank == GrainTable::Account) + .collect(); + assert_eq!(account_grains.len(), 1, "same-account legs must coalesce"); + assert_eq!(account_grains[0].delta, 50, "net of −100 then +150"); + let payer_grains: Vec<_> = grains + .iter() + .filter(|g| g.table_rank == GrainTable::ArPayer) + .collect(); + assert_eq!(payer_grains.len(), 1); + assert_eq!(payer_grains[0].delta, 50); +} + +#[test] +fn cr_unallocated_line_yields_an_unallocated_grain() { + // A CR UNALLOCATED line (CR on a CR-normal unapplied-cash account) fans out + // to its account grain (0) + the UNALLOCATED grain (3), keyed by payer + + // currency + account, carrying the normal-side-positive delta. + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let unalloc = Uuid::now_v7(); + let l = line( + unalloc, + AccountClass::Unallocated, + Side::Credit, + 1000, + payer, + ); + let mut normal_sides = HashMap::new(); + normal_sides.insert(unalloc, Side::Credit); + + let grains = derive_grains(&entry(tenant), &[l], &normal_sides).unwrap(); + // account(0) + unallocated(3). + assert_eq!(grains.len(), 2); + let unalloc_grain = grains + .iter() + .find(|g| g.table_rank == GrainTable::Unallocated) + .expect("an UNALLOCATED-rank grain must be emitted"); + assert_eq!(unalloc_grain.account_id, unalloc); + assert_eq!(unalloc_grain.payer_tenant_id, payer); + assert_eq!(unalloc_grain.currency, "USD"); + assert_eq!(unalloc_grain.account_class, AccountClass::Unallocated); + // CR on a CR-normal account → positive delta. + assert_eq!(unalloc_grain.delta, 1000); +} + +#[test] +fn missing_normal_side_is_an_error() { + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let ar = Uuid::now_v7(); + let l = line(ar, AccountClass::Ar, Side::Debit, 1000, payer); + let err = derive_grains(&entry(tenant), &[l], &HashMap::new()).unwrap_err(); + assert_eq!(err, ProjectError::MissingNormalSide(ar)); +} + +#[test] +fn cr_reusable_credit_line_yields_a_keyed_credit_grain() { + // A CR REUSABLE_CREDIT line (CR on a CR-normal wallet account) fans out to + // its account grain (0) + the REUSABLE_CREDIT grain (4), keyed by payer + + // currency + account + the credit-grant event type, carrying the + // normal-side-positive delta and the entry's posted-at as the first-granted + // recency stamp. + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let wallet = Uuid::now_v7(); + let e = entry(tenant); + let posted_at = e.posted_at_utc; + let mut l = line( + wallet, + AccountClass::ReusableCredit, + Side::Credit, + 1000, + payer, + ); + l.credit_grant_event_type = Some("promo".to_owned()); + let mut normal_sides = HashMap::new(); + normal_sides.insert(wallet, Side::Credit); + + let grains = derive_grains(&e, &[l], &normal_sides).unwrap(); + // account(0) + reusable_credit(4). + assert_eq!(grains.len(), 2); + let credit_grain = grains + .iter() + .find(|g| g.table_rank == GrainTable::ReusableCredit) + .expect("a REUSABLE_CREDIT-rank grain must be emitted"); + assert_eq!(credit_grain.account_id, wallet); + assert_eq!(credit_grain.payer_tenant_id, payer); + assert_eq!(credit_grain.currency, "USD"); + assert_eq!(credit_grain.account_class, AccountClass::ReusableCredit); + assert_eq!(credit_grain.credit_grant_event_type, "promo"); + // CR on a CR-normal account → positive delta. + assert_eq!(credit_grain.delta, 1000); + // First-write-wins recency stamp = the entry's posted-at. + assert_eq!(credit_grain.first_granted_at, Some(posted_at)); +} + +#[test] +fn dr_reusable_credit_line_is_negative_delta() { + // A DR REUSABLE_CREDIT line (a wallet spend) nets a negative delta against + // the CR-normal wallet — the app-level overdraw guard in the upsert is what + // rejects it below zero. + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let wallet = Uuid::now_v7(); + let mut l = line( + wallet, + AccountClass::ReusableCredit, + Side::Debit, + 400, + payer, + ); + l.credit_grant_event_type = Some("promo".to_owned()); + let mut normal_sides = HashMap::new(); + normal_sides.insert(wallet, Side::Credit); + + let grains = derive_grains(&entry(tenant), &[l], &normal_sides).unwrap(); + let credit_grain = grains + .iter() + .find(|g| g.table_rank == GrainTable::ReusableCredit) + .expect("a REUSABLE_CREDIT-rank grain must be emitted"); + // DR on a CR-normal account → negative delta. + assert_eq!(credit_grain.delta, -400); +} + +#[test] +fn reusable_credit_without_event_type_is_rejected() { + // A REUSABLE_CREDIT line that reaches projection without its sub-grain bucket + // would key a phantom "" sub-balance — reject rather than silently default to + // "" (the DB NOT-NULL CHECK tests NULL, not "", so it would not catch it). + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let wallet = Uuid::now_v7(); + let mut l = line( + wallet, + AccountClass::ReusableCredit, + Side::Credit, + 1000, + payer, + ); + l.credit_grant_event_type = None; + let line_id = l.line_id; + let mut normal_sides = HashMap::new(); + normal_sides.insert(wallet, Side::Credit); + + let err = derive_grains(&entry(tenant), &[l], &normal_sides).unwrap_err(); + assert_eq!(err, ProjectError::MissingCreditEventType(line_id)); +} + +#[test] +fn grain_lock_order_ranks_are_pinned() { + // Pins the ACTUAL deadlock-free balance-cache lock order. The code is the + // source of truth (the design doc was reconciled to it; payer is ranked + // before invoice). A reorder is a cross-slice + // deadlock risk (Slice 3 acquires these same grains) and must be deliberate — + // a reorder fails the BUILD (const assertions), not just the test. Slice 4 + // extends the chain with the recognition ranks (6/7) it added below tax. + // `GrainTable` is `derive(Ord)`, so the lock order IS the declaration order; + // these `as u8` discriminant checks still fail the BUILD on an accidental + // reorder. The recognition tables (schedule/segment) extend the chain below + // `Tax` when their variants are added. + const { + assert!( + (GrainTable::Account as u8) < (GrainTable::ArPayer as u8), + "account must lock before ar_payer" + ); + assert!( + (GrainTable::ArPayer as u8) < (GrainTable::ArInvoice as u8), + "ar_payer must lock before ar_invoice" + ); + assert!( + (GrainTable::ArInvoice as u8) < (GrainTable::Unallocated as u8), + "ar_invoice before unallocated" + ); + assert!( + (GrainTable::Unallocated as u8) < (GrainTable::ReusableCredit as u8), + "unallocated before reusable_credit" + ); + assert!( + (GrainTable::ReusableCredit as u8) < (GrainTable::Tax as u8), + "reusable_credit before tax" + ); + } +} + +#[test] +fn different_credit_grant_event_types_do_not_coalesce() { + // The credit-grant event type is a PK dim of the wallet sub-grain, so two + // lines on the SAME account/currency but DIFFERENT event types map to two + // distinct cache rows — they must stay two grains, not collapse. + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let wallet = Uuid::now_v7(); + let mut promo = line( + wallet, + AccountClass::ReusableCredit, + Side::Credit, + 100, + payer, + ); + promo.credit_grant_event_type = Some("promo".to_owned()); + let mut referral = line( + wallet, + AccountClass::ReusableCredit, + Side::Credit, + 250, + payer, + ); + referral.credit_grant_event_type = Some("referral".to_owned()); + let mut normal_sides = HashMap::new(); + normal_sides.insert(wallet, Side::Credit); + + let grains = derive_grains(&entry(tenant), &[promo, referral], &normal_sides).unwrap(); + let credit_grain_count = grains + .iter() + .filter(|g| g.table_rank == GrainTable::ReusableCredit) + .count(); + assert_eq!( + credit_grain_count, 2, + "distinct event types must not coalesce" + ); +} + +#[test] +fn same_credit_grant_event_type_coalesces_into_one_net_grain() { + // Two lines on the SAME account/currency/event-type share a cache-row + // identity, so they collapse to ONE grain carrying the net delta — exactly + // like the same-account AR coalescing. + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let wallet = Uuid::now_v7(); + let mut grant = line( + wallet, + AccountClass::ReusableCredit, + Side::Credit, + 500, + payer, + ); + grant.credit_grant_event_type = Some("promo".to_owned()); + let mut spend = line( + wallet, + AccountClass::ReusableCredit, + Side::Debit, + 200, + payer, + ); + spend.credit_grant_event_type = Some("promo".to_owned()); + let mut normal_sides = HashMap::new(); + normal_sides.insert(wallet, Side::Credit); + + let grains = derive_grains(&entry(tenant), &[grant, spend], &normal_sides).unwrap(); + let credit_grains: Vec<_> = grains + .iter() + .filter(|g| g.table_rank == GrainTable::ReusableCredit) + .collect(); + assert_eq!(credit_grains.len(), 1, "same event type must coalesce"); + // CR +500 then DR −200 on a CR-normal account → net +300. + assert_eq!(credit_grains[0].delta, 300); +} + +#[test] +fn reusable_credit_grain_sorts_between_unallocated_and_tax() { + // The canonical lock order places the wallet sub-grain (rank 4) strictly + // after unallocated (3) and before tax (5). A single entry exercising an + // UNALLOCATED line, a REUSABLE_CREDIT line, and a TAX_PAYABLE line must emit + // those three grains in that relative order after the sort. + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let unalloc = Uuid::now_v7(); + let wallet = Uuid::now_v7(); + let tax = Uuid::now_v7(); + let unalloc_line = line(unalloc, AccountClass::Unallocated, Side::Credit, 100, payer); + let mut credit_line = line( + wallet, + AccountClass::ReusableCredit, + Side::Credit, + 100, + payer, + ); + credit_line.credit_grant_event_type = Some("promo".to_owned()); + let mut tax_line = line(tax, AccountClass::TaxPayable, Side::Credit, 100, payer); + tax_line.tax_jurisdiction = Some("US-CA".to_owned()); + tax_line.tax_filing_period = Some("2026Q2".to_owned()); + let mut normal_sides = HashMap::new(); + normal_sides.insert(unalloc, Side::Credit); + normal_sides.insert(wallet, Side::Credit); + normal_sides.insert(tax, Side::Credit); + + let grains = derive_grains( + &entry(tenant), + &[unalloc_line, credit_line, tax_line], + &normal_sides, + ) + .unwrap(); + let ranks: Vec = grains + .iter() + .map(|g| g.table_rank) + .filter(|r| { + *r == GrainTable::Unallocated + || *r == GrainTable::ReusableCredit + || *r == GrainTable::Tax + }) + .collect(); + assert_eq!( + ranks, + vec![ + GrainTable::Unallocated, + GrainTable::ReusableCredit, + GrainTable::Tax + ], + "lock order: unallocated → reusable_credit → tax" + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/posting/service.rs b/gears/bss/ledger/ledger/src/infra/posting/service.rs new file mode 100644 index 000000000..9dfdccca7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/posting/service.rs @@ -0,0 +1,1187 @@ +//! `PostingService` — the transactional posting engine. One ACID +//! transaction per entry runs the full sequence: idempotency claim → insert +//! balanced lines → fiscal-period gate → account lifecycle + `normal_side` +//! lookup → balance projection → finalize → COMMIT. Pure structural +//! invariants (`validate_balanced_entry`) and the line-count ceiling are +//! checked BEFORE the transaction opens, so a malformed request never takes +//! a write lock. +//! +//! ## Error handling across the transaction boundary +//! +//! [`DBProvider::transaction`] fixes the closure error type to +//! [`DbError`], yet a business rejection discovered AFTER a write (e.g. a +//! negative balance after the journal insert) MUST roll the transaction +//! back. The closure therefore encodes a business [`DomainError`] into a +//! sentinel [`DbError::Sea`] (`DbErr::Custom`) and returns `Err`, forcing a +//! rollback; once `transaction()` returns, the sentinel is decoded back into +//! the original [`DomainError`]. A `DbError` WITHOUT the sentinel prefix is a +//! genuine infrastructure fault and surfaces as [`DomainError::Internal`]. + +use std::collections::HashMap; +use std::sync::Arc; + +use bss_ledger_sdk::{PostingRef, Side}; +use chrono::Utc; +use sea_orm::DbErr; +use toolkit_db::secure::{AccessScope, DbTx, TxConfig}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::posting::{LineFacts, validate_balanced_entry}; +use crate::domain::status::LIFECYCLE_OPEN; +use crate::infra::events::payloads::{ + AlarmCategory, AlarmSeverity, LedgerEntryPosted, LedgerInvariantAlarm, LedgerLineSummary, +}; +use crate::infra::posting::chain::ChainSealer; +use crate::infra::posting::idempotency::{ + ClaimOutcome, IdempotencyGate, PostingRefRow, STATUS_POSTED, STATUS_QUEUED, +}; +use crate::infra::posting::period::{FiscalPeriodGuard, PeriodError}; +use crate::infra::posting::projector::{BalanceProjector, ProjectError}; +use crate::infra::storage::repo::{JournalRepo, ReferenceRepo}; + +/// Maximum number of lines a single entry may carry. +const MAX_LINES: usize = 1000; + +/// Record-separator framing a sentinel-encoded business error inside a +/// `DbErr::Custom` payload: `LEDGER_POST_ERR␟`. +const SENTINEL_TAG: &str = "LEDGER_POST_ERR"; +/// Unit-separator used inside the sentinel payload. +const SENTINEL_SEP: char = '\u{1f}'; + +/// Retry-extractor for the `SERIALIZABLE` post: a wrapped `DbErr` so a +/// serialization failure (surfaced at a statement or COMMIT) is recognised as +/// retryable contention. The business-rejection sentinel is a `DbErr::Custom`, +/// which the contention classifier treats as NON-retryable — so only genuine +/// conflicts retry, business rejections propagate immediately. +fn as_db_err(e: &DbError) -> Option<&sea_orm::DbErr> { + match e { + DbError::Sea(db_err) => Some(db_err), + _ => None, + } +} + +/// The facts a finalized fresh post exposes to its in-transaction sidecar: +/// the new entry id and its DB-generated sequence. Passed by reference so the +/// sidecar can stamp counter rows that must commit atomically with the journal +/// entry. +#[derive(Clone, Copy, Debug)] +pub struct PostedFacts { + pub entry_id: Uuid, + pub created_seq: i64, +} + +/// An in-transaction hook the posting engine runs AFTER balance projection and +/// BEFORE the dedup row is finalized, on the fresh-post path only (a replay +/// returns before projection). The sidecar writes counter rows (e.g. the +/// payment settlement / allocation caches) inside the SAME serializable +/// transaction, so they commit atomically with the entry or roll back with it. +/// +/// An `Err(DomainError)` rolls the whole post back; the engine encodes it as a +/// non-retryable business sentinel so it surfaces to the caller unchanged. +/// Threaded as `Arc` (not `&dyn`) because the retry closure is +/// `FnMut` and must `Clone` its inputs across attempts. +#[async_trait::async_trait] +pub trait PostSidecar: Send + Sync { + /// Run the sidecar's in-transaction writes against the posted facts. + /// + /// # Errors + /// A [`DomainError`] rolls the post back (encoded as a non-retryable + /// business rejection). + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError>; + + /// Whether this sidecar must run BEFORE balance projection (default: `false` + /// — project first, then the sidecar). A refund / claw-back sidecar + /// overrides this to `true` so its rank-1 money-out cap / underflow CHECK + /// runs first: an over-refund or out-of-order claw-back then surfaces as the + /// canonical `RefundExceedsSettled` / `RefundClawbackDeferred`, instead of + /// the projector's structural no-negative guard tripping first with a raw + /// `NegativeBalance` (a stage-1 over-settled forward draws `UNALLOCATED` + /// negative; an out-of-order claw-back draws `REFUND_CLEARING` negative — + /// both before the cap classification can refine the error). + fn run_before_projection(&self) -> bool { + false + } +} + +/// The transactional posting engine. +#[derive(Clone)] +pub struct PostingService { + db: DBProvider, + journal: JournalRepo, + reference: ReferenceRepo, + idempotency: IdempotencyGate, + freeze: crate::infra::posting::freeze::TamperFreezeGuard, + period: FiscalPeriodGuard, + policy: crate::infra::policy_version::PolicyVersionGuard, + projector: BalanceProjector, + chain: ChainSealer, + publisher: Arc, +} + +/// How [`PostingService::post_in_txn`] step 1 (the idempotency gate) behaves — +/// the ONLY thing that differs between a fresh inline post and the deferred-apply +/// drive of a previously-queued item. Everything after step 1 (period gate → +/// insert → project → sidecar → finalize → outbox) is byte-identical for both +/// modes, so the body is parameterized by this rather than duplicated. +#[derive(Clone, Copy, Debug)] +enum ClaimMode { + /// The unchanged inline-post path: `claim` the dedup row (seed `CLAIMED`) via + /// `INSERT … ON CONFLICT DO NOTHING`; a conflict is a replay. This is exactly + /// today's behaviour — `post()` always passes `Fresh`, so the public posting + /// contract is unchanged. + Fresh, + /// The deferred-apply path (Group D): the dedup row was already claimed + /// `QUEUED` at intake, so DON'T re-claim — `read` it instead. A `POSTED` row + /// is an idempotent re-drive of an already-applied item (return `Replay`); a + /// `QUEUED` row falls through to the SAME period-gate/insert/project/sidecar/ + /// finalize tail as `Fresh` — `finalize` flips `QUEUED → POSTED` exactly as it + /// flips `CLAIMED → POSTED`. Any other state (no row / `CLAIMED`) is an + /// invariant breach and surfaces as an infra fault. + QueuedApply, +} + +/// How `run_post` / `post_in_txn` claim the idempotency-dedup row: the +/// [`ClaimMode`] plus, for a `Fresh` claim, an optional REQUEST-based payload +/// hash to bind instead of the entry-derived one (so a payment orchestrator's +/// replay short-circuit can reject a same-key / different-payload reuse). A +/// `QueuedApply` carries no override — it reads the row claimed at intake. +/// Bundling the two lets the post entry points read as one intent +/// (`ClaimSpec::fresh()` / `::fresh_with_request_hash(h)` / `::queued_apply()`) +/// and keeps `run_post` to a tidy arity. +#[derive(Clone)] +struct ClaimSpec { + mode: ClaimMode, + /// `Fresh` only: a request-based hash to key the claim on instead of the + /// entry-derived `payload_hash`. `None` ⇒ derive from the entry (the + /// byte-identical pre-Group-D behaviour). + payload_hash_override: Option, +} + +impl ClaimSpec { + /// Inline post keyed on the entry-derived payload hash (today's `post()`). + fn fresh() -> Self { + Self { + mode: ClaimMode::Fresh, + payload_hash_override: None, + } + } + + /// Inline post keyed on an externally-computed REQUEST hash. + fn fresh_with_request_hash(request_hash: String) -> Self { + Self { + mode: ClaimMode::Fresh, + payload_hash_override: Some(request_hash), + } + } + + /// Deferred apply of a row already claimed `QUEUED` at intake. + fn queued_apply() -> Self { + Self { + mode: ClaimMode::QueuedApply, + payload_hash_override: None, + } + } +} + +/// Outcome of the in-transaction posting body, carried out of the closure on +/// the commit (`Ok`) path. +#[derive(Clone, Copy, Debug)] +enum PostOutcome { + /// A fresh post: the new entry id + DB-generated sequence. + Posted { + entry_id: uuid::Uuid, + created_seq: i64, + }, + /// An idempotent replay of a prior, finalized post (its real entry id). + Replay { entry_id: uuid::Uuid }, +} + +impl PostingService { + /// Build a `PostingService` and its sub-repositories from one provider. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + ) -> Self { + let journal = JournalRepo::new(db.clone()); + let reference = ReferenceRepo::new(db.clone()); + Self { + db, + journal, + reference, + idempotency: IdempotencyGate::new(), + freeze: crate::infra::posting::freeze::TamperFreezeGuard::new(), + period: FiscalPeriodGuard::new(), + policy: crate::infra::policy_version::PolicyVersionGuard::new(), + projector: BalanceProjector::new(), + chain: ChainSealer::new(), + publisher, + } + } + + /// Post a balanced entry in one ACID transaction, idempotent on the + /// `(tenant, source_doc_type, source_business_id)` key. + /// + /// # Errors + /// A [`DomainError`] on any domain rejection (unbalanced/empty/mixed-payer, + /// too-large, idempotency conflict, period-closed, account-closed, + /// negative-balance), or [`DomainError::Internal`] on an infrastructure + /// fault. + pub async fn post( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: NewEntry, + lines: Vec, + sidecar: Option>, + ) -> Result { + // The unchanged inline-post path: `ClaimMode::Fresh` makes step 1 of the + // in-txn body `claim` the dedup row exactly as before, so this is + // byte-identical to the pre-Group-D `post`. + self.run_post(ctx, scope, entry, lines, sidecar, ClaimSpec::fresh()) + .await + } + + /// Like [`Self::post`] but binds an externally-computed, REQUEST-based payload + /// hash to the idempotency claim instead of deriving the entry-based hash. A + /// payment orchestrator (allocate / credit) that short-circuits a replay + /// BEFORE rebuilding its (state-dependent) entry must key dedup on a hash that + /// is stable across that rebuild — derived from the request, not the entry — + /// so the short-circuit can compare it and reject a same-key / different- + /// payload reuse as [`DomainError::IdempotencyConflict`] rather than silently + /// replaying the prior result. Otherwise identical to [`Self::post`] + /// (`ClaimMode::Fresh`). + /// + /// # Errors + /// As [`Self::post`]. + pub async fn post_with_request_hash( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: NewEntry, + lines: Vec, + sidecar: Option>, + request_hash: String, + ) -> Result { + self.run_post( + ctx, + scope, + entry, + lines, + sidecar, + ClaimSpec::fresh_with_request_hash(request_hash), + ) + .await + } + + /// Post a previously-QUEUED allocation as a deferred apply (Group D) — a + /// sibling of [`Self::post`] with IDENTICAL pre-txn validation and the SAME + /// serializable retry wrapper, differing only in the dedup gate: step 1 + /// `read`s the dedup row (claimed `QUEUED` at intake) instead of re-claiming + /// it, then finalizes `QUEUED → POSTED`. An already-`POSTED` row is an + /// idempotent re-drive and returns a replay. Caps are thus re-evaluated AT + /// APPLY TIME (§4.7) — the period gate, the projector's no-negative guard, and + /// the sidecar's per-payment cap CHECK all run against then-current state, not + /// intake-time state. + /// + /// The queue row's `→APPLIED` flip is NOT done here — it rides the `sidecar` + /// (the caller composes the allocation sidecar with the queue-flip), so the + /// apply effect and the work-state transition commit atomically in this one + /// transaction. + /// + /// # Errors + /// A [`DomainError`] on any domain rejection (re-evaluated cap / period / + /// account / negative-balance), or [`DomainError::Internal`] on an infra + /// fault — including a dedup row that is neither `QUEUED` nor a finalized + /// `POSTED` (an invariant breach for a queued-apply). + pub async fn post_queued_apply( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: NewEntry, + lines: Vec, + sidecar: Option>, + ) -> Result { + self.run_post(ctx, scope, entry, lines, sidecar, ClaimSpec::queued_apply()) + .await + } + + /// Shared driver behind [`Self::post`] / [`Self::post_with_request_hash`] + /// (`Fresh`) and [`Self::post_queued_apply`] (`QueuedApply`): the pre-txn + /// validation + `normal_side` load + the SERIALIZABLE retry transaction + the + /// out-of-band invariant alarm. The modes differ only in the [`ClaimSpec`]: + /// its [`ClaimMode`] is threaded into the in-txn body's step 1, and its + /// optional request-hash override keys a `Fresh` claim (`None` reproduces the + /// byte-identical pre-Group-D inline `post`). + async fn run_post( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: NewEntry, + lines: Vec, + sidecar: Option>, + claim: ClaimSpec, + ) -> Result { + // --- PRE-TRANSACTION (fail fast, no writes) --- + let facts: Vec = lines + .iter() + .map(|l| LineFacts { + side: l.side, + amount_minor: l.amount_minor, + currency: l.currency.clone(), + currency_scale: l.currency_scale, + payer_tenant_id: l.payer_tenant_id, + functional_amount_minor: l.functional_amount_minor, + }) + .collect(); + if let Err(v) = validate_balanced_entry(&entry.entry_currency, &facts) { + return Err(v.into()); + } + if lines.len() > MAX_LINES { + return Err(DomainError::EntryTooLarge(format!( + "entry has {} lines (max {MAX_LINES})", + lines.len() + ))); + } + + // Pre-transaction gate: tenant-termination kill switch (design §3.2). A + // held `tenant_posting_lock` refuses every post for the tenant with + // `TENANT_POSTING_LOCKED`, before any write. Read on its own connection + // (like the account-lifecycle pre-check below); a lock set CONCURRENTLY + // is not caught here, tolerable for a rare admin op. + if self + .reference + .is_tenant_posting_locked(scope, entry.tenant_id) + .await + .map_err(|e| decode_post_error(&repo_to_db(e)))? + { + return Err(DomainError::TenantPostingLocked(format!( + "tenant {} is posting-locked", + entry.tenant_id + ))); + } + + // Pre-transaction gate: clock-skew guard (design §3.2 FiscalPeriodGuard). + // Skew beyond ±24 h between the post's `posted_at_utc` and the server + // wall clock is quarantined (`CLOCK_SKEW_QUARANTINE`), re-submittable via + // the material-backdating exception path; skew beyond ±15 min posts but + // raises a `CLOCK_SKEW` Warn alarm out-of-band (no rollback). + match crate::infra::posting::period::classify_clock_skew(entry.posted_at_utc, Utc::now()) { + crate::infra::posting::period::ClockSkewVerdict::Ok => {} + crate::infra::posting::period::ClockSkewVerdict::Warn => { + tracing::warn!( + tenant_id = %entry.tenant_id, + posted_at_utc = %entry.posted_at_utc, + "clock skew beyond ±15 min on a post" + ); + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::ClockSkew, + severity: crate::infra::events::alarm_catalog::severity( + AlarmCategory::ClockSkew, + ), + tenant_id: entry.tenant_id, + scope: format!( + "tenant:{}/flow:{}/business:{}", + entry.tenant_id, + entry.source_doc_type.as_str(), + entry.source_business_id + ), + code: "CLOCK_SKEW".to_owned(), + detail: format!( + "posted_at_utc {} skewed >±15 min from server clock", + entry.posted_at_utc + ), + affected: Vec::new(), + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + } + crate::infra::posting::period::ClockSkewVerdict::Reject => { + return Err(DomainError::ClockSkewQuarantine(format!( + "posted_at_utc {} skewed >±24 h from server clock", + entry.posted_at_utc + ))); + } + } + + // Account lifecycle + normal_sides are read BEFORE the transaction: + // the repos open their own (non-transactional) connection, which + // Postgres forbids inside an active transaction. A closed account fails + // fast here with no writes. ACCEPTED LIMITATION: this read is outside the + // serializable snapshot, so an account closed CONCURRENTLY (after this + // read, before COMMIT) is not detected — unlike the in-txn period gate. + // Tolerable: account close is a rare admin op and `normal_side` is + // immutable; an in-txn account re-pin is a tracked follow-up. + let normal_sides = self + .load_normal_sides(scope, &lines) + .await + .map_err(|e| decode_post_error(&e))?; + + // Capture alarm-scope fields before `entry`/`lines` move into the + // closure (same reason `ctx` is cloned below) — used out-of-band on + // the Err path; internal ids only, no PII. + let alarm_tenant = entry.tenant_id; + let alarm_flow = entry.source_doc_type.as_str().to_owned(); + let alarm_business = entry.source_business_id.clone(); + + // --- TRANSACTION --- + // Clone the engine + scope into the closure so the `transaction` + // borrow of `self.db` does not conflict with the captured engine. + let svc = self.clone(); + let scope = scope.clone(); + // Owned clone for the `move` closure; the borrowed `ctx` param stays + // usable on the Err arm below (out-of-band alarm). + let ctx_txn = ctx.clone(); + // SERIALIZABLE + retry: period close runs SERIALIZABLE too, so an + // overlapping post/close pair conflicts under Postgres SSI and the loser + // retries here — close can never certify a period this entry lands in. + // The body is `FnMut` (re-clones its inputs per attempt); the post is + // idempotent across attempts (the dedup claim re-runs from a fresh txn). + // Business rejections carry a non-retryable sentinel `DbErr`, so only + // genuine contention retries. + let result = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let svc = svc.clone(); + let ctx_txn = ctx_txn.clone(); + let scope = scope.clone(); + let entry = entry.clone(); + let lines = lines.clone(); + let normal_sides = normal_sides.clone(); + // `Arc` is cheap to clone per attempt; the + // sidecar's writes re-run from a fresh txn on a retry, mirroring + // the idempotent post body. + let sidecar = sidecar.clone(); + // `ClaimSpec` is re-cloned per attempt (like `entry`/`lines`), + // since the retry body is `FnMut` and it carries an owned hash. + let claim = claim.clone(); + Box::pin(async move { + svc.post_in_txn( + &ctx_txn, + txn, + &scope, + entry, + lines, + normal_sides, + sidecar, + claim, + ) + .await + }) + }) + .await; + + match result { + Ok(PostOutcome::Posted { + entry_id, + created_seq, + }) => Ok(PostingRef { + entry_id, + created_seq, + replayed: false, + }), + Ok(PostOutcome::Replay { entry_id }) => Ok(PostingRef { + // A replay carries the prior, finalized entry id (the in-txn + // body only yields Replay once it has confirmed the dedup row + // is POSTED with a real id — never the nil UUID). The sequence + // is not re-read here; replay callers key on the id. + entry_id, + created_seq: 0, + replayed: true, + }), + Err(db_err) => { + let err = decode_post_error(&db_err); + // Out-of-band invariant alarm: fire-and-forget on a separate + // committed connection, so it survives the rolled-back post and + // never changes the error returned to the caller. + if let Some((category, severity, code)) = alarm_for(&err) { + let alarm = LedgerInvariantAlarm { + category, + severity, + tenant_id: alarm_tenant, + scope: format!( + "tenant:{alarm_tenant}/flow:{alarm_flow}/business:{alarm_business}" + ), + code: code.to_owned(), + detail: err.to_string(), // internal diagnostic — no PII + // Hot posting-path rejection: the single offending entry + // is named in `scope`/`detail`; no per-grain list. + affected: Vec::new(), + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + } + Err(err) + } + } + } + + /// The in-transaction posting body. Business rejections are encoded as a + /// sentinel `DbError` so the closure error type stays `DbError` while + /// still forcing a rollback. + // Threads the post's distinct inputs (entry / lines / normal_sides / sidecar) + // through the serializable retry closure; the claim mode + request-hash are + // bundled into `ClaimSpec`, so this stays just over the lint's arg ceiling. + #[allow(clippy::too_many_arguments)] + async fn post_in_txn( + &self, + ctx: &SecurityContext, + txn: &toolkit_db::secure::DbTx<'_>, + scope: &AccessScope, + entry: NewEntry, + lines: Vec, + normal_sides: HashMap, + sidecar: Option>, + claim: ClaimSpec, + ) -> Result { + let ClaimSpec { + mode: claim_mode, + payload_hash_override, + } = claim; + let tenant = entry.tenant_id; + let legal_entity = entry.legal_entity_id; + let flow = entry.source_doc_type.as_str().to_owned(); + let business_id = entry.source_business_id.clone(); + let period_id = entry.period_id.clone(); + + // 1. + 2. Idempotency gate. `Fresh` claims the dedup row (the unchanged + // inline path); `QueuedApply` reads the row already claimed `QUEUED` at + // intake. Both yield "proceed" (fall through to step 3) or an early + // `Replay`; only the gate mechanics differ, so the whole tail below is + // shared. Only `Fresh` needs the payload hash (the claim + the replay's + // conflict guard); `QueuedApply` reads the row and re-derives the entry + // from the same queued payload, so the hash is computed lazily in the + // `Fresh` arm. + match claim_mode { + ClaimMode::Fresh => { + // `Fresh` keys the dedup claim on this hash; a payment orchestrator + // may override it with a REQUEST-based hash (stable across its + // state-dependent entry rebuild) so its replay short-circuit can + // compare against it. + let payload_hash = payload_hash_override + .unwrap_or_else(|| IdempotencyGate::payload_hash(&entry, &lines)); + match self + .idempotency + .claim(txn, tenant, &flow, &business_id, &payload_hash) + .await + .map_err(repo_to_db)? + { + ClaimOutcome::Replay(row) => { + if row.payload_hash != payload_hash { + return Err(business(DomainError::IdempotencyConflict( + "idempotency key reused with a different payload".to_owned(), + ))); + } + // A matching-hash replay must reference a FINALIZED post. + // The winner's claim + finalize are atomic in one + // transaction, and the conflicting `INSERT … ON CONFLICT DO + // NOTHING` blocks a concurrent claimer until that + // transaction commits — so a committed dedup row is always + // POSTED with a result id. Guard the invariant: never hand + // back the nil UUID for a row that is somehow not + // finalized; surface an infra fault so the caller retries + // instead of keying on a phantom entry. + return match row.result_entry_id { + Some(entry_id) if row.status == STATUS_POSTED => { + Ok(PostOutcome::Replay { entry_id }) + } + _ => Err(infra(format!( + "idempotency replay race: dedup row {tenant}/{flow}/{business_id} \ + not finalized (status={}, has_result={})", + row.status, + row.result_entry_id.is_some() + ))), + }; + } + ClaimOutcome::Claimed => {} + } + } + ClaimMode::QueuedApply => { + // The dedup row was claimed `QUEUED` at intake — DON'T re-claim, + // `read` it in-txn. A `POSTED` row with a result id is an + // idempotent re-drive of an already-applied item (return the prior + // entry as a replay — the queue-flip sidecar is a no-op a second + // time since the row is already terminal, but a re-drive normally + // never reaches here because the queue row is no longer claimable). + // A `QUEUED` row falls through to the shared tail (period gate → + // insert → project → sidecar → finalize), and `finalize` flips it + // `QUEUED → POSTED` exactly as it flips `CLAIMED`. Anything else (no + // row, or `CLAIMED`) is an invariant breach for a queued apply. + match self + .idempotency + .read(txn, tenant, &flow, &business_id) + .await + .map_err(repo_to_db)? + { + Some(PostingRefRow { + status, + result_entry_id: Some(entry_id), + .. + }) if status == STATUS_POSTED => { + return Ok(PostOutcome::Replay { entry_id }); + } + Some(row) if row.status == STATUS_QUEUED => {} + other => { + return Err(infra(format!( + "queued-apply on a non-QUEUED dedup row {tenant}/{flow}/{business_id} \ + (status={}, has_result={})", + other.as_ref().map_or("", |r| r.status.as_str()), + other.as_ref().is_some_and(|r| r.result_entry_id.is_some()), + ))); + } + } + } + } + + // 2b. Tamper-freeze gate (fail fast, BEFORE any write): if the integrity + // verifier froze this scope (a broken tamper-evidence chain), reject a + // FRESH post into it with `TamperVerificationFailed` before the + // append-only insert takes write locks. A replay returned above, so it + // is never blocked; the freeze read is in-txn, so a concurrent + // freeze/post pair conflicts under SSI like the period gate. + self.freeze.check(txn, scope, tenant, &period_id).await?; + + // 3. Fiscal-period gate (fail fast, BEFORE any write): a post into a + // CLOSED/absent period is rejected here, before the append-only insert + // takes write locks on journal_entry/journal_line. pin_open reads the + // period row in-txn, so the post/close SSI conflict is unchanged. + match self + .period + .pin_open(txn, tenant, legal_entity, &period_id) + .await + { + Ok(()) => {} + Err(PeriodError::Closed) => { + return Err(business(DomainError::PeriodClosed( + "fiscal period is closed or absent".to_owned(), + ))); + } + Err(PeriodError::Db(e)) => return Err(infra(format!("period guard: {e}"))), + } + + // Keep clones for the projector (the insert consumes the originals). + let entry_for_proj = entry.clone(); + let lines_for_proj = lines.clone(); + + // 3b. Policy-version guard (§4.6, AC #15): a correction (one whose header + // carries `reverses_entry_id`) MUST REUSE the original posting's pinned + // evidence refs, never invent new ones. Read-only over the original's + // lines, so it slots in after the period gate and before the insert — it + // takes no write lock. A fresh original posting (`reverses_entry_id` None) + // is a no-op fast-exit. A correction that invents a pinned ref the + // original never had is rejected with `PolicyVersionViolation`. + self.policy + .check(txn, scope, &entry_for_proj, &lines_for_proj) + .await?; + + // 4. Append-only insert of the header + lines. + let entry_ref = self + .journal + .insert_entry_with_lines(txn, entry, lines) + .await + .map_err(repo_to_db)?; + + // 6 / 6b. Balance projection + in-transaction sidecar (non-replay path + // only — a replay, in either `ClaimMode`, returns above). Their ORDER + // depends on the sidecar: a refund / claw-back sidecar + // (`run_before_projection() == true`) runs its rank-1 money-out cap / + // underflow CHECK BEFORE projection, so an over-refund / out-of-order + // claw-back surfaces as the canonical `RefundExceedsSettled` / + // `RefundClawbackDeferred` instead of the projector's structural + // no-negative guard tripping first with a raw `NegativeBalance`. Every + // other post projects first, then runs its sidecar. Either way both run + // AFTER the insert and BEFORE finalize, committing atomically with the + // journal entry; an `Err` is encoded as a non-retryable business sentinel + // that rolls the whole post back. On the `QueuedApply` path the + // (project-first) sidecar marks the queue row `→APPLIED` alongside the + // allocation counter writes. + let posted_facts = PostedFacts { + entry_id: entry_ref.entry_id, + created_seq: entry_ref.created_seq, + }; + let sidecar_before = sidecar + .as_ref() + .is_some_and(|sc| sc.run_before_projection()); + + if sidecar_before && let Some(sc) = &sidecar { + sc.run(txn, scope, &posted_facts).await.map_err(business)?; + } + + self.projector + .project( + txn, + scope, + &entry_for_proj, + &lines_for_proj, + &normal_sides, + entry_ref.created_seq, + ) + .await + .map_err(project_to_db)?; + + if !sidecar_before && let Some(sc) = &sidecar { + sc.run(txn, scope, &posted_facts).await.map_err(business)?; + } + + // 6c. Seal the tamper-evidence chain: link this entry onto the tenant's + // tip (or genesis) and advance the tip. The single from-NULL chain-only + // UPDATE is the one mutation the relaxed append-only trigger permits. + // A failure rolls the whole post back via `?` like the other steps. + self.chain + .seal(txn, scope, &entry_for_proj, &lines_for_proj, &entry_ref) + .await?; + + // 7. Finalize the dedup row before COMMIT — flips `CLAIMED → POSTED` + // (Fresh) or `QUEUED → POSTED` (QueuedApply); the update is keyed on the + // PK and is status-agnostic, so it stamps the result id either way. + self.idempotency + .finalize( + txn, + tenant, + &flow, + &business_id, + entry_ref.entry_id, + entry_ref.created_seq, + ) + .await + .map_err(repo_to_db)?; + + // 8. Publish `billing.ledger.entry.posted` into the SAME transaction + // (transactional outbox): the event row commits atomically with the + // entry, or a publish failure rolls the whole post back via `infra`. + // Non-replay path only — a replay (either mode) returns above without + // publishing (the entry was already announced on its first post). + let posted_event = LedgerEntryPosted { + entry_id: entry_ref.entry_id, + tenant_id: entry_for_proj.tenant_id, + legal_entity_id: entry_for_proj.legal_entity_id, + period_id: entry_for_proj.period_id.clone(), + source_doc_type: entry_for_proj.source_doc_type.as_str().to_owned(), + source_business_id: entry_for_proj.source_business_id.clone(), + posted_at_utc: entry_for_proj.posted_at_utc, + created_seq: entry_ref.created_seq, + lines: lines_for_proj + .iter() + .map(|l| LedgerLineSummary { + account_class: l.account_class.as_str().to_owned(), + side: l.side.as_str().to_owned(), + amount_minor: l.amount_minor, + currency: l.currency.clone(), + currency_scale: l.currency_scale, + }) + .collect(), + }; + self.publisher + .publish_entry_posted(ctx, txn, posted_event) + .await + .map_err(|e| infra(format!("{e}")))?; + + // 9. COMMIT happens when the closure returns Ok. + Ok(PostOutcome::Posted { + entry_id: entry_ref.entry_id, + created_seq: entry_ref.created_seq, + }) + } + + /// Load each DISTINCT account's `normal_side`, asserting it is provisioned + /// and `OPEN`. Absent or non-OPEN → an encoded `AccountClosed` business + /// error. + async fn load_normal_sides( + &self, + scope: &AccessScope, + lines: &[NewLine], + ) -> Result, DbError> { + let mut normal_sides: HashMap = HashMap::new(); + for line in lines { + if normal_sides.contains_key(&line.account_id) { + continue; + } + let account = self + .reference + .find_account(scope, line.account_id) + .await + .map_err(repo_to_db)?; + let Some(account) = account else { + return Err(business(DomainError::AccountClosed(format!( + "account {} is not provisioned", + line.account_id + )))); + }; + if account.lifecycle_state != LIFECYCLE_OPEN { + return Err(business(DomainError::AccountClosed(format!( + "account {} is not OPEN", + line.account_id + )))); + } + let side = match account.normal_side.as_str() { + "DR" => Side::Debit, + "CR" => Side::Credit, + other => { + return Err(infra(format!( + "account {} has an invalid normal_side {other:?}", + line.account_id + ))); + } + }; + normal_sides.insert(line.account_id, side); + } + Ok(normal_sides) + } +} + +/// Map a [`RepoError`](crate::domain::model::RepoError) into the sentinel +/// `DbError`: a repo db/row failure is an infrastructure fault; a +/// scale-locked / out-of-range rejection maps to its domain variant. +pub(crate) fn repo_to_db(e: crate::domain::model::RepoError) -> DbError { + use crate::domain::model::RepoError; + match e { + RepoError::CurrencyScaleLocked(c) => business(DomainError::CurrencyScaleLocked(format!( + "currency scale locked: {c}" + ))), + // A wrong per-line scale changes the implied magnitude → out-of-range + // (wire `AMOUNT_OUT_OF_RANGE`), preserving the prior posting contract. + RepoError::ScaleOutOfRange(c) => business(DomainError::AmountOutOfRange(format!( + "scale out of range: {c}" + ))), + other => infra(other.to_string()), + } +} + +/// Map a [`ProjectError`] into the sentinel `DbError`. +fn project_to_db(e: ProjectError) -> DbError { + match e { + ProjectError::NegativeBalance { + account_id, + balance_minor, + } => business(DomainError::NegativeBalance(format!( + "balance for account {account_id} would go negative ({balance_minor})" + ))), + // Should not happen — every account's side is loaded in step 5. + ProjectError::MissingNormalSide(id) => business(DomainError::AccountClosed(format!( + "missing normal_side for account {id}" + ))), + // Invariant breach (builders always set the bucket) — surface as Internal + // rather than silently keying a phantom "" wallet sub-balance. + ProjectError::MissingCreditEventType(id) => business(DomainError::Internal(format!( + "REUSABLE_CREDIT line {id} missing credit_grant_event_type" + ))), + // A coalesced money delta overflowed i64 — a clean amount-class rejection + // (422), not a 500: surface as the business error the adjustments path + // already uses for out-of-range amounts. + ProjectError::Overflow { + account_id, + currency, + field, + } => business(DomainError::AmountOutOfRange(format!( + "coalesced money delta overflowed i64 for account {account_id} ({currency}, {field})" + ))), + ProjectError::Db(e) => infra(format!("projector: {e}")), + } +} + +/// Encode a business [`DomainError`] as a sentinel `DbError` so the transaction +/// closure (whose error type is fixed to `DbError`) rolls back yet preserves +/// the rejection for decoding after `transaction()` returns. The payload is a +/// `DbErr::Custom`, which the contention classifier treats as NON-retryable — +/// so a business rejection propagates immediately and is never retried. +pub(crate) fn business(err: DomainError) -> DbError { + let (tag, detail) = domain_parts(err); + DbError::Sea(DbErr::Custom(format!( + "{SENTINEL_TAG}{SENTINEL_SEP}{tag}{SENTINEL_SEP}{detail}" + ))) +} + +/// Encode an internal (infrastructure) failure as a non-sentinel `DbError`. +pub(crate) fn infra(message: impl Into) -> DbError { + DbError::Sea(DbErr::Custom(message.into())) +} + +/// Decode a `DbError` returned from a `transaction_with_retry` back into a +/// [`DomainError`]: a sentinel-tagged `DbErr::Custom` (written by [`business`]) +/// yields the original business rejection; any other `DbError` is an +/// infrastructure fault ([`DomainError::Internal`]). Shared by service paths +/// that run their own sentinel-carrying transaction (e.g. the audit-surface +/// cross-tenant elevation txn) and need the post path's decode semantics. +pub(crate) fn decode_business_error(db_err: &DbError) -> DomainError { + decode_post_error(db_err) +} + +/// Decode a `DbError` returned from `transaction()` back into a [`DomainError`]: +/// a sentinel-tagged `DbErr::Custom` yields the original business rejection; any +/// other `DbError` is an infrastructure fault ([`DomainError::Internal`]). +fn decode_post_error(db_err: &DbError) -> DomainError { + if let DbError::Sea(DbErr::Custom(payload)) = db_err + && let Some(rest) = payload.strip_prefix(&format!("{SENTINEL_TAG}{SENTINEL_SEP}")) + && let Some((tag, detail)) = rest.split_once(SENTINEL_SEP) + { + return domain_from_parts(tag, detail.to_owned()); + } + // A concurrent wallet over-draw that slips past the app-level pre-check trips + // the DB no-negative CHECK on reusable_credit_subbalance; surface it as the + // clean CreditExceedsWallet (→409) rather than an opaque Internal (500). No + // money is lost (the CHECK held); only the wire surface is corrected. + if db_err + .to_string() + .contains("chk_reusable_credit_subbalance_no_negative") + { + return DomainError::CreditExceedsWallet( + "concurrent wallet over-draw rejected by the no-negative guard".to_owned(), + ); + } + DomainError::Internal(db_err.to_string()) +} + +/// Map a decoded [`DomainError`] to its out-of-band invariant alarm +/// (category, severity, wire code), or `None` for ordinary client rejections +/// that raise no alarm. +fn alarm_for(err: &DomainError) -> Option<(AlarmCategory, AlarmSeverity, &'static str)> { + // Only the (category, wire code) pair is named per variant; the severity is + // ALWAYS taken from the normative §4.7 catalog (`alarm_catalog::severity`) so + // the emitter can never drift from it (e.g. an idempotency-key collision is + // Critical per AC #19, not Warn). + let (category, code) = match err { + DomainError::IdempotencyConflict(_) => ( + AlarmCategory::IdempotencyPayloadConflict, + "IDEMPOTENCY_PAYLOAD_CONFLICT", + ), + DomainError::NegativeBalance(_) => ( + AlarmCategory::NegativeBalanceViolation, + "NEGATIVE_BALANCE_VIOLATION", + ), + // The per-schedule over-recognition cap CHECK rejected a release (Slice 4 + // §4.3 / §6): the cumulative recognized would exceed the deferred total. + // Fires out-of-band on the rolled-back release, exactly like the + // negative-balance guard (the recognition stamp sidecar surfaces this as + // `OverRecognition`). Severity is taken from the §4.7 catalog (Critical). + DomainError::OverRecognition(_) => (AlarmCategory::OverRecognition, "OVER_RECOGNITION"), + _ => return None, + }; + Some(( + category, + crate::infra::events::alarm_catalog::severity(category), + code, + )) +} + +/// Split a [`DomainError`] into a stable per-variant tag + its detail for the +/// sentinel round-trip. Exhaustive, so a new variant forces a tag here; the +/// tags are internal to the post txn (encoded and decoded in this module only). +fn domain_parts(err: DomainError) -> (&'static str, String) { + use DomainError as D; + match err { + D::Unbalanced(d) => ("Unbalanced", d), + D::Empty(d) => ("Empty", d), + D::MixedPayer(d) => ("MixedPayer", d), + D::MissingPayer(d) => ("MissingPayer", d), + D::MixedLegalEntity(d) => ("MixedLegalEntity", d), + D::InconsistentScale(d) => ("InconsistentScale", d), + D::AmountOutOfRange(d) => ("AmountOutOfRange", d), + D::EntryTooLarge(d) => ("EntryTooLarge", d), + D::InvalidRequest(d) => ("InvalidRequest", d), + D::ScaleOutOfRange(d) => ("ScaleOutOfRange", d), + D::CreditResidualUndisposed(d) => ("CreditResidualUndisposed", d), + D::MoneyOutCapExceeded(d) => ("MoneyOutCapExceeded", d), + D::AllocationTooLarge(d) => ("AllocationTooLarge", d), + D::AllocationCurrencyMismatch(d) => ("AllocationCurrencyMismatch", d), + D::CurrencyMismatch(d) => ("CurrencyMismatch", d), + // FX rate errors are raised pre-post (rate-lock); listed for the + // exhaustive-match contract only (they never ride the sentinel). + D::FxRateUnavailable(d) => ("FxRateUnavailable", d), + D::FxRateStaleNotAllowed(d) => ("FxRateStaleNotAllowed", d), + D::AllocationSplitInvalid(d) => ("AllocationSplitInvalid", d), + D::GrantExceedsUnallocated(d) => ("GrantExceedsUnallocated", d), + D::CreditExceedsOpenAr(d) => ("CreditExceedsOpenAr", d), + D::CreditExceedsWallet(d) => ("CreditExceedsWallet", d), + D::ScheduleTooLong(d) => ("ScheduleTooLong", d), + D::SspSnapshotRequired(d) => ("SspSnapshotRequired", d), + D::MissingPoAllocationGroup(d) => ("MissingPoAllocationGroup", d), + D::RecognitionPolicyConflict(d) => ("RecognitionPolicyConflict", d), + D::CreditNoteSplitAmbiguous(d) => ("CreditNoteSplitAmbiguous", d), + D::CreditNoteExceedsHeadroom(d) => ("CreditNoteExceedsHeadroom", d), + // The refund cap CHECKs fire INSIDE the post txn (the RefundPostSidecar's + // counter increments), so these ride the sentinel to surface to the caller. + D::RefundExceedsSettled(d) => ("RefundExceedsSettled", d), + D::RefundExceedsAllocated(d) => ("RefundExceedsAllocated", d), + D::ModificationTreatmentReview(d) => ("ModificationTreatmentReview", d), + D::RecognitionWithoutInvoiceLink(d) => ("RecognitionWithoutInvoiceLink", d), + D::PiiInMetadataValue(d) => ("PiiInMetadataValue", d), + D::MissingInvestigationReason(d) => ("MissingInvestigationReason", d), + D::CrossTenantAccessDenied(d) => ("CrossTenantAccessDenied", d), + // Governed manual adjustment rejected by the §4.6 governor (allow-list / + // write-off guard). Decided BEFORE the post (the handler runs `govern` + // out-of-txn), so it never actually rides the sentinel — listed for the + // exhaustive match contract. + D::ManualAdjustmentNotAllowed(d) => ("ManualAdjustmentNotAllowed", d), + D::PeriodClosed(d) => ("PeriodClosed", d), + D::AccountClosed(d) => ("AccountClosed", d), + D::PayerClosed(d) => ("PayerClosed", d), + D::AccountMappingMissing(d) => ("AccountMappingMissing", d), + D::NegativeBalance(d) => ("NegativeBalance", d), + D::SettlementReturnOverAllocated(d) => ("SettlementReturnOverAllocated", d), + D::InvalidDisputeTransition(d) => ("InvalidDisputeTransition", d), + D::ChargebackExceedsSettled(d) => ("ChargebackExceedsSettled", d), + D::ChargebackOnRefunded(d) => ("ChargebackOnRefunded", d), + D::ClockSkewQuarantine(d) => ("ClockSkewQuarantine", d), + D::PeriodNotOpen(d) => ("PeriodNotOpen", d), + D::PeriodCloseBlocked(d) => ("PeriodCloseBlocked", d), + D::PeriodCloseInProgress(d) => ("PeriodCloseInProgress", d), + D::IdempotencyConflict(d) => ("IdempotencyConflict", d), + D::CurrencyScaleLocked(d) => ("CurrencyScaleLocked", d), + D::OverRecognition(d) => ("OverRecognition", d), + // Group E: a claw-back whose money-out decrement would underflow is raised + // by the refund post sidecar and MUST round-trip unchanged (the handler + // matches on it to DEFER the claw-back to the queue, not hard-fail). + D::RefundClawbackDeferred(d) => ("RefundClawbackDeferred", d), + // Cross-currency unsupported-op reject (Slice 5): guarded BEFORE the post + // (claw-back in the refund handler, mapping-correction in the REST handler), + // so it never rides the sentinel — listed for the exhaustive match contract. + D::FxOperationUnsupported(d) => ("FxOperationUnsupported", d), + // The dispute-hold gate runs OUT-OF-TXN in the refund handler BEFORE the + // post (the open dispute is read out-of-txn), so it never actually rides the + // sentinel; listed for the exhaustive match contract (Z5-2). + D::RefundDisputeHeld(d) => ("RefundDisputeHeld", d), + D::DualControlRequired(d) => ("DualControlRequired", d), + D::SelfApprovalForbidden(d) => ("SelfApprovalForbidden", d), + D::ApprovalNotActionable(d) => ("ApprovalNotActionable", d), + D::DualControlPolicyOutOfRange(d) => ("DualControlPolicyOutOfRange", d), + D::TamperVerificationFailed(d) => ("TamperVerificationFailed", d), + D::PolicyVersionViolation(d) => ("PolicyVersionViolation", d), + D::TenantPostingLocked(d) => ("TenantPostingLocked", d), + D::PeriodNotFound(d) => ("PeriodNotFound", d), + D::ApprovalNotFound(d) => ("ApprovalNotFound", d), + D::PayerPiiNotFound(d) => ("PayerPiiNotFound", d), + // Guarded in the credit/debit-note handlers BEFORE the post, so it never + // actually rides the sentinel — listed for the exhaustive match contract. + D::NoteInvoiceNotFound(d) => ("NoteInvoiceNotFound", d), + // Likewise guarded in the refund handler BEFORE the post (the origin + // settlement is resolved out-of-txn); listed for the exhaustive contract. + D::RefundOriginNotFound(d) => ("RefundOriginNotFound", d), + D::Internal(d) => ("Internal", d), + } +} + +/// Reconstruct a [`DomainError`] from a sentinel tag + detail; an unrecognised +/// tag degrades to [`DomainError::Internal`] (never silently dropped). +fn domain_from_parts(tag: &str, detail: String) -> DomainError { + use DomainError as D; + match tag { + "Unbalanced" => D::Unbalanced(detail), + "Empty" => D::Empty(detail), + "MixedPayer" => D::MixedPayer(detail), + "MissingPayer" => D::MissingPayer(detail), + "MixedLegalEntity" => D::MixedLegalEntity(detail), + "InconsistentScale" => D::InconsistentScale(detail), + "AmountOutOfRange" => D::AmountOutOfRange(detail), + "EntryTooLarge" => D::EntryTooLarge(detail), + "InvalidRequest" => D::InvalidRequest(detail), + "ScaleOutOfRange" => D::ScaleOutOfRange(detail), + "CreditResidualUndisposed" => D::CreditResidualUndisposed(detail), + "MoneyOutCapExceeded" => D::MoneyOutCapExceeded(detail), + "AllocationTooLarge" => D::AllocationTooLarge(detail), + "AllocationCurrencyMismatch" => D::AllocationCurrencyMismatch(detail), + "CurrencyMismatch" => D::CurrencyMismatch(detail), + "FxRateUnavailable" => D::FxRateUnavailable(detail), + "FxRateStaleNotAllowed" => D::FxRateStaleNotAllowed(detail), + "AllocationSplitInvalid" => D::AllocationSplitInvalid(detail), + "GrantExceedsUnallocated" => D::GrantExceedsUnallocated(detail), + "CreditExceedsOpenAr" => D::CreditExceedsOpenAr(detail), + "CreditExceedsWallet" => D::CreditExceedsWallet(detail), + "ScheduleTooLong" => D::ScheduleTooLong(detail), + "SspSnapshotRequired" => D::SspSnapshotRequired(detail), + "MissingPoAllocationGroup" => D::MissingPoAllocationGroup(detail), + "RecognitionPolicyConflict" => D::RecognitionPolicyConflict(detail), + "CreditNoteSplitAmbiguous" => D::CreditNoteSplitAmbiguous(detail), + "CreditNoteExceedsHeadroom" => D::CreditNoteExceedsHeadroom(detail), + "RefundExceedsSettled" => D::RefundExceedsSettled(detail), + "RefundExceedsAllocated" => D::RefundExceedsAllocated(detail), + "ModificationTreatmentReview" => D::ModificationTreatmentReview(detail), + "RecognitionWithoutInvoiceLink" => D::RecognitionWithoutInvoiceLink(detail), + "PiiInMetadataValue" => D::PiiInMetadataValue(detail), + "MissingInvestigationReason" => D::MissingInvestigationReason(detail), + "CrossTenantAccessDenied" => D::CrossTenantAccessDenied(detail), + "PeriodClosed" => D::PeriodClosed(detail), + "AccountClosed" => D::AccountClosed(detail), + "PayerClosed" => D::PayerClosed(detail), + "AccountMappingMissing" => D::AccountMappingMissing(detail), + "NegativeBalance" => D::NegativeBalance(detail), + "SettlementReturnOverAllocated" => D::SettlementReturnOverAllocated(detail), + "InvalidDisputeTransition" => D::InvalidDisputeTransition(detail), + "ChargebackExceedsSettled" => D::ChargebackExceedsSettled(detail), + "ChargebackOnRefunded" => D::ChargebackOnRefunded(detail), + "ClockSkewQuarantine" => D::ClockSkewQuarantine(detail), + "PeriodNotOpen" => D::PeriodNotOpen(detail), + "PeriodCloseBlocked" => D::PeriodCloseBlocked(detail), + "PeriodCloseInProgress" => D::PeriodCloseInProgress(detail), + "IdempotencyConflict" => D::IdempotencyConflict(detail), + "CurrencyScaleLocked" => D::CurrencyScaleLocked(detail), + "OverRecognition" => D::OverRecognition(detail), + "RefundClawbackDeferred" => D::RefundClawbackDeferred(detail), + "FxOperationUnsupported" => D::FxOperationUnsupported(detail), + "RefundDisputeHeld" => D::RefundDisputeHeld(detail), + "DualControlRequired" => D::DualControlRequired(detail), + "SelfApprovalForbidden" => D::SelfApprovalForbidden(detail), + "ApprovalNotActionable" => D::ApprovalNotActionable(detail), + "DualControlPolicyOutOfRange" => D::DualControlPolicyOutOfRange(detail), + "TamperVerificationFailed" => D::TamperVerificationFailed(detail), + "PolicyVersionViolation" => D::PolicyVersionViolation(detail), + "TenantPostingLocked" => D::TenantPostingLocked(detail), + "PeriodNotFound" => D::PeriodNotFound(detail), + "ApprovalNotFound" => D::ApprovalNotFound(detail), + "PayerPiiNotFound" => D::PayerPiiNotFound(detail), + "NoteInvoiceNotFound" => D::NoteInvoiceNotFound(detail), + "RefundOriginNotFound" => D::RefundOriginNotFound(detail), + "ManualAdjustmentNotAllowed" => D::ManualAdjustmentNotAllowed(detail), + _ => D::Internal(detail), + } +} + +#[cfg(test)] +mod tests { + use super::{AlarmSeverity, DomainError, alarm_for}; + use crate::infra::events::alarm_catalog; + + /// The emitter MUST take its severity from the normative §4.7 catalog. An + /// idempotency-key collision is Critical per AC #19 — the regression guard + /// for the prior hardcoded `Warn`. + #[test] + fn idempotency_conflict_alarm_is_catalog_critical() { + let (category, severity, code) = + alarm_for(&DomainError::IdempotencyConflict("dup".to_owned())) + .expect("an idempotency conflict raises an alarm"); + assert_eq!(code, "IDEMPOTENCY_PAYLOAD_CONFLICT"); + assert_eq!( + severity.as_str(), + AlarmSeverity::Critical.as_str(), + "an idempotency-key collision is Critical (AC #19), not Warn" + ); + assert_eq!( + severity.as_str(), + alarm_catalog::severity(category).as_str(), + "the emitter severity must equal the catalog severity" + ); + } + + /// Every variant `alarm_for` maps MUST carry the catalog's severity — pins + /// the no-drift property generally, not just for idempotency. + #[test] + fn emitter_severity_always_matches_catalog() { + for err in [ + DomainError::IdempotencyConflict("x".to_owned()), + DomainError::NegativeBalance("x".to_owned()), + ] { + let (category, severity, _) = alarm_for(&err).expect("variant raises an alarm"); + assert_eq!( + severity.as_str(), + alarm_catalog::severity(category).as_str() + ); + } + } +} diff --git a/gears/bss/ledger/ledger/src/infra/provisioning.rs b/gears/bss/ledger/ledger/src/infra/provisioning.rs new file mode 100644 index 000000000..c4dce3270 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/provisioning.rs @@ -0,0 +1,3 @@ +//! Infrastructure provisioning: the transactional seller-provisioning seed. + +pub mod service; diff --git a/gears/bss/ledger/ledger/src/infra/provisioning/service.rs b/gears/bss/ledger/ledger/src/infra/provisioning/service.rs new file mode 100644 index 000000000..72e8f9e97 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/provisioning/service.rs @@ -0,0 +1,251 @@ +//! `ProvisioningService` — the transactional seller-provisioning seed. One +//! sea-orm transaction runs the additive SELECT-then-INSERT of the chart of +//! accounts, non-ISO currency scales, the fiscal-calendar config, and the +//! initial OPEN fiscal period. The fiscal-calendar fields are validated and +//! the initial `period_id` derived BEFORE the transaction opens (pure +//! `domain::provisioning::plan`), so a malformed calendar never takes a write +//! lock. +//! +//! ## Error handling across the transaction boundary +//! +//! [`DBProvider::transaction`] fixes the closure error type to [`DbError`]. +//! The only distinct business rejection that must survive the boundary is a +//! scale that exceeds `i64` headroom ([`RepoError::ScaleOutOfRange`]): the +//! closure encodes it into a sentinel [`DbError::Sea`] (`DbErr::Custom`) and +//! returns `Err`, forcing a rollback; once `transaction()` returns the +//! sentinel is decoded back into [`DomainError::ScaleOutOfRange`]. Every +//! other [`RepoError`]/[`DbError`] is an infrastructure fault and surfaces as +//! [`DomainError::Internal`]. + +use bss_ledger_sdk::{AccountInfo, ProvisionOutcome, ProvisionRequest}; +use chrono::Utc; +use sea_orm::DbErr; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::{ + AccountRow, CurrencyScaleRow, FiscalCalendarRow, FiscalPeriodRow, RepoError, +}; +use crate::domain::provisioning::plan; +use crate::domain::status::{LIFECYCLE_OPEN, PERIOD_STATUS_OPEN}; +use crate::infra::storage::repo::ReferenceRepo; + +/// Record-separator framing a sentinel-encoded scale-out-of-range business +/// error inside a `DbErr::Custom` payload: `BSS_PROV_SCALE_OOR␟`. +const SENTINEL_TAG: &str = "BSS_PROV_SCALE_OOR"; +/// Unit-separator used inside the sentinel payload. +const SENTINEL_SEP: char = '\u{1f}'; + +/// The transactional seller-provisioning service. +#[derive(Clone)] +pub struct ProvisioningService { + db: DBProvider, + reference: ReferenceRepo, +} + +impl ProvisioningService { + /// Build a `ProvisioningService` and its reference repository from one + /// provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + let reference = ReferenceRepo::new(db.clone()); + Self { db, reference } + } + + /// Provision a seller legal-entity in one transaction: additively seed the + /// chart of accounts, non-ISO currency scales, the fiscal-calendar config, + /// and the initial OPEN fiscal period. Idempotent — existing rows are a + /// no-op and reported as "existing". + /// + /// # Errors + /// [`DomainError::InvalidRequest`] on a malformed fiscal calendar; + /// [`DomainError::ScaleOutOfRange`] when a non-ISO scale exceeds the + /// supported headroom; [`DomainError::Internal`] on a storage/ + /// transaction failure. + pub async fn provision(&self, req: ProvisionRequest) -> Result { + // --- PRE-TRANSACTION (fail fast, no writes) --- + plan::validate_calendar(&req.fiscal_calendar)?; + let period_id = plan::initial_period_id(Utc::now()); + + // --- TRANSACTION --- + // Clone the repo + request into the closure so the `transaction` + // borrow of `self.db` does not conflict with the captured repo. + let reference = self.reference.clone(); + let period_id_for_txn = period_id.clone(); + let result = self + .db + .transaction(move |txn| { + Box::pin(async move { + Self::provision_in_txn(&reference, txn, req, period_id_for_txn).await + }) + }) + .await; + + match result { + Ok(outcome) => Ok(outcome), + Err(db_err) => Err(decode_provision_error(&db_err)), + } + } + + /// The in-transaction seed body. The scale-out-of-range business rejection + /// is encoded as a sentinel `DbError` so the closure error type stays + /// `DbError` while still forcing a rollback. + async fn provision_in_txn( + reference: &ReferenceRepo, + txn: &toolkit_db::secure::DbTx<'_>, + req: ProvisionRequest, + period_id: String, + ) -> Result { + let tenant_id = req.tenant_id; + // v1: one legal entity per tenant — the LE is the tenant. + let legal_entity_id = req.tenant_id; + let timezone = req.fiscal_calendar.timezone.clone(); + + // Chart of accounts. + let mut accounts_created: u32 = 0; + let mut accounts_existing: u32 = 0; + // The accounts THIS call creates (re-existing ones are not returned — + // the full chart is discoverable via `list_accounts`). + let mut accounts: Vec = Vec::new(); + for account in req.accounts { + // Capture the coordinate before `account` moves into the row. + let account_class = account.account_class; + let currency = account.currency.clone(); + let revenue_stream = account.revenue_stream.clone(); + let row = AccountRow { + account_id: Uuid::now_v7(), + tenant_id, + legal_entity_id, + account_class: account.account_class.as_str().to_owned(), + currency: account.currency, + revenue_stream: account.revenue_stream, + normal_side: account.normal_side.as_str().to_owned(), + may_go_negative: account.may_go_negative, + lifecycle_state: LIFECYCLE_OPEN.to_owned(), + }; + let (account_id, created) = reference + .insert_account_if_absent_txn(txn, row) + .await + .map_err(repo_to_db)?; + if created { + accounts_created += 1; + accounts.push(AccountInfo { + account_id, + account_class, + currency, + revenue_stream, + lifecycle_state: LIFECYCLE_OPEN.to_owned(), + }); + } else { + accounts_existing += 1; + } + } + + // Non-ISO currency scales. + let mut scales_created: u32 = 0; + let mut scales_existing: u32 = 0; + for scale in req.currency_scales { + let row = CurrencyScaleRow { + tenant_id, + currency: scale.currency, + minor_units: i16::from(scale.minor_units), + plausible_max_major: scale + .plausible_max_major + .unwrap_or(crate::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR), + source: scale.source, + }; + if reference + .insert_currency_scale_if_absent_txn(txn, row) + .await + .map_err(repo_to_db)? + { + scales_created += 1; + } else { + scales_existing += 1; + } + } + + // Fiscal-calendar config. + let calendar_created = reference + .upsert_fiscal_calendar_if_absent_txn( + txn, + FiscalCalendarRow { + tenant_id, + legal_entity_id, + fiscal_tz: timezone.clone(), + granularity: req.fiscal_calendar.granularity.as_str().to_owned(), + fy_start_month: i16::from(req.fiscal_calendar.fy_start_month), + functional_currency: req.fiscal_calendar.functional_currency.clone(), + }, + ) + .await + .map_err(repo_to_db)?; + + // Initial OPEN fiscal period. + let period_created = reference + .insert_fiscal_period_if_absent_txn( + txn, + FiscalPeriodRow { + tenant_id, + legal_entity_id, + period_id: period_id.clone(), + fiscal_tz: timezone, + status: PERIOD_STATUS_OPEN.to_owned(), + }, + ) + .await + .map_err(repo_to_db)?; + + Ok(ProvisionOutcome { + accounts, + accounts_created, + accounts_existing, + scales_created, + scales_existing, + calendar_created, + period_id, + period_created, + }) + } +} + +/// Map a [`RepoError`] into a `DbError` for the transaction closure: a +/// scale-out-of-range rejection is sentinel-encoded (decoded back to +/// [`DomainError::ScaleOutOfRange`]); everything else is an infrastructure +/// fault carried as a plain `DbErr::Custom`. +fn repo_to_db(e: RepoError) -> DbError { + match e { + RepoError::ScaleOutOfRange(currency) => scale_out_of_range(¤cy), + other => infra(other.to_string()), + } +} + +/// Encode a scale-out-of-range business rejection as a sentinel `DbError` so +/// the transaction closure rolls back yet preserves the offending currency for +/// decoding after `transaction()` returns. +fn scale_out_of_range(currency: &str) -> DbError { + DbError::Sea(DbErr::Custom(format!( + "{SENTINEL_TAG}{SENTINEL_SEP}{currency}" + ))) +} + +/// Encode an internal (infrastructure) failure as a non-sentinel `DbError`. +fn infra(message: impl Into) -> DbError { + DbError::Sea(DbErr::Custom(message.into())) +} + +/// Decode a `DbError` returned from `transaction()` back into a +/// [`DomainError`]: a sentinel-tagged `DbErr::Custom` yields +/// [`DomainError::ScaleOutOfRange`]; any other `DbError` is an +/// infrastructure fault. +fn decode_provision_error(db_err: &DbError) -> DomainError { + if let DbError::Sea(DbErr::Custom(payload)) = db_err + && let Some(currency) = payload.strip_prefix(&format!("{SENTINEL_TAG}{SENTINEL_SEP}")) + { + return DomainError::ScaleOutOfRange(format!( + "currency {currency} scale exceeds the supported headroom" + )); + } + DomainError::Internal(db_err.to_string()) +} diff --git a/gears/bss/ledger/ledger/src/infra/recognition.rs b/gears/bss/ledger/ledger/src/infra/recognition.rs new file mode 100644 index 000000000..4f7f15c81 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/recognition.rs @@ -0,0 +1,37 @@ +//! Infra-side ASC 606 recognition (Slice 4): the DB-touching half of the +//! recognition flow whose pure derivation lives in +//! [`crate::domain::recognition`]. Group C ships the in-transaction +//! materialization sidecar; Group D adds the release runner; the Phase 3 +//! disaggregation read API is NOT here. +//! +//! - [`sidecar`] — [`sidecar::ScheduleBuilderSidecar`], the [`PostSidecar`] that +//! materializes the derived `recognition_schedule` + `recognition_segment` rows +//! in the SAME serializable transaction as the invoice post's +//! `CR CONTRACT_LIABILITY` credit, idempotent on the `SCHEDULE_BUILD` claim; +//! [`sidecar::RecognitionStampSidecar`], the release-side [`PostSidecar`] +//! that bumps `recognized_minor` (under the over-recognition cap CHECK) and +//! stamps the segment `DONE` in the same txn as the `DR CL / CR Revenue` post; +//! plus [`sidecar::RecognitionReversalSidecar`] (Group F1), the clawback-side +//! [`PostSidecar`] that DECREMENTS `recognized_minor` (under the non-negative +//! cap CHECK) in the same txn as the compensating `DR Revenue / CR CL` post, +//! leaving the reversed segment `DONE`. +//! - [`runner`] — [`runner::RecognitionRunner`], the S6 release mechanism +//! (Group D): posts one balanced `DR CONTRACT_LIABILITY / CR REVENUE` entry per +//! due `PENDING` segment through the Slice 1 [`PostingService`], idempotent per +//! `(tenant, RECOGNITION, schedule_id:segment_no)`; plus the reversal mechanism +//! (Group F1) keyed `schedule_id:segment_no:reversal`, the §9 recognition +//! metrics, and the `RECOGNITION_PERIOD_QUEUED` / `RECOGNITION_DOUBLE_CREDIT` +//! alarms. +//! - [`run_service`] — [`run_service::RecognitionRunService`], the Group E +//! orchestration that brackets one [`runner::RecognitionRunner`] pass with the +//! `recognition_run` row lifecycle (dedup → `RUNNING` → `DONE`/`FAILED`) and +//! maps the pass summary onto the SDK [`RecognitionRunOutcome`]. +//! +//! [`PostSidecar`]: crate::infra::posting::service::PostSidecar +//! [`PostingService`]: crate::infra::posting::service::PostingService +//! [`RecognitionRunOutcome`]: bss_ledger_sdk::RecognitionRunOutcome + +pub mod change_service; +pub mod run_service; +pub mod runner; +pub mod sidecar; diff --git a/gears/bss/ledger/ledger/src/infra/recognition/change_service.rs b/gears/bss/ledger/ledger/src/infra/recognition/change_service.rs new file mode 100644 index 000000000..7307bfe0a --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/recognition/change_service.rs @@ -0,0 +1,598 @@ +//! `RecognitionChangeService` — the Group H schedule change / cancel path +//! (design §3.6 / §4.6). It marks an ACTIVE recognition schedule terminal +//! (`CANCELLED` or `REPLACED`) and, on a `replace`, mints a fresh ACTIVE version +//! that re-plans the REMAINING deferred — all in ONE serializable transaction, +//! emitting `billing.ledger.schedule.changed` in-txn. Mirrors the construction of +//! [`RecognitionRunService`](super::run_service::RecognitionRunService) (a +//! `DBProvider` + the publisher), but the change posts **no journal entry** (it is +//! a pure schedule-lifecycle transition — the `CONTRACT_LIABILITY` balance is +//! already correct), so it brackets a bare `transaction_with_retry` rather than a +//! [`PostingService`](crate::infra::posting::service::PostingService) post. +//! +//! **Order of operations** (each gate documented): +//! +//! 1. **Treatment gate FIRST** ([`gate_treatment`], design §3.6) — `prospective` +//! / `separate_contract` proceed; `catch_up` / unknown ⇒ +//! [`DomainError::ModificationTreatmentReview`] with NO state change. Run before +//! the transaction so a review never opens a txn or mutates a schedule. +//! 2. **Action parse** ([`ChangeAction::parse`]) — `cancel` / `replace`; and, for +//! a `replace`, the `new_segments` are validated to sum to the schedule's +//! REMAINING deferred (`total_deferred − recognized`) before any write. +//! 3. **One serializable txn (with retry)** — claim `(tenant, SCHEDULE_CHANGE, +//! change_id)` (a `Replay` short-circuits to the prior result, recomputed from +//! durable state — no second schedule minted, never `finalize`d); read the ACTIVE +//! `(tenant, schedule_id)` schedule in-txn (missing / non-ACTIVE ⇒ +//! [`DomainError::InvalidRequest`] 400 — the gear's only `NotFound` variant is +//! `fiscal_period`-tagged, so a generic 400 is used, not a misattributed period +//! error); apply the transition (`cancel` flips `ACTIVE → CANCELLED`; `replace` +//! flips `ACTIVE → REPLACED` then inserts the new ACTIVE version — `version = +//! old + 1`, same business key, remaining deferred — + its PENDING segments, +//! old-first so the partial one-live UNIQUE holds); then emit +//! `billing.ledger.schedule.changed` in-txn (atomic with the transition). + +use std::sync::Arc; + +use bss_ledger_sdk::{ChangeRecognitionSchedule, ChangeSegment, ScheduleChangeRef}; +use toolkit_db::secure::{AccessScope, DbTx, TxConfig}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::recognition::change::{ChangeAction, gate_treatment}; +use crate::domain::status::{ + SCHEDULE_STATUS_ACTIVE, SCHEDULE_STATUS_CANCELLED, SCHEDULE_STATUS_REPLACED, +}; +use crate::infra::events::payloads::LedgerScheduleChanged; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::posting::idempotency::{ClaimOutcome, IdempotencyGate}; +use crate::infra::storage::entity::recognition_schedule; +use crate::infra::storage::repo::RecognitionRepo; +use crate::infra::storage::repo::recognition_repo::{NewSegment, ReplacementSchedule}; + +/// The idempotency-dedup `flow` literal for a schedule change. `idempotency_dedup` +/// `flow` is a free-text column (no CHECK), so a Group H change reuses the +/// [`IdempotencyGate`] with this literal + `business_id = change_id` WITHOUT a new +/// `SourceDocType` variant (the SDK enum is declared final). The claim is the +/// at-most-once change marker. +const FLOW_SCHEDULE_CHANGE: &str = "SCHEDULE_CHANGE"; + +/// Carries the in-transaction change decision out of the `SERIALIZABLE` body on +/// the commit path. Both arms are `Ok(_)` (the txn commits); infrastructure / +/// serialization faults are the closure's `Err(DbError)`. A `DomainError` raised +/// inside the body (e.g. a missing ACTIVE schedule) is wrapped into `DbError` +/// (via [`domain_to_db`]) so it propagates out of the retry helper and is +/// re-projected by [`db_to_domain`] — mirroring the `period_close` pattern. +enum ChangeTxnResult { + /// The change applied (fresh claim): the resulting ref. + Applied(ScheduleChangeRef), + /// The change was an idempotent replay (the `change_id` claim already + /// existed): the prior ref, recomputed from durable schedule state. + Replayed(ScheduleChangeRef), +} + +/// Applies Group H schedule changes (cancel / replace) over one serializable +/// transaction. Holds the [`IdempotencyGate`] (the `change_id` claim), the event +/// publisher (the in-txn `schedule.changed` emit), and the `DBProvider` (to open +/// the change txn). The schedule reads/writes go through [`RecognitionRepo`]'s +/// in-txn associated functions (which take the `DbTx`), so no repo instance is +/// held. Same `db`/`publisher` deps as the peer recognition / payment services. +pub struct RecognitionChangeService { + db: DBProvider, + idempotency: IdempotencyGate, + publisher: Arc, +} + +impl RecognitionChangeService { + /// Build the change-service over one database provider + the event publisher + /// (the in-txn `schedule.changed` emit). Mirrors + /// [`RecognitionRunService::new`](super::run_service::RecognitionRunService::new) + /// minus the runner/lease (a change posts no journal entry). + #[must_use] + pub fn new(db: DBProvider, publisher: Arc) -> Self { + Self { + db, + idempotency: IdempotencyGate::new(), + publisher, + } + } + + /// Change or cancel an ACTIVE recognition schedule (design §3.6 / §4.6). + /// Gates the treatment first (a `catch_up`/unknown treatment is a review with + /// NO state change), validates the action + (for a `replace`) the segments, + /// then applies the transition in one serializable transaction and emits + /// `schedule.changed` in-txn. Idempotent on `cmd.change_id`. + /// + /// # Errors + /// [`DomainError::ModificationTreatmentReview`] for a `catch_up`/unknown + /// treatment; [`DomainError::InvalidRequest`] for an unknown action, a + /// `replace` whose `new_segments` are missing/empty or do not sum to the + /// remaining deferred, OR when no ACTIVE schedule exists for + /// `(tenant, schedule_id)`; [`DomainError::Internal`] on a storage fault. + pub async fn change( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + cmd: ChangeRecognitionSchedule, + ) -> Result { + // 1. Treatment gate FIRST (design §3.6) — before any txn / state read. A + // catch_up / unknown treatment surfaces for review and changes nothing. + gate_treatment(&cmd.treatment)?; + + // 2. Parse the action; pre-validate a replace's segments against the + // schedule's remaining deferred is deferred to the in-txn body (it + // needs the read schedule), but the *shape* (segments present for a + // replace) is checked here for a clean 400. + let action = ChangeAction::parse(&cmd.action)?; + if action == ChangeAction::Replace { + let segs = cmd.new_segments.as_deref().unwrap_or(&[]); + if segs.is_empty() { + return Err(DomainError::InvalidRequest( + "schedule replace requires at least one replacement segment".to_owned(), + )); + } + for (i, seg) in segs.iter().enumerate() { + if seg.amount_minor < 0 { + return Err(DomainError::InvalidRequest(format!( + "replacement segment {i} amount must be >= 0, got {}", + seg.amount_minor + ))); + } + } + } + + // 3. One serializable transaction (with retry): claim → read → transition + // → emit. The body returns a `ChangeTxnResult` on the commit path; a + // business precondition (missing ACTIVE schedule, bad segment sum) is + // wrapped into `DbError` so it leaves the retry helper and is + // re-projected below. A serialization conflict (a concurrent release / + // change) retries (SSI). + let cmd = cmd.clone(); + let scope_owned = scope.clone(); + let ctx_owned = ctx.clone(); + let publisher = Arc::clone(&self.publisher); + let idempotency = self.idempotency.clone(); + + let result: Result = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let cmd = cmd.clone(); + let scope = scope_owned.clone(); + let ctx = ctx_owned.clone(); + let publisher = Arc::clone(&publisher); + let idempotency = idempotency.clone(); + Box::pin(async move { + change_in_txn(txn, &idempotency, &publisher, &ctx, &scope, &cmd, action).await + }) + }) + .await; + + match result { + Ok(ChangeTxnResult::Applied(r) | ChangeTxnResult::Replayed(r)) => Ok(r), + Err(e) => Err(db_to_domain(&e)), + } + } +} + +/// In-transaction change body: claim → read ACTIVE schedule → cancel/replace → +/// emit `schedule.changed`. Runs under the caller's `SERIALIZABLE` transaction so +/// the claim, the status flip, and any replacement insert share one snapshot and +/// conflict with a concurrent release. A business precondition is raised as a +/// `DomainError` wrapped into `DbError` (so it propagates out of the retry helper); +/// a serialization conflict surfaces as a retryable `DbErr`. +async fn change_in_txn( + txn: &DbTx<'_>, + idempotency: &IdempotencyGate, + publisher: &Arc, + ctx: &SecurityContext, + scope: &AccessScope, + cmd: &ChangeRecognitionSchedule, + action: ChangeAction, +) -> Result { + let tenant = cmd.tenant_id; + + // a. Idempotency claim on the change_id. SCHEDULE_CHANGE posts no journal + // entry, so the payload hash is over the change_id (stable across retries). + // A `Replay` means the change already applied — recompute the prior result + // from durable schedule state (no second schedule minted). + let payload_hash = IdempotencyGate::content_hash(&cmd.change_id); + match idempotency + .claim( + txn, + tenant, + FLOW_SCHEDULE_CHANGE, + &cmd.change_id, + &payload_hash, + ) + .await + .map_err(|e| repo_to_db(&e))? + { + ClaimOutcome::Claimed => {} + ClaimOutcome::Replay(_) => { + let replayed = replay_result(txn, scope, cmd).await?; + return Ok(ChangeTxnResult::Replayed(replayed)); + } + } + + // b. Read the ACTIVE schedule in-txn (joins the serializable snapshot). A + // missing / non-ACTIVE target ⇒ InvalidRequest (400 — the change cannot be + // satisfied against current state; the gear's only NotFound variant is + // fiscal-period-tagged, so we do not misattribute it). + let schedule = RecognitionRepo::read_schedule_in_txn(txn, scope, tenant, &cmd.schedule_id) + .await? + .filter(|s| s.status == SCHEDULE_STATUS_ACTIVE) + .ok_or_else(|| { + domain_to_db(DomainError::InvalidRequest(format!( + "no ACTIVE recognition schedule {} for tenant {tenant} to change", + cmd.schedule_id + ))) + })?; + + // c. Apply the transition. + let result = match action { + ChangeAction::Cancel => apply_cancel(txn, scope, tenant, &cmd.schedule_id).await?, + ChangeAction::Replace => apply_replace(txn, scope, &schedule, cmd).await?, + }; + + // d. Emit billing.ledger.schedule.changed in-txn (transactional outbox): the + // event row commits atomically with the transition, or rolls back with it. + // `treatment` is the upstream code that let the change proceed; `status` is + // the original schedule's resulting terminal status. + publisher + .publish_schedule_changed( + ctx, + txn, + LedgerScheduleChanged { + tenant_id: tenant, + schedule_id: cmd.schedule_id.clone(), + new_schedule_id: result.new_schedule_id.clone(), + treatment: cmd.treatment.clone(), + status: result.status.clone(), + }, + ) + .await + .map_err(|e| { + domain_to_db(DomainError::Internal(format!( + "publish schedule_changed: {e}" + ))) + })?; + + Ok(ChangeTxnResult::Applied(result)) +} + +/// Apply a `cancel`: flip the ACTIVE schedule `→ CANCELLED` (bump `version`). The +/// unreleased deferred remainder stays as `CONTRACT_LIABILITY` (no auto-reversal, +/// v1). A `rows_affected == 0` (a concurrent change already moved it) is folded +/// into the same terminal ref — the change is idempotent on its effect. +async fn apply_cancel( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, +) -> Result { + RecognitionRepo::mark_schedule_status( + txn, + scope, + tenant, + schedule_id, + SCHEDULE_STATUS_ACTIVE, + SCHEDULE_STATUS_CANCELLED, + ) + .await?; + Ok(ScheduleChangeRef { + schedule_id: schedule_id.to_owned(), + new_schedule_id: None, + status: SCHEDULE_STATUS_CANCELLED.to_owned(), + }) +} + +/// Apply a `replace`: validate the new segments sum to the remaining deferred, +/// flip the old schedule `→ REPLACED`, then insert the new ACTIVE version +/// (`version = old + 1`, same business key, remaining deferred) + its PENDING +/// segments — all in this txn. Already-DONE segments on the old schedule stay; no +/// compensating journal entry (the `CONTRACT_LIABILITY` balance already equals the +/// remaining deferred). +async fn apply_replace( + txn: &DbTx<'_>, + scope: &AccessScope, + old: &recognition_schedule::Model, + cmd: &ChangeRecognitionSchedule, +) -> Result { + let tenant = old.tenant_id; + let segments = cmd.new_segments.as_deref().unwrap_or(&[]); + + // The remaining deferred = what was deferred minus what has already been + // recognized. The new version re-plans exactly this remainder (prospective). + let remaining = old + .total_deferred_minor + .checked_sub(old.recognized_minor) + .ok_or_else(|| { + domain_to_db(DomainError::Internal(format!( + "schedule {} remaining deferred underflow (total={}, recognized={})", + old.schedule_id, old.total_deferred_minor, old.recognized_minor + ))) + })?; + let supplied: i64 = sum_segments(segments).map_err(domain_to_db)?; + if supplied != remaining { + return Err(domain_to_db(DomainError::InvalidRequest(format!( + "replacement segments sum to {supplied} but the schedule's remaining deferred is \ + {remaining} (total={}, recognized={})", + old.total_deferred_minor, old.recognized_minor + )))); + } + + // Validate the replacement PERIODS (design §4.6) BEFORE the status flip: each + // `period_id` must parse as `YYYYMM`, the supplied periods must be strictly + // ascending + distinct, and the FIRST replacement period must be strictly + // greater than the highest period the OLD schedule has already recognized + // (its max-`DONE`-segment period) — so a replacement can never re-target an + // already-recognized period (cross-version double-recognition). The max DONE + // period is read IN-TXN (the change claim holds the conn-bypass guard). + let max_done = + RecognitionRepo::max_done_segment_period_in_txn(txn, scope, tenant, &old.schedule_id) + .await?; + validate_replacement_periods(segments, max_done.as_deref()).map_err(domain_to_db)?; + + // Flip the OLD schedule REPLACED FIRST (same txn) so the partial one-live + // UNIQUE frees before the new ACTIVE inserts. + RecognitionRepo::mark_schedule_status( + txn, + scope, + tenant, + &old.schedule_id, + SCHEDULE_STATUS_ACTIVE, + SCHEDULE_STATUS_REPLACED, + ) + .await?; + + // Mint the successor id + version, carrying the SAME business-key dims. + let new_schedule_id = Uuid::now_v7().to_string(); + let new_version = old.version.checked_add(1).ok_or_else(|| { + domain_to_db(DomainError::Internal(format!( + "schedule {} version overflow", + old.schedule_id + ))) + })?; + let replacement = ReplacementSchedule { + tenant_id: tenant, + schedule_id: new_schedule_id.clone(), + payer_tenant_id: old.payer_tenant_id, + source_invoice_id: old.source_invoice_id.clone(), + source_invoice_item_ref: old.source_invoice_item_ref.clone(), + po_allocation_group: old.po_allocation_group.clone(), + subscription_ref: old.subscription_ref.clone(), + revenue_stream: old.revenue_stream.clone(), + currency: old.currency.clone(), + total_deferred_minor: remaining, + policy_ref: old.policy_ref.clone(), + ssp_snapshot_ref: old.ssp_snapshot_ref.clone(), + vc_estimate_ref: old.vc_estimate_ref.clone(), + vc_method_ref: old.vc_method_ref.clone(), + version: new_version, + }; + RecognitionRepo::insert_replacement_schedule(txn, scope, &replacement).await?; + + // Insert the successor's PENDING segments (segment_no 1..). + let new_segments: Vec = segments + .iter() + .enumerate() + .map(|(i, seg)| { + let segment_no = i32::try_from(i + 1).map_err(|_| { + domain_to_db(DomainError::InvalidRequest( + "too many replacement segments (segment number overflow)".to_owned(), + )) + })?; + Ok(NewSegment { + tenant_id: tenant, + schedule_id: new_schedule_id.clone(), + segment_no, + period_id: seg.period_id.clone(), + amount_minor: seg.amount_minor, + }) + }) + .collect::, DbError>>()?; + RecognitionRepo::insert_segments(txn, scope, &new_segments).await?; + + Ok(ScheduleChangeRef { + schedule_id: old.schedule_id.clone(), + new_schedule_id: Some(new_schedule_id), + status: SCHEDULE_STATUS_REPLACED.to_owned(), + }) +} + +/// Recompute the result of an already-applied change (the idempotent replay +/// path) from durable schedule state — no second schedule is minted. Reads the +/// original schedule's current terminal status: `CANCELLED` ⇒ no successor; +/// `REPLACED` ⇒ resolve the ACTIVE successor (same business key, `version = +/// old.version + 1`). A still-ACTIVE schedule under a claimed `change_id` is an +/// invariant breach (the claim implies the change committed) — surfaced as +/// `Internal`. +async fn replay_result( + txn: &DbTx<'_>, + scope: &AccessScope, + cmd: &ChangeRecognitionSchedule, +) -> Result { + let tenant = cmd.tenant_id; + let schedule = RecognitionRepo::read_schedule_in_txn(txn, scope, tenant, &cmd.schedule_id) + .await? + .ok_or_else(|| { + domain_to_db(DomainError::Internal(format!( + "schedule {} vanished on change replay", + cmd.schedule_id + ))) + })?; + match schedule.status.as_str() { + SCHEDULE_STATUS_CANCELLED => Ok(ScheduleChangeRef { + schedule_id: cmd.schedule_id.clone(), + new_schedule_id: None, + status: SCHEDULE_STATUS_CANCELLED.to_owned(), + }), + SCHEDULE_STATUS_REPLACED => { + // The successor is the ACTIVE schedule at version old+1 with the same + // business key — read IN-TXN (the claim guard holds the conn-bypass + // guard, so an out-of-txn read would fail). + let successor = RecognitionRepo::read_active_successor_in_txn( + txn, + scope, + tenant, + &schedule.source_invoice_id, + &schedule.source_invoice_item_ref, + &schedule.revenue_stream, + schedule.version, + ) + .await?; + Ok(ScheduleChangeRef { + schedule_id: cmd.schedule_id.clone(), + new_schedule_id: successor.map(|s| s.schedule_id), + status: SCHEDULE_STATUS_REPLACED.to_owned(), + }) + } + other => Err(domain_to_db(DomainError::Internal(format!( + "change replay found schedule {} in unexpected status {other:?}", + cmd.schedule_id + )))), + } +} + +/// Sum the replacement segment amounts (i128 intermediate to dodge an i64 +/// overflow on a pathological set), returning the i64 total. +fn sum_segments(segments: &[ChangeSegment]) -> Result { + let mut total: i128 = 0; + for seg in segments { + total += i128::from(seg.amount_minor); + } + i64::try_from(total).map_err(|_| { + DomainError::InvalidRequest("replacement segment amounts overflow i64".to_owned()) + }) +} + +/// Validate the replacement segments' `period_id`s (design §4.6), independent of +/// their amounts (summed by [`sum_segments`]): (1) each parses as a well-formed +/// `YYYYMM`; (2) the supplied periods are strictly ascending + distinct (a +/// schedule lays its segments out in increasing period order, 1:1 with +/// `segment_no`); (3) the FIRST replacement period is strictly greater than +/// `max_done_period` — the highest period the OLD schedule has already recognized +/// (its max-`DONE`-segment period, `None` ⇒ nothing recognized yet, no floor) — +/// so a replacement can never re-target an already-recognized period +/// (cross-version double-recognition). `period_id` is the `YYYYMM` +/// lexical-sortable string, so a `<=` string compare is the period-order compare +/// once each value is confirmed well-formed. Pure (no I/O); the caller reads +/// `max_done_period` in-txn. +/// +/// # Errors +/// [`DomainError::InvalidRequest`] (400) for a malformed period, a +/// non-ascending / duplicate period, or a first period that does not clear the +/// already-recognized floor. +fn validate_replacement_periods( + segments: &[ChangeSegment], + max_done_period: Option<&str>, +) -> Result<(), DomainError> { + let mut prev: Option<&str> = None; + for (i, seg) in segments.iter().enumerate() { + let pid = seg.period_id.as_str(); + if !is_well_formed_period(pid) { + return Err(DomainError::InvalidRequest(format!( + "replacement segment {i} period {pid:?} is not a valid YYYYMM" + ))); + } + // Strictly ascending + distinct (a `<=` against the predecessor catches + // both a descending and a duplicate period). + if let Some(prev) = prev + && pid <= prev + { + return Err(DomainError::InvalidRequest(format!( + "replacement segment periods must be strictly ascending and distinct, but \ + {pid:?} does not follow {prev:?}" + ))); + } + prev = Some(pid); + } + // The first replacement period must clear the already-recognized floor. + if let (Some(first), Some(floor)) = (segments.first(), max_done_period) + && first.period_id.as_str() <= floor + { + return Err(DomainError::InvalidRequest(format!( + "first replacement period {:?} must be strictly after the schedule's last \ + already-recognized period {floor:?} (a replacement cannot re-recognize a period \ + that already posted)", + first.period_id + ))); + } + Ok(()) +} + +/// `true` iff `period_id` is a well-formed `YYYYMM` (6 chars, month `1..=12`) — +/// the same shape [`crate::domain::period`] / the runner's `parse_period` +/// validate. A well-formed 6-char period is lexically sortable, which the period +/// ordering checks above rely on. +fn is_well_formed_period(period_id: &str) -> bool { + if period_id.len() != 6 { + return false; + } + let Some(month) = period_id.get(4..6).and_then(|m| m.parse::().ok()) else { + return false; + }; + period_id + .get(0..4) + .is_some_and(|y| y.parse::().is_ok()) + && (1..=12).contains(&month) +} + +/// Extractor for the retry helper: a wrapped `DbErr` (so a serialization failure +/// surfaced at a statement or COMMIT is recognised as retryable). Mirrors +/// `period_close::as_db_err`. +fn as_db_err(e: &DbError) -> Option<&sea_orm::DbErr> { + match e { + DbError::Sea(db_err) => Some(db_err), + _ => None, + } +} + +/// Wrap a [`DomainError`] into a `DbError` so it propagates out of the retry +/// helper unchanged (NOT retryable — `DbError::Other` carries no `sea_orm::DbErr`, +/// so [`as_db_err`] returns `None`). Re-projected by [`db_to_domain`]. +fn domain_to_db(e: DomainError) -> DbError { + DbError::Other(anyhow::anyhow!(DomainErrorCarrier(e))) +} + +/// Re-project the closure's `DbError` back into a [`DomainError`]: a wrapped +/// `DomainError` (raised in the body via [`domain_to_db`]) is unwrapped verbatim; +/// any other `DbError` (a genuine storage / serialization fault that exhausted +/// retries) is an [`DomainError::Internal`]. +fn db_to_domain(e: &DbError) -> DomainError { + if let DbError::Other(err) = e + && let Some(carrier) = err.downcast_ref::() + { + return carrier.0.clone(); + } + DomainError::Internal(format!("schedule change txn: {e}")) +} + +/// Map the idempotency-gate [`RepoError`] (the `SCHEDULE_CHANGE` claim) into the +/// change txn's `DbError` as a non-retryable `Internal`. The schedule +/// reads/writes go through the in-txn [`RecognitionRepo`] helpers, which now +/// return `DbError` directly and PRESERVE the inner `sea_orm::DbErr` (so a +/// serialization conflict at the contended `ACTIVE` row stays retryable via +/// [`as_db_err`] — see `recognition_repo::scope_to_db`); only the claim still +/// surfaces a `RepoError`, and a claim fault is treated as an infrastructure +/// `Internal`, not a retryable conflict. +fn repo_to_db(e: &crate::domain::model::RepoError) -> DbError { + domain_to_db(DomainError::Internal(format!("recognition repo: {e}"))) +} + +/// Wrapper so a [`DomainError`] can ride inside `anyhow::Error` (and thus +/// `DbError::Other`) and be downcast back out by [`db_to_domain`]. Mirrors the +/// `period_close::scope_to_db` round-trip intent without needing the inner +/// `sea_orm::DbErr` (a change conflict retries at COMMIT, not on the carried +/// business error). +#[derive(Debug)] +struct DomainErrorCarrier(DomainError); + +impl std::fmt::Display for DomainErrorCarrier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for DomainErrorCarrier {} diff --git a/gears/bss/ledger/ledger/src/infra/recognition/run_service.rs b/gears/bss/ledger/ledger/src/infra/recognition/run_service.rs new file mode 100644 index 000000000..e2c2f23cc --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/recognition/run_service.rs @@ -0,0 +1,305 @@ +//! `RecognitionRunService` — the Group E orchestration wrapper around a +//! [`RecognitionRunner`] pass (design §4.3 / §5, the S6 release). It brackets +//! one `run_period` pass with the `recognition_run` row lifecycle (dedup → +//! `RUNNING` → `DONE`/`FAILED`) and maps the [`RunPeriodSummary`] onto the SDK +//! [`RecognitionRunOutcome`] (`Ran` vs `Queued`). +//! +//! **Idempotent replay (dedup).** A trigger whose `(tenant, period_id, run_id)` +//! already has a `recognition_run` row replays that run reference +//! (`Ran { replayed: true }`) instead of starting a second run — the run-trigger +//! idempotency key (design §4.3). The key includes `period_id`, so reusing one +//! `run_id` across two periods runs both. A `None` `run_id` mints a fresh one +//! (`Uuid::now_v7`), so an un-keyed trigger always runs. +//! +//! **Single-active-run guard (`coord` lease).** A fresh trigger acquires a +//! [`coord`] lease keyed `recognition-run:{tenant}:{period_id}` (a TTL + renewal +//! heartbeat) before it runs. A concurrent trigger for the same +//! `(tenant, period_id)` sees [`CoordError::LeaseHeld`] and returns a no-op +//! replay (`Ran { replayed: true, released: 0 }`) instead of racing the holder. +//! This serialises redundant runs and removes the `SERIALIZABLE` contention two +//! overlapping passes would otherwise hit on the shared per-segment +//! `RECOGNITION` idempotency claim. Correctness never depended on it — each +//! segment release is already at-most-once via that claim + the `status = DONE` +//! guard — so a lease that lapses mid-pass (a pathologically long run) cannot +//! double-credit; it only re-admits a redundant pass. The heartbeat keeps the +//! lease live across a normal pass; a crashed run's lease lapses at TTL so the +//! period is re-runnable within ~a minute. +//! +//! **Run-row bracketing.** Under the held lease a fresh trigger inserts the row +//! `RUNNING`, runs the pass, then flips it `DONE` (success) or `FAILED` (a +//! release error is propagated after the `FAILED` flip — the segments already +//! released stay committed, each being its own atomic post). + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use bss_ledger_sdk::{RecognitionRunOutcome, RecognitionRunQueued, RecognitionRunRef}; +use chrono::Utc; +use coord::{CoordError, LeaseGuard, LeaseManager}; +use futures::FutureExt as _; +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::recognition::runner::RecognitionRunner; +use crate::infra::storage::repo::RecognitionRepo; + +/// Lease TTL for a recognition run. Comfortably longer than the renewal period +/// so one missed heartbeat (a transient DB blip) does not drop the lease, yet +/// short enough that a crashed run's lease lapses and the period is re-runnable +/// within ~a minute. +const RECOGNITION_LEASE_TTL: Duration = Duration::from_mins(1); +/// Renewal heartbeat period (~`TTL`/3): a live pass renews well before expiry. +const RECOGNITION_LEASE_RENEW: Duration = Duration::from_secs(20); + +/// Orchestrates one recognition-run trigger: a single-active-run [`coord`] lease +/// bracketing the `recognition_run` row lifecycle (dedup → `RUNNING` → +/// `DONE`/`FAILED`) around a [`RecognitionRunner`] pass. Holds the runner (the +/// per-segment release engine), the [`RecognitionRepo`] (the run-row +/// reads/writes), and the lease manager — same `db`/`publisher` deps as the peer +/// payment services. +pub struct RecognitionRunService { + runner: RecognitionRunner, + recognition: RecognitionRepo, + /// Single-active-run lease (design §4.3). Keyed + /// `recognition-run:{tenant}:{period_id}`, built over the same `Db` as the + /// repos. See [`coord`] + the module-level note. + lease: LeaseManager, + /// Metrics sink: the run-duration histogram is emitted here (it brackets the + /// whole pass); the per-segment recognized-minor / over-recognition / + /// double-credit / queue-depth metrics are emitted inside the runner. + metrics: Arc, +} + +impl RecognitionRunService { + /// Build the run-service over one database provider + the event publisher + + /// the metrics sink (threaded into the runner's posting engine + the §9 + /// recognition metrics). The lease manager is built from the provider's + /// `Db` (`db.db()`); mirrors how the peer payment services are built from a + /// `db.clone()` + a publisher + a metrics clone. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + let lease = LeaseManager::new(db.db()); + let runner = RecognitionRunner::new(db.clone(), publisher, Arc::clone(&metrics)); + let recognition = RecognitionRepo::new(db); + Self { + runner, + recognition, + lease, + metrics, + } + } + + /// Trigger a recognition run for `(tenant, period_id)`. Mints `run_id` when + /// `None`; dedups on the `recognition_run` row (a prior row replays its + /// reference without re-running); otherwise acquires the single-active-run + /// lease and — under it — brackets a [`RecognitionRunner`] pass with the + /// `RUNNING → DONE`/`FAILED` row lifecycle, mapping the + /// [`RunPeriodSummary`](crate::infra::recognition::runner::RunPeriodSummary) + /// onto the SDK outcome (`Queued` when any segment was parked out-of-order, + /// else `Ran`). A peer already holding the lease yields a no-op replay. + /// + /// # Errors + /// Any [`DomainError`] the underlying pass raises + /// ([`DomainError::OverRecognition`], [`DomainError::PeriodClosed`], + /// [`DomainError::AccountClosed`], …) — propagated AFTER the run row is + /// flipped `FAILED`; or [`DomainError::Internal`] on a run-row read/write + /// fault or a lease-acquire fault. + pub async fn trigger( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + run_id: Option, + ) -> Result { + // Mint a fresh run_id for an un-keyed trigger; a caller-supplied one is + // the idempotency key the dedup read below keys on. + let run_id = run_id.unwrap_or_else(Uuid::now_v7); + + // Dedup: a trigger whose (tenant, period_id, run_id) already ran replays + // that run reference (idempotent) instead of starting a second run. The + // key includes period_id: a client that reuses one + // run_id across two periods must run BOTH — keying on (tenant, run_id) + // alone short-circuited the second period to the first and silently never + // recognized it. The prior run's release tally is not re-derived — a replay + // reports zero releases (the work already committed under the original run). + if let Some(prior) = self + .recognition + .read_run(scope, tenant, period_id, run_id) + .await + .map_err(|e| DomainError::Internal(format!("read recognition_run: {e}")))? + { + return Ok(RecognitionRunOutcome::Ran(RecognitionRunRef { + run_id, + period_id: prior.period_id, + replayed: true, + released: 0, + already_recognized: 0, + })); + } + + // Single-active-run guard: take the lease for this (tenant, period). A + // concurrent run holding it ⇒ no-op replay (we do not start a second + // pass for the same period); the holder releases its due segments. + let lease_key = format!("recognition-run:{tenant}:{period_id}"); + let guard = match self.lease.acquire(&lease_key, RECOGNITION_LEASE_TTL).await { + Ok(guard) => guard, + Err(CoordError::LeaseHeld) => { + return Ok(RecognitionRunOutcome::Ran(RecognitionRunRef { + run_id, + period_id: period_id.to_owned(), + replayed: true, + released: 0, + already_recognized: 0, + })); + } + Err(e) => { + return Err(DomainError::Internal(format!( + "recognition lease acquire ({lease_key}): {e}" + ))); + } + }; + + // Keep the lease live across a long pass; stop the heartbeat before we + // release, then free the slot (preserving the forensic `attempts` streak + // on a failed run). + let renewal = guard.spawn_renewal(RECOGNITION_LEASE_RENEW); + let result = self.run_locked(ctx, scope, tenant, period_id, run_id).await; + renewal.shutdown().await; + Self::release_lease(guard, result.is_ok()).await; + result + } + + /// The bracketed pass under a held lease: insert the `RUNNING` row, run the + /// `RecognitionRunner` pass (bracketed by the run-duration histogram), then + /// flip the row `DONE` (success) or `FAILED` (error propagated after). + async fn run_locked( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + run_id: Uuid, + ) -> Result { + self.recognition + .insert_run(scope, tenant, run_id, period_id, Utc::now()) + .await + .map_err(|e| DomainError::Internal(format!("insert recognition_run: {e}")))?; + + // F3: bracket the release pass with the run-duration histogram (design §9), + // recorded on every exit — success, error, AND panic (each consumed wall + // time). The pass is wrapped in `catch_unwind`: a panic + // would otherwise unwind past `finish_run`, leaving the row stuck `RUNNING` + // so a same-key retry replays it as `released: 0`; instead the row flips + // FAILED and the panic resumes. + let started = Instant::now(); + let pass = std::panic::AssertUnwindSafe( + self.runner + .run_period(ctx, scope, tenant, period_id, run_id), + ) + .catch_unwind() + .await; + self.metrics + .recognition_run_duration(started.elapsed().as_secs_f64()); + let summary = match pass { + Ok(Ok(summary)) => summary, + Ok(Err(err)) => { + self.flip_failed(scope, tenant, period_id, run_id).await; + return Err(err); + } + Err(panic) => { + self.flip_failed(scope, tenant, period_id, run_id).await; + std::panic::resume_unwind(panic); + } + }; + + self.recognition + .finish_run(scope, tenant, period_id, run_id, true) + .await + .map_err(|e| DomainError::Internal(format!("finish recognition_run: {e}")))?; + + // Surface an obligation-stalled backlog: segments left + // unreleased because a performance obligation was NOT satisfied are counted + // in `summary.skipped` but were otherwise invisible. v1's obligation gate is + // a stub that never skips, so this is dormant until the Slice 7 obligation + // feed lands — at which point a non-zero count is an operator-actionable + // stall, not a silent backlog. + if summary.skipped > 0 { + tracing::warn!( + target: "bss.ledger.recognition", + %period_id, + skipped = summary.skipped, + "recognition run left segments unreleased on an unsatisfied performance obligation" + ); + } + + // Map the summary onto the SDK outcome: any out-of-order park (§4.6) ⇒ + // Queued (HTTP 202 `recognition-period-queued`); otherwise Ran (the run + // released its due segments in order). `released` = fresh posts this + // pass; `already_recognized` = idempotent RECOGNITION replays. + let outcome = if summary.queued > 0 { + RecognitionRunOutcome::Queued(RecognitionRunQueued { + run_id, + period_id: period_id.to_owned(), + released: summary.released, + queued: summary.queued, + }) + } else { + RecognitionRunOutcome::Ran(RecognitionRunRef { + run_id, + period_id: period_id.to_owned(), + replayed: false, + released: summary.released, + already_recognized: summary.replayed, + }) + }; + Ok(outcome) + } + + /// Flip the run row `RUNNING → FAILED` (best-effort): a finish-row fault is + /// logged, not propagated (it is subordinate to the original failure). Shared by + /// the error and panic arms of the bracketed pass. + async fn flip_failed(&self, scope: &AccessScope, tenant: Uuid, period_id: &str, run_id: Uuid) { + if let Err(finish_err) = self + .recognition + .finish_run(scope, tenant, period_id, run_id, false) + .await + { + tracing::error!( + error = %finish_err, + %run_id, + "bss-ledger: failed to flip recognition_run FAILED after a run error/panic" + ); + } + } + + /// Release the run lease, logging (not failing) on a release fault — the run + /// already committed, and a lingering lease lapses at its TTL. `succeeded` + /// picks `release` (reset the `attempts` streak) vs `release_with_retry` + /// (preserve it, so a flapping run stays visible to operators). + async fn release_lease(guard: LeaseGuard, succeeded: bool) { + let key = guard.key().to_owned(); + let released = if succeeded { + guard.release().await + } else { + guard.release_with_retry().await + }; + if let Err(e) = released { + tracing::warn!( + target: "bss-ledger", + error = %e, + lease_key = %key, + "failed to release recognition-run lease (will lapse at TTL)" + ); + } + } +} diff --git a/gears/bss/ledger/ledger/src/infra/recognition/runner.rs b/gears/bss/ledger/ledger/src/infra/recognition/runner.rs new file mode 100644 index 000000000..2d57eb03a --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/recognition/runner.rs @@ -0,0 +1,866 @@ +//! `RecognitionRunner` — the ASC 606 S6 **release** mechanism (design §4.3, +//! Group D1). It turns a due `PENDING` recognition segment into recognized +//! revenue by posting **one balanced entry** through the Slice 1 +//! [`PostingService`]: +//! +//! | Line | Side | Account class | +//! |------|------|---------------| +//! | Recognize | DR | `CONTRACT_LIABILITY` (the segment's stream) | +//! | Revenue | CR | `REVENUE` (the **same** stream) | +//! +//! `amount_minor` is the segment's amount, `currency` the schedule's currency, +//! and both legs carry the schedule's `revenue_stream` (per-stream disaggregation, +//! §4.5 — DR and CR draw the same stream). Each line's chart `account_id` is bound +//! from the provisioned chart of accounts via [`load_chart`] + [`ChartIndex::resolve`] +//! (mirroring [the invoice-post bind path](crate::infra::invoice_post)); the +//! per-line scale is resolved from the currency registry, exactly as the +//! settlement / invoice posts. +//! +//! **Atomicity + at-most-once.** The post threads a +//! [`RecognitionStampSidecar`](crate::infra::recognition::sidecar::RecognitionStampSidecar) +//! so the journal entry, the `recognized_minor += amount` counter bump (under the +//! per-schedule over-recognition cap CHECK → [`DomainError::OverRecognition`] 409), +//! and the segment `→ DONE` stamp all commit in the SAME serializable transaction +//! or roll back together (§4.3). The entry's `source_doc_type = RECOGNITION` + +//! `source_business_id = "{schedule_id}:{segment_no}"` key the Slice 1 +//! `IdempotencyGate`, so the release is at-most-once per +//! `(tenant, RECOGNITION, schedule_id:segment_no)` — a replay returns the prior +//! [`PostingRef`] without re-crediting (and the sidecar never runs on a replay). +//! +//! **Scope.** [`Self::run_period`] releases the due `PENDING` segments for a +//! `(tenant, period_id)` in ascending `(schedule_id, segment_no)` order, applying +//! the E1 out-of-order → `QUEUED` guard, the E3 missed-close reassignment, and +//! the E4 obligation gate. The single-active-run orchestration + `recognition_run` +//! row live in the [`RecognitionRunService`](super::run_service). Group F adds: +//! [`Self::release_reversal`] (the `DR Revenue / CR CL` clawback keyed +//! `schedule_id:segment_no:reversal`, decrementing `recognized_minor`); the §9 +//! recognition metrics (recognized-minor on release, queue-depth on a park); and +//! the EXPLICIT `RECOGNITION_PERIOD_QUEUED` (a park) + `RECOGNITION_DOUBLE_CREDIT` +//! (a detected re-credit) alarms — the `OVER_RECOGNITION` alarm is the posting +//! engine's (it fires on the rolled-back release). `effective_at` is the first +//! day of the segment's own `period_id` month (the natural-period convention); +//! the OPEN-period gate is the foundation's (the post fails `PeriodClosed` if the +//! segment's period is not open). + +use std::sync::Arc; + +use bss_ledger_sdk::{ + AccountClass, MappingStatus, PostEntry, PostLine, PostingRef, Side, SourceDocType, +}; +use chrono::NaiveDate; +use toolkit_db::secure::AccessScope; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::{NewEntry, NewLine}; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::domain::ports::obligation_state::{ + AlwaysSatisfiedObligationState, ObligationContext, ObligationStateResolver, +}; +use crate::domain::status::SEGMENT_STATUS_DONE; +use crate::infra::currency_scale::CurrencyScaleResolver; +use crate::infra::events::payloads::{ + AffectedItem, AlarmCategory, AlarmSeverity, LedgerInvariantAlarm, +}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::posting::chart::{ChartIndex, load_chart}; +use crate::infra::posting::service::{PostSidecar, PostingService}; +use crate::infra::recognition::sidecar::{RecognitionReversalSidecar, RecognitionStampSidecar}; +use crate::infra::storage::repo::recognition_repo::DuePendingSegment; +use crate::infra::storage::repo::{RecognitionRepo, ReferenceRepo}; + +/// Origin literal stamped on posts made through this service (mirrors the +/// invoice-post / settlement orchestrators). +const ORIGIN_SYSTEM: &str = "SYSTEM"; + +/// A single segment to release: the schedule/segment identity + the stream / +/// currency / amount the `DR CL / CR Revenue` entry posts with. Built from a +/// [`DuePendingSegment`] read, or supplied directly by a caller that already +/// holds the context (the Group E orchestrator / the Group F job). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReleasableSegment { + pub schedule_id: String, + pub segment_no: i32, + pub period_id: String, + pub amount_minor: i64, + pub revenue_stream: String, + pub currency: String, +} + +impl From for ReleasableSegment { + fn from(s: DuePendingSegment) -> Self { + Self { + schedule_id: s.schedule_id, + segment_no: s.segment_no, + period_id: s.period_id, + amount_minor: s.amount_minor, + revenue_stream: s.revenue_stream, + currency: s.currency, + } + } +} + +/// The outcome of releasing one segment: the segment's id + the resulting +/// [`PostingRef`] (`replayed = true` when the release was an idempotent replay of +/// a prior run — at-most-once held). +#[derive(Clone, Debug)] +pub struct ReleasedSegment { + pub schedule_id: String, + pub segment_no: i32, + pub posting: PostingRef, +} + +/// A small summary of a `run_period` pass: how many due segments were released +/// (fresh + replayed) and the per-segment posting refs. The ordering-gap / +/// QUEUED accounting is Group E; for Group D this just tallies the releases. +#[derive(Clone, Debug, Default)] +pub struct RunPeriodSummary { + /// Segments released on THIS pass (a fresh post, `replayed = false`). + pub released: usize, + /// Segments that were already released (an idempotent `RECOGNITION` replay). + pub replayed: usize, + /// Segments parked `QUEUED` this pass (E1 ordering — a lower-period + /// predecessor was not yet `DONE`, so the segment is delayed, not released). + pub queued: usize, + /// Segments skipped this pass by the E4 obligation gate (the obligation was + /// not satisfied — delayed, never released early). v1 never skips (the + /// default resolver always proceeds). + pub skipped: usize, + /// The per-segment release outcomes, in release order. + pub segments: Vec, +} + +/// Releases due recognition segments through the Slice 1 posting engine. Holds +/// only what it needs: the chart reader ([`ReferenceRepo`]), the per-line scale +/// resolver, the [`PostingService`], and the [`RecognitionRepo`] (the due-segment +/// read + the in-txn counter/stamp writes the sidecar drives). +pub struct RecognitionRunner { + posting: PostingService, + reference: ReferenceRepo, + resolver: CurrencyScaleResolver, + recognition: RecognitionRepo, + /// E4 run-gating: consulted per segment before release (a NOT-satisfied + /// obligation delays the release). v1 default = always-satisfied (proceed). + obligation: Arc, + /// Metrics sink (design §9): the recognized-minor counter on each release, + /// the over-recognition + double-credit counters + the queue-depth gauge on + /// their paths, and the run-duration histogram (emitted by the run-service). + /// Held behind the port so unit tests pass [`NoopLedgerMetrics`]. + metrics: Arc, + /// Publisher for the out-of-band recognition alarms the runner raises + /// EXPLICITLY (the `RECOGNITION_PERIOD_QUEUED` park + the + /// `RECOGNITION_DOUBLE_CREDIT` stamp breach). The `OVER_RECOGNITION` alarm is + /// raised by the posting engine's `alarm_for` (it fires on the rolled-back + /// release), so the runner does not double-emit it. + publisher: Arc, +} + +impl RecognitionRunner { + /// Build the runner over one database provider + the event publisher + + /// the metrics sink (threaded into the posting engine + the §9 recognition + /// metrics). Mirrors + /// [`crate::infra::invoice_post::InvoicePostService::new`] / + /// [`crate::infra::payment::settle::SettlementService::new`]. + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + ) -> Self { + let posting = PostingService::new(db.clone(), Arc::clone(&publisher)); + let reference = ReferenceRepo::new(db.clone()); + let resolver = CurrencyScaleResolver::new(ReferenceRepo::new(db.clone())); + let recognition = RecognitionRepo::new(db); + Self { + posting, + reference, + resolver, + recognition, + // v1: no Subscriptions feed — proceed (design §4.3). The real + // fail-safe reader replaces this when the feed lands (Slice 7). + obligation: Arc::new(AlwaysSatisfiedObligationState), + metrics, + publisher, + } + } + + /// Release the due `PENDING` segments for `(tenant, period_id)` in ascending + /// `(schedule_id, segment_no)` order (Group D — the ordering GAP guard that a + /// predecessor segment be `DONE` is Group E). Each segment is released via + /// [`Self::release_segment`]; a release error short-circuits the pass and + /// propagates (the segments already released stay committed — each is its own + /// atomic post). Returns a [`RunPeriodSummary`] tallying fresh vs replayed + /// releases + the per-segment refs. + /// + /// `run_id` labels every release of this pass on its segment row + the posted + /// entry's audit linkage; the caller mints it (the Group E orchestration owns + /// the `recognition_run` row + the single-active-run lock — Group D just + /// threads the id through). + /// + /// # Errors + /// Any [`DomainError`] a per-segment release raises ([`DomainError::OverRecognition`], + /// [`DomainError::PeriodClosed`], [`DomainError::AccountClosed`], …) or + /// [`DomainError::Internal`] on an infrastructure fault. + pub async fn run_period( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + run_id: Uuid, + ) -> Result { + // TODO(slice-7): `list_due_pending_segments` is an unbounded feed — a + // per-pass cap needs continuation semantics (resume cursor) so a + // pathological backlog can't load an unbounded set into one pass; out of + // scope here. + let due = self + .recognition + .list_due_pending_segments(scope, tenant, period_id) + .await + .map_err(|e| DomainError::Internal(format!("list due segments: {e}")))?; + + // E3 missed-close (§4.3 E-2): a segment whose own target period has + // CLOSED posts into the tenant's current open period instead (its target + // stays on the segment row for audit). Resolved once per pass. + let current_open = self + .recognition + .current_open_period(scope, tenant) + .await + .map_err(|e| DomainError::Internal(format!("current open period: {e}")))?; + + // Load the chart of accounts ONCE per pass (tenant-scoped + stable across + // the pass): every segment binds its `DR CL / CR REVENUE` legs from the + // same chart, so a per-segment `load_chart` (a full chart scan) would be a + // needless N+1 against an immutable-within-the-pass projection. Bind it by + // reference into each release. + let chart = load_chart(&self.reference, scope, tenant).await?; + + let mut summary = RunPeriodSummary::default(); + for seg in due { + let seg: ReleasableSegment = seg.into(); + + // E4 run-gating: release only when the performance obligation is + // satisfied — never early. A NOT-satisfied obligation delays the + // segment (left PENDING; a later run retries). v1 default proceeds. + let obligation = ObligationContext { + tenant_id: tenant, + schedule_id: seg.schedule_id.clone(), + subscription_ref: None, + }; + if !self.obligation.is_satisfied(&obligation).await { + summary.skipped += 1; + continue; + } + + // E1 ordering (§4.6): a lower-`period_id` predecessor of the SAME + // schedule that is not yet `DONE` ⇒ park this segment `QUEUED` (do + // NOT release out of order); a later run drains it once the + // predecessor commits. + let undone = self + .recognition + .count_predecessors_not_done(scope, tenant, &seg.schedule_id, &seg.period_id) + .await + .map_err(|e| DomainError::Internal(format!("predecessor check: {e}")))?; + if undone > 0 { + self.recognition + .mark_segment_queued(scope, tenant, &seg.schedule_id, seg.segment_no) + .await + .map_err(|e| DomainError::Internal(format!("mark queued: {e}")))?; + summary.queued += 1; + // F3: one Warn `RECOGNITION_PERIOD_QUEUED` alarm per parked segment + // (best-effort, out-of-band) — a later run drains it once the + // predecessor commits, so it is re-detected until the gap closes. + self.emit_period_queued( + ctx, + tenant, + &seg.schedule_id, + seg.segment_no, + &seg.period_id, + ) + .await; + continue; + } + + // E3 missed-close: if the segment's own target period is closed + // (strictly before the current open period), release into the current + // open period — the segment row keeps its original `period_id` as the + // audit target. Otherwise release into the segment's own period. (The + // "do not release a FUTURE period early" bound is the caller's: the + // Group F job targets the CURRENT period, so `list_due_pending_segments` + // — `period_id <= target` — never enumerates a future segment. See + // `RecognitionRunJob` / H1.) + let release_seg = match ¤t_open { + Some(open) if seg.period_id.as_str() < open.as_str() => ReleasableSegment { + period_id: open.clone(), + ..seg.clone() + }, + _ => seg.clone(), + }; + let posting = match self + .release_segment_with_chart(ctx, scope, tenant, &chart, &release_seg, run_id) + .await + { + Ok(posting) => posting, + Err(e) => { + // §9 `ledger_over_recognition_total`: count the per-schedule cap + // breach here (the `OVER_RECOGNITION` alarm is the posting + // engine's `alarm_for`; this is the dedicated counter). Other + // rejections are not over-recognition. + if matches!(e, DomainError::OverRecognition(_)) { + self.metrics.over_recognition(); + } + return Err(e); + } + }; + if posting.replayed { + summary.replayed += 1; + } else { + summary.released += 1; + } + summary.segments.push(ReleasedSegment { + schedule_id: seg.schedule_id, + segment_no: seg.segment_no, + posting, + }); + } + // F3: observe the segments this pass parked QUEUED out-of-order as the + // recognition-period queue-depth gauge (design §9). NOTE: this records + // THIS pass's parked count, not the standing backlog, and + // the gauge is unlabelled — so concurrent passes (the per-tenant ticker + a + // REST trigger, across tenants) clobber each other's last write. It signals + // "a pass just parked N", not "N are queued now". An accurate backlog gauge + // needs a per-tenant label + a `COUNT(status='QUEUED')` read (follow-up). + self.metrics + .recognition_period_queue_depth(i64::try_from(summary.queued).unwrap_or(i64::MAX)); + Ok(summary) + } + + /// Release ONE due segment: build the balanced `DR CONTRACT_LIABILITY / + /// CR REVENUE` entry (both on the segment's stream, the schedule's currency, + /// `amount_minor = segment.amount_minor`), bind each leg's chart `account_id`, + /// resolve per-line scale, and post through [`PostingService`] threading the + /// [`RecognitionStampSidecar`] (the `recognized_minor` delta + segment `DONE` + /// stamp commit in the same txn). Idempotent on + /// `(tenant, RECOGNITION, schedule_id:segment_no)` — a replay returns the prior + /// [`PostingRef`] (`replayed = true`) without re-crediting. + /// + /// # Errors + /// [`DomainError::OverRecognition`] when the per-schedule cap CHECK rejects the + /// release; [`DomainError::AccountClosed`] when the stream's `CONTRACT_LIABILITY` + /// / `REVENUE` account is not provisioned; any foundation rejection + /// (period-closed / negative-balance / …) or [`DomainError::Internal`] on an + /// infrastructure fault. + pub async fn release_segment( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + segment: &ReleasableSegment, + run_id: Uuid, + ) -> Result { + // Standalone release (a single-segment caller / a test): load the chart + // once, then delegate to the chart-bound path. A `run_period` pass loads + // the chart ONCE for the whole pass and calls + // [`Self::release_segment_with_chart`] directly (no per-segment scan). + let chart = load_chart(&self.reference, scope, tenant).await?; + self.release_segment_with_chart(ctx, scope, tenant, &chart, segment, run_id) + .await + } + + /// Release ONE due segment against an ALREADY-LOADED chart of accounts — the + /// pass-internal release path. Identical to [`Self::release_segment`] but + /// takes the tenant chart by reference (hoisted once per `run_period` pass, + /// stable across it) instead of scanning the chart per segment. + /// + /// # Errors + /// Same as [`Self::release_segment`]. + async fn release_segment_with_chart( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + chart: &ChartIndex, + segment: &ReleasableSegment, + run_id: Uuid, + ) -> Result { + // Build the balanced two-line entry (nil placeholder account_ids; bound + // below from the chart, like every other post path). + let entry = build_recognition_entry(ctx, tenant, segment); + + // Bind each leg's chart account_id (per-stream CONTRACT_LIABILITY / REVENUE + // resolve on the segment's stream) from the passed-in chart. + let mut bound = entry; + for line in &mut bound.lines { + line.account_id = resolve_line(chart, line).ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + line.account_class.as_str(), + line.revenue_stream, + line.currency + )) + })?; + } + + // Map to the engine's NewEntry/NewLine (resolving per-line scale) + post, + // threading the stamp sidecar so the counter bump + segment DONE stamp + // commit atomically with the entry. + let sidecar: Arc = Arc::new(RecognitionStampSidecar { + tenant_id: tenant, + schedule_id: segment.schedule_id.clone(), + segment_no: segment.segment_no, + // The entry's period — the segment's own, or the current-open period + // when E-2 reassigned it (the caller passes the reassigned segment). + period_id: segment.period_id.clone(), + amount_minor: segment.amount_minor, + revenue_stream: segment.revenue_stream.clone(), + currency: segment.currency.clone(), + run_id, + // The runner holds the publisher + ctx; thread them in so the + // `revenue.recognized` event publishes in the SAME release txn. + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }); + let posting = match self.post_bound(ctx, scope, bound, sidecar).await { + Ok(posting) => posting, + Err(err) => { + // F3 (best-effort): a concurrent run that already released this + // segment leaves the per-segment `RECOGNITION` claim present, so + // the loser normally returns an idempotent replay BEFORE the + // sidecar — no double-credit. If instead the loser reached the + // sidecar and its `stamp_segment_done` matched no PENDING/QUEUED + // row (the segment is already `DONE`), that is a detected + // double-credit attempt: raise the `RECOGNITION_DOUBLE_CREDIT` + // alarm + counter. The original error still propagates (the post + // rolled back — no second credit landed). + self.detect_double_credit(ctx, scope, tenant, segment, &err) + .await; + return Err(err); + } + }; + // F3: count the recognized revenue moved CONTRACT_LIABILITY → REVENUE on a + // FRESH release (a replay re-credits nothing, so it is not counted). The + // stream label is the schedule's revenue stream (design §9). + if !posting.replayed { + self.metrics + .revenue_recognized_minor(segment.amount_minor, &segment.revenue_stream); + } + Ok(posting) + } + + /// Reverse / claw back ONE already-released segment (design §4.3, Group F1): + /// post the compensating `DR REVENUE / CR CONTRACT_LIABILITY` entry (the + /// mirror of [`Self::release_segment`] — same stream / currency / amount as + /// the original release) through [`PostingService`], threading the + /// [`RecognitionReversalSidecar`] so the `recognized_minor -= amount` + /// decrement commits in the SAME txn. Idempotent on + /// `(tenant, RECOGNITION, schedule_id:segment_no:reversal)` — a replay returns + /// the prior [`PostingRef`] without re-reversing. **The reversed segment stays + /// `DONE`** (its release happened and was compensated; re-recognizing the + /// period needs a new schedule version, Phase 3) — this method does NOT touch + /// the `recognition_segment` row. + /// + /// No REST endpoint in v1: a reversal is invoked by the Phase 3 + /// schedule-change / correction path (or a maintenance caller); this is the + /// mechanism it builds on. + /// + /// # Errors + /// [`DomainError::OverRecognition`] when the decrement would drive + /// `recognized_minor` below zero (a reversal larger than the cumulative + /// recognized — the non-negative cap CHECK rejects it); + /// [`DomainError::AccountClosed`] when the stream's `REVENUE` / + /// `CONTRACT_LIABILITY` account is not provisioned; any foundation rejection + /// (period-closed / …) or [`DomainError::Internal`] on an infra fault. + pub async fn release_reversal( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + segment: &ReleasableSegment, + ) -> Result { + // Build the balanced reversing entry (DR REVENUE / CR CONTRACT_LIABILITY — + // opposite of the release), keyed `schedule_id:segment_no:reversal`. + let entry = build_reversal_entry(ctx, tenant, segment); + + // Bind each leg's chart account_id (same per-stream classes as the + // release, just opposite sides). + let chart = load_chart(&self.reference, scope, tenant).await?; + let mut bound = entry; + for line in &mut bound.lines { + line.account_id = resolve_line(&chart, line).ok_or_else(|| { + DomainError::AccountClosed(format!( + "no provisioned account for class {} / stream {:?} / currency {}", + line.account_class.as_str(), + line.revenue_stream, + line.currency + )) + })?; + } + + // Thread the reversal sidecar: it DECREMENTS `recognized_minor` (negative + // delta, under the non-negative cap CHECK) and leaves the segment row + // untouched (`status = DONE` stays). + let sidecar: Arc = Arc::new(RecognitionReversalSidecar { + tenant_id: tenant, + schedule_id: segment.schedule_id.clone(), + segment_no: segment.segment_no, + period_id: segment.period_id.clone(), + amount_minor: segment.amount_minor, + revenue_stream: segment.revenue_stream.clone(), + currency: segment.currency.clone(), + // The runner holds the publisher + ctx; thread them in so the + // `revenue.recognition_reversed` event publishes in the SAME reversal + // txn. + publisher: Arc::clone(&self.publisher), + ctx: ctx.clone(), + }); + self.post_bound(ctx, scope, bound, sidecar).await + } + + /// Best-effort `RECOGNITION_DOUBLE_CREDIT` detection on a failed release: if + /// the failed segment is now `DONE` (a concurrent run already released it), + /// raise the alarm + counter. Re-reads the segment once (scoped); a read + /// failure is swallowed (this is a best-effort diagnostic on an already-failed + /// release, never a second error). Only the `stamp_segment_done` invariant + /// breach (an `Internal` error) is a double-credit candidate — an + /// `OverRecognition` / `AccountClosed` / period rejection is unrelated, so + /// those skip the probe. (Do NOT widen the probe to + /// `OverRecognition`. A same-segment second credit is caught by the per-segment + /// `RECOGNITION` idempotency claim BEFORE the post, so an `OverRecognition` cap + /// trip is a cross-segment over-recognition — not a double-credit; alarming it + /// `RECOGNITION_DOUBLE_CREDIT` would be a false positive.) + async fn detect_double_credit( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + tenant: Uuid, + segment: &ReleasableSegment, + err: &DomainError, + ) { + if !matches!(err, DomainError::Internal(_)) { + return; + } + let already_done = self + .recognition + .list_segments(scope, tenant, &segment.schedule_id) + .await + .ok() + .into_iter() + .flatten() + .any(|s| s.segment_no == segment.segment_no && s.status == SEGMENT_STATUS_DONE); + if !already_done { + return; + } + self.metrics.recognition_double_credit(); + let code = AlarmCategory::RecognitionDoubleCredit.as_str().to_owned(); + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::RecognitionDoubleCredit, + severity: AlarmSeverity::Critical, + tenant_id: tenant, + scope: format!( + "tenant:{tenant}/flow:RECOGNITION/business:{}:{}", + segment.schedule_id, segment.segment_no + ), + code, + detail: format!( + "second credit attempted for an already-DONE segment \ + (schedule={}, segment={})", + segment.schedule_id, segment.segment_no + ), + affected: vec![AffectedItem { + id: format!("{}:{}", segment.schedule_id, segment.segment_no), + currency: segment.currency.clone(), + expected_minor: 0, + actual_minor: segment.amount_minor, + }], + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + } + + /// Emit one out-of-band `RECOGNITION_PERIOD_QUEUED` `Warn` alarm for a segment + /// parked out-of-order (design §4.6 / §6). Fire-and-forget; the run continues. + async fn emit_period_queued( + &self, + ctx: &SecurityContext, + tenant: Uuid, + schedule_id: &str, + segment_no: i32, + period_id: &str, + ) { + let code = AlarmCategory::RecognitionPeriodQueued.as_str().to_owned(); + let alarm = LedgerInvariantAlarm { + category: AlarmCategory::RecognitionPeriodQueued, + severity: AlarmSeverity::Warn, + tenant_id: tenant, + scope: format!("tenant:{tenant}/flow:RECOGNITION/business:{schedule_id}:{segment_no}"), + code, + detail: format!( + "segment parked QUEUED out-of-order (schedule={schedule_id}, \ + segment={segment_no}, period={period_id}): a lower-period \ + predecessor is not yet DONE" + ), + affected: Vec::new(), + }; + self.publisher.emit_invariant_alarm(ctx, alarm).await; + } + + /// Map an already-account-bound recognition [`PostEntry`] to the engine's + /// `NewEntry`/`NewLine` (resolving each line's scale) and post with the stamp + /// sidecar. Mirrors the settlement orchestrator's `post_bound`. + async fn post_bound( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + entry: PostEntry, + sidecar: Arc, + ) -> Result { + let new_entry = NewEntry { + entry_id: entry.entry_id, + tenant_id: entry.tenant_id, + // v1: one legal entity per tenant — derived server-side. + legal_entity_id: entry.tenant_id, + period_id: entry.period_id.clone(), + entry_currency: entry.entry_currency.clone(), + source_doc_type: entry.source_doc_type, + source_business_id: entry.source_business_id.clone(), + reverses_entry_id: entry.reverses_entry_id, + reverses_period_id: entry.reverses_period_id.clone(), + posted_at_utc: chrono::Utc::now(), + effective_at: entry.effective_at, + origin: ORIGIN_SYSTEM.to_owned(), + posted_by_actor_id: entry.posted_by_actor_id, + correlation_id: entry.correlation_id, + rounding_evidence: serde_json::Value::Null, + // Slice 5: recognition is translation, not a re-lock (S6 does NOT + // re-lock, spec §3.2); the schedule currency is as posted. None here. + rate_snapshot_ref: None, + }; + let mut new_lines: Vec = Vec::with_capacity(entry.lines.len()); + for line in entry.lines { + let scale = self + .resolver + .resolve(scope, entry.tenant_id, &line.currency) + .await + .map_err(|e| DomainError::Internal(format!("currency scale resolve: {e}")))?; + new_lines.push(new_line(line, scale)); + } + self.posting + .post(ctx, scope, new_entry, new_lines, Some(sidecar)) + .await + } +} + +/// The `RECOGNITION` idempotency business id for one released segment: +/// `"{schedule_id}:{segment_no}"` (design §4.1 / §7). Set as the entry's +/// `source_business_id`; with `source_doc_type = RECOGNITION` it keys the Slice 1 +/// `IdempotencyGate` at-most-once per `(tenant, RECOGNITION, schedule_id:segment_no)`. +#[must_use] +fn recognition_business_id(schedule_id: &str, segment_no: i32) -> String { + format!("{schedule_id}:{segment_no}") +} + +/// The `RECOGNITION` idempotency business id for one segment **reversal**: +/// `"{schedule_id}:{segment_no}:reversal"` (design §4.3). Distinct from the +/// forward-release key (`schedule_id:segment_no`), so a reversal is its own +/// at-most-once unit and can never collide with the original `DONE` release. +#[must_use] +fn reversal_business_id(schedule_id: &str, segment_no: i32) -> String { + format!("{schedule_id}:{segment_no}:reversal") +} + +/// Build the balanced `DR CONTRACT_LIABILITY / CR REVENUE` [`PostEntry`] for one +/// segment release. Both legs carry the segment's `revenue_stream` (per-stream +/// disaggregation, §4.5) and the schedule's `currency`; the amounts are equal +/// (`amount_minor`), so `Σ DR == Σ CR` exactly. Account ids are nil placeholders +/// (bound from the chart by the caller). The `effective_at` is the first day of +/// the segment's `period_id` month (Group D natural-period convention; the +/// OPEN-period gate + E-2 reassignment are the foundation's / Group E's). +fn build_recognition_entry( + ctx: &SecurityContext, + tenant: Uuid, + segment: &ReleasableSegment, +) -> PostEntry { + let effective_at = first_day_of_period(&segment.period_id); + let dr = recognition_line(segment, AccountClass::ContractLiability, Side::Debit); + let cr = recognition_line(segment, AccountClass::Revenue, Side::Credit); + PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + period_id: segment.period_id.clone(), + entry_currency: segment.currency.clone(), + source_doc_type: SourceDocType::Recognition, + source_business_id: recognition_business_id(&segment.schedule_id, segment.segment_no), + effective_at, + posted_by_actor_id: ctx.subject_id(), + correlation_id: Uuid::now_v7(), + reverses_entry_id: None, + reverses_period_id: None, + lines: vec![dr, cr], + } +} + +/// Build the balanced **reversal** `DR REVENUE / CR CONTRACT_LIABILITY` +/// [`PostEntry`] for one segment clawback (design §4.3) — the mirror of +/// [`build_recognition_entry`]: the SAME stream both legs, the schedule's +/// currency, equal amounts (`amount_minor`), so `Σ DR == Σ CR` exactly. Keyed +/// `schedule_id:segment_no:reversal` under `RECOGNITION`. Account ids are nil +/// placeholders (bound from the chart by the caller). The `effective_at` is the +/// first day of the segment's `period_id` month (the same natural-period +/// convention as the release; the OPEN-period gate is the foundation's). +fn build_reversal_entry( + ctx: &SecurityContext, + tenant: Uuid, + segment: &ReleasableSegment, +) -> PostEntry { + let effective_at = first_day_of_period(&segment.period_id); + // Opposite sides of the release: DR REVENUE (give back the recognized + // revenue) / CR CONTRACT_LIABILITY (restore the deferred balance). + let dr = recognition_line(segment, AccountClass::Revenue, Side::Debit); + let cr = recognition_line(segment, AccountClass::ContractLiability, Side::Credit); + PostEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + period_id: segment.period_id.clone(), + entry_currency: segment.currency.clone(), + source_doc_type: SourceDocType::Recognition, + source_business_id: reversal_business_id(&segment.schedule_id, segment.segment_no), + effective_at, + posted_by_actor_id: ctx.subject_id(), + correlation_id: Uuid::now_v7(), + reverses_entry_id: None, + reverses_period_id: None, + lines: vec![dr, cr], + } +} + +/// Build one recognition [`PostLine`] for `class`/`side` from the segment: the +/// stream-tagged per-stream class (`CONTRACT_LIABILITY` / `REVENUE`), the +/// schedule's currency, the segment amount. The `account_id` is a nil placeholder +/// (bound from the chart by the caller). Recognition lines carry no +/// payer/invoice/tax dims — they move deferred revenue to earned revenue within +/// the seller's own ledger — so `payer_tenant_id` is the nil placeholder +/// ([`segment_payer_placeholder`]; both legs share it, so the entry is trivially +/// single-payer) and the optional dims are `None`. +fn recognition_line(segment: &ReleasableSegment, class: AccountClass, side: Side) -> PostLine { + PostLine { + line_id: Uuid::now_v7(), + payer_tenant_id: segment_payer_placeholder(), + seller_tenant_id: None, + resource_tenant_id: None, + account_id: Uuid::nil(), + account_class: class, + gl_code: None, + side, + amount_minor: segment.amount_minor, + currency: segment.currency.clone(), + invoice_id: None, + due_date: None, + revenue_stream: Some(segment.revenue_stream.clone()), + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// The payer-tenant placeholder for a recognition line. A recognition entry moves +/// the seller's own deferred revenue to earned revenue (no buyer is party to it), +/// so there is no real payer; the foundation's single-payer-tenant entry +/// invariant still wants a value, so the nil UUID stands in (both legs share it, +/// so the entry is trivially single-payer). The payer-on-the-schedule +/// (`payer_tenant_id`) is the audit fact, recorded on the schedule, not re-stamped +/// on the recognition lines. +#[must_use] +fn segment_payer_placeholder() -> Uuid { + Uuid::nil() +} + +/// First day of a `YYYYMM` `period_id` as the entry's `effective_at`. A +/// malformed period (not a parseable `YYYYMM`) falls back to [`NaiveDate::MIN`], +/// which the foundation's OPEN-period gate rejects — a malformed segment never +/// silently posts to a wrong date. (Group D's natural-period convention; E-2 +/// missed-close reassignment is Group E.) +#[must_use] +fn first_day_of_period(period_id: &str) -> NaiveDate { + parse_period(period_id) + .and_then(|(y, m)| NaiveDate::from_ymd_opt(y, m, 1)) + // Defensive only — the segment row's period is validated at + // schedule-build (`period_id_plus`), so this never fires in practice; + // `MIN` is a const (no panic) the OPEN-period gate rejects. + .unwrap_or(NaiveDate::MIN) +} + +/// Parse a `YYYYMM` period id into `(year, month)`; `None` when it is not a +/// 6-char string with a `1..=12` month (mirrors the validation in +/// [`crate::domain::period`]). +fn parse_period(period_id: &str) -> Option<(i32, u32)> { + if period_id.len() != 6 { + return None; + } + let year: i32 = period_id.get(0..4)?.parse().ok()?; + let month: u32 = period_id.get(4..6)?.parse().ok()?; + if !(1..=12).contains(&month) { + return None; + } + Some((year, month)) +} + +/// Thin `PostLine` adapter over [`ChartIndex::resolve`]: per-stream classes +/// (`CONTRACT_LIABILITY` / `REVENUE`) key on the line's stream. Mirrors the +/// invoice-post / settlement `resolve_line`. +fn resolve_line(chart: &ChartIndex, line: &PostLine) -> Option { + chart.resolve( + line.account_class, + &line.currency, + line.revenue_stream.as_deref(), + ) +} + +/// Map one SDK [`PostLine`] + its resolved scale to the engine's [`NewLine`] +/// (mirrors `invoice_post::new_line` / `settle::new_line`). +fn new_line(line: PostLine, scale: u8) -> NewLine { + NewLine { + line_id: line.line_id, + payer_tenant_id: line.payer_tenant_id, + seller_tenant_id: line.seller_tenant_id, + resource_tenant_id: line.resource_tenant_id, + account_id: line.account_id, + account_class: line.account_class, + gl_code: line.gl_code, + side: line.side, + amount_minor: line.amount_minor, + currency: line.currency, + currency_scale: scale, + invoice_id: line.invoice_id, + due_date: line.due_date, + revenue_stream: line.revenue_stream, + mapping_status: line.mapping_status, + functional_amount_minor: line.functional_amount_minor, + functional_currency: line.functional_currency, + tax_jurisdiction: line.tax_jurisdiction, + tax_filing_period: line.tax_filing_period, + tax_rate_ref: line.tax_rate_ref, + legal_entity_id: None, + invoice_item_ref: line.invoice_item_ref, + sku_or_plan_ref: line.sku_or_plan_ref, + price_id: line.price_id, + pricing_snapshot_ref: line.pricing_snapshot_ref, + po_allocation_group: line.po_allocation_group, + credit_grant_event_type: line.credit_grant_event_type, + ar_status: line.ar_status, + } +} + +#[cfg(test)] +#[path = "runner_tests.rs"] +mod runner_tests; diff --git a/gears/bss/ledger/ledger/src/infra/recognition/runner_tests.rs b/gears/bss/ledger/ledger/src/infra/recognition/runner_tests.rs new file mode 100644 index 000000000..7a19fac9d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/recognition/runner_tests.rs @@ -0,0 +1,218 @@ +//! Pure unit tests for the `RecognitionRunner` entry construction (Group D): +//! the balanced `DR CONTRACT_LIABILITY / CR REVENUE` shape (same stream both +//! legs, equal amount, `Σ DR == Σ CR`), the `RECOGNITION` idempotency key +//! (`schedule_id:segment_no`), the schedule currency on the entry + lines, and +//! the natural-period `effective_at`. The atomic-release / over-recognition / +//! idempotent-replay behaviours need a database and are Group F4 testcontainers +//! tests (NOT here) — see the note at the foot of this file. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use bss_ledger_sdk::{AccountClass, Side, SourceDocType}; +use chrono::{Datelike, NaiveDate}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::*; + +fn segment() -> ReleasableSegment { + ReleasableSegment { + schedule_id: "sched-7".to_owned(), + segment_no: 3, + period_id: "202607".to_owned(), + amount_minor: 2_500, + revenue_stream: "recurring".to_owned(), + currency: "USD".to_owned(), + } +} + +#[test] +fn business_id_is_schedule_colon_segment() { + assert_eq!(recognition_business_id("sched-7", 3), "sched-7:3"); +} + +#[test] +fn entry_is_recognition_keyed_on_schedule_segment() { + let ctx = SecurityContext::anonymous(); + let tenant = Uuid::from_u128(1); + let entry = build_recognition_entry(&ctx, tenant, &segment()); + + assert_eq!(entry.source_doc_type, SourceDocType::Recognition); + assert_eq!(entry.source_business_id, "sched-7:3"); + assert_eq!(entry.tenant_id, tenant); + assert_eq!(entry.entry_currency, "USD"); + // A forward release reverses nothing. + assert!(entry.reverses_entry_id.is_none()); + assert!(entry.reverses_period_id.is_none()); +} + +#[test] +fn entry_is_dr_contract_liability_cr_revenue_same_stream_equal_amount() { + let ctx = SecurityContext::anonymous(); + let entry = build_recognition_entry(&ctx, Uuid::from_u128(1), &segment()); + + assert_eq!(entry.lines.len(), 2, "exactly two legs"); + + let dr = entry + .lines + .iter() + .find(|l| l.side == Side::Debit) + .expect("a DR leg"); + let cr = entry + .lines + .iter() + .find(|l| l.side == Side::Credit) + .expect("a CR leg"); + + // DR CONTRACT_LIABILITY (draw down the deferred balance). + assert_eq!(dr.account_class, AccountClass::ContractLiability); + // CR REVENUE (recognize). + assert_eq!(cr.account_class, AccountClass::Revenue); + + // Both legs carry the SAME stream (per-stream disaggregation, §4.5). + assert_eq!(dr.revenue_stream.as_deref(), Some("recurring")); + assert_eq!(cr.revenue_stream.as_deref(), Some("recurring")); + + // Equal amounts ⇒ balanced (Σ DR == Σ CR), the schedule currency on both. + assert_eq!(dr.amount_minor, 2_500); + assert_eq!(cr.amount_minor, 2_500); + assert_eq!(dr.currency, "USD"); + assert_eq!(cr.currency, "USD"); + + // Lines are bound from the chart later — the builder emits the nil placeholder. + assert_eq!(dr.account_id, Uuid::nil()); + assert_eq!(cr.account_id, Uuid::nil()); + + // A recognition leg carries no AR/invoice/tax dims. + assert!(dr.invoice_id.is_none()); + assert!(cr.invoice_id.is_none()); + assert!(dr.tax_jurisdiction.is_none()); + assert!(cr.ar_status.is_none()); +} + +#[test] +fn entry_posts_to_the_segments_period() { + let ctx = SecurityContext::anonymous(); + let entry = build_recognition_entry(&ctx, Uuid::from_u128(1), &segment()); + assert_eq!(entry.period_id, "202607"); + // effective_at is the first day of that period (Group D natural-period rule). + assert_eq!( + entry.effective_at, + NaiveDate::from_ymd_opt(2026, 7, 1).unwrap() + ); + assert_eq!(entry.effective_at.day(), 1); + assert_eq!(entry.effective_at.month(), 7); +} + +#[test] +fn first_day_of_period_parses_yyyymm() { + assert_eq!( + first_day_of_period("202607"), + NaiveDate::from_ymd_opt(2026, 7, 1).unwrap() + ); + assert_eq!( + first_day_of_period("202612"), + NaiveDate::from_ymd_opt(2026, 12, 1).unwrap() + ); +} + +#[test] +fn first_day_of_period_malformed_falls_back_to_a_gate_rejectable_sentinel() { + // A malformed period yields `NaiveDate::MIN` — the foundation OPEN-period + // gate rejects it; it never silently posts to a wrong date. + let bad = first_day_of_period("oops"); + assert_eq!(bad, NaiveDate::MIN); + // Out-of-range month is also rejected. + assert_eq!(first_day_of_period("202613"), NaiveDate::MIN); +} + +#[test] +fn reversal_business_id_is_schedule_colon_segment_colon_reversal() { + // Distinct from the forward-release key (`sched-7:3`), so a reversal is its + // own at-most-once unit and never collides with the original DONE release. + assert_eq!(reversal_business_id("sched-7", 3), "sched-7:3:reversal"); + assert_ne!( + reversal_business_id("sched-7", 3), + recognition_business_id("sched-7", 3) + ); +} + +#[test] +fn reversal_entry_is_dr_revenue_cr_contract_liability_same_stream_equal_amount() { + let ctx = SecurityContext::anonymous(); + let entry = build_reversal_entry(&ctx, Uuid::from_u128(1), &segment()); + + assert_eq!(entry.source_doc_type, SourceDocType::Recognition); + assert_eq!(entry.source_business_id, "sched-7:3:reversal"); + assert_eq!(entry.lines.len(), 2, "exactly two legs"); + + let dr = entry + .lines + .iter() + .find(|l| l.side == Side::Debit) + .expect("a DR leg"); + let cr = entry + .lines + .iter() + .find(|l| l.side == Side::Credit) + .expect("a CR leg"); + + // The MIRROR of the release: DR REVENUE (give back the recognized revenue) / + // CR CONTRACT_LIABILITY (restore the deferred balance). + assert_eq!(dr.account_class, AccountClass::Revenue); + assert_eq!(cr.account_class, AccountClass::ContractLiability); + + // Both legs carry the same stream + currency; equal amounts ⇒ balanced. + assert_eq!(dr.revenue_stream.as_deref(), Some("recurring")); + assert_eq!(cr.revenue_stream.as_deref(), Some("recurring")); + assert_eq!(dr.amount_minor, 2_500); + assert_eq!(cr.amount_minor, 2_500); + assert_eq!(dr.currency, "USD"); + assert_eq!(cr.currency, "USD"); + + // A reversal reverses nothing via the header's reverse-link (it is a fresh + // compensating entry keyed on the `:reversal` business id, not a strict + // line-negation reversal); account ids are bound from the chart later. + assert!(entry.reverses_entry_id.is_none()); + assert_eq!(dr.account_id, Uuid::nil()); + assert_eq!(cr.account_id, Uuid::nil()); +} + +#[test] +fn due_pending_segment_projects_into_releasable() { + let due = DuePendingSegment { + schedule_id: "s1".to_owned(), + segment_no: 1, + period_id: "202606".to_owned(), + amount_minor: 100, + revenue_stream: "usage".to_owned(), + currency: "EUR".to_owned(), + total_deferred_minor: 1_200, + recognized_minor: 0, + }; + let r: ReleasableSegment = due.into(); + assert_eq!(r.schedule_id, "s1"); + assert_eq!(r.segment_no, 1); + assert_eq!(r.revenue_stream, "usage"); + assert_eq!(r.currency, "EUR"); + assert_eq!(r.amount_minor, 100); +} + +// ── NOTE — Group F4 testcontainers coverage (NOT in this pure-unit file) ── +// The integration/concurrency tests for the release + reversal live in +// `tests/postgres_recognition_run.rs` (Group F4, design §11), driving the REAL +// `RecognitionRunService` against a testcontainer Postgres. They cover: +// * atomic release: `DR CL / CR Revenue` + the `recognized_minor += amount` +// bump + the segment `→ DONE` stamp all commit in ONE txn; +// * at-most-once: a re-run of the same `(schedule, segment)` replays the prior +// entry (no second credit) via the `RECOGNITION` idempotency claim + the +// `status = DONE` / `UNIQUE (schedule, period_id)` guards; +// * over-recognition: a release pushing `recognized_minor` past +// `total_deferred_minor` is blocked at the per-schedule cap CHECK and maps to +// `OverRecognition` (409) — even when a sibling schedule keeps the per-stream +// `CONTRACT_LIABILITY` account aggregate positive; +// * reversal: `release_reversal` posts `DR Revenue / CR CL`, decrements +// `recognized_minor`, and the reversed segment stays `DONE`; +// * racing runs on the same / different segments → no double-credit; +// * ordering: period N released before N-1 is DONE → N parked QUEUED, then +// drained by a later run once N-1 commits. diff --git a/gears/bss/ledger/ledger/src/infra/recognition/sidecar.rs b/gears/bss/ledger/ledger/src/infra/recognition/sidecar.rs new file mode 100644 index 000000000..2c553d249 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/recognition/sidecar.rs @@ -0,0 +1,556 @@ +//! [`ScheduleBuilderSidecar`] — the in-transaction [`PostSidecar`] that +//! materializes ASC 606 recognition schedules in the SAME serializable +//! transaction as the invoice post's `CR CONTRACT_LIABILITY` credit (design +//! §4.2 / Group C2/C3). Mirrors the payment +//! [sidecars](crate::infra::payment::sidecar): its writes commit atomically with +//! the journal entry or roll back with it (a derivation that produced a schedule +//! whose insert fails — or a duplicate-build collision — rolls the whole post +//! back, so a deferred Contract-liability balance never exists without its +//! schedule, and a schedule never exists without the balance). +//! +//! For each [`BuiltSchedule`] the pure derivation produced (one per deferred +//! item-stream), [`run`](ScheduleBuilderSidecar::run): +//! +//! 1. **Claims `SCHEDULE_BUILD` idempotency** keyed +//! `business_id = source_invoice_id:source_invoice_item_ref:revenue_stream`. +//! `SCHEDULE_BUILD` posts NO journal entry of its own (the invoice post is the +//! entry); the claim is purely the at-most-once build guard. On a **replay** +//! (the key is already present — a duplicate build of the same invoice/item/ +//! stream) it **skips** the schedule (the ACTIVE schedule already exists; the +//! partial UNIQUE is the storage backstop) and does NOT mint a second +//! `schedule_id`. The claim is never `finalize`d (there is no result entry to +//! stamp); it stays `CLAIMED` as a permanent build marker. +//! 2. On a **fresh claim**, mints a fresh `schedule_id` (`UUIDv7` string), projects +//! the [`BuiltSchedule`] into the [`NewSchedule`] + [`NewSegment`] insert +//! shapes (supplying the posting-context identity it holds — `tenant_id`, +//! `payer_tenant_id`, `source_invoice_id`, and the schedule's +//! `source_invoice_item_ref`), and inserts both via [`RecognitionRepo`]. +//! +//! A deferred item MUST carry an `invoice_item_ref` (`source_invoice_item_ref` +//! is `NOT NULL` and must resolve to the Contract-liability line this very post +//! created, §4.7) — the orchestrator blocks a deferred item that lacks one +//! BEFORE the post, so every [`PlannedScheduleMaterialization`] here already +//! carries a non-empty ref. + +use std::collections::HashMap; +use std::sync::Arc; + +use bss_ledger_sdk::SourceDocType; +use chrono::Utc; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::RepoError; +use crate::domain::recognition::builder::BuiltSchedule; +use crate::infra::events::payloads::{LedgerRevenueRecognitionReversed, LedgerRevenueRecognized}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::posting::idempotency::{ClaimOutcome, IdempotencyGate}; +use crate::infra::posting::service::{PostSidecar, PostedFacts}; +use crate::infra::storage::repo::RecognitionRepo; +use crate::infra::storage::repo::recognition_repo::{NewSchedule, NewSegment}; + +/// One schedule to materialize: the pure [`BuiltSchedule`] plan plus the +/// `source_invoice_item_ref` it draws down (the Contract-liability line this post +/// created, §4.7). The orchestrator pairs each derived schedule with its item's +/// ref (asserted non-empty before the post) and the sidecar projects the pair +/// into the storage rows. +#[derive(Clone, Debug)] +pub struct PlannedScheduleMaterialization { + /// The derived schedule plan (deferred amount + segments + stamped refs). + pub schedule: BuiltSchedule, + /// The deferred item's `invoice_item_ref` — the `recognition_schedule` + /// `source_invoice_item_ref` (NOT NULL); non-empty by orchestrator invariant. + pub source_invoice_item_ref: String, +} + +/// In-transaction sidecar that materializes the derived recognition schedules. +/// Holds the plans + the posting-context identity common to all of them (the +/// per-schedule identity lives on each [`PlannedScheduleMaterialization`]). +pub struct ScheduleBuilderSidecar { + /// The seller tenant whose ledger this posts into (`= entry.tenant_id`). + pub tenant_id: Uuid, + /// The tenant that pays the invoice (the schedule's `payer_tenant_id`). + pub payer_tenant_id: Uuid, + /// The external invoice id (the schedule's `source_invoice_id` + the first + /// segment of the `SCHEDULE_BUILD` dedup business id). + pub source_invoice_id: String, + /// The schedules to materialize (one per deferred item-stream). + pub schedules: Vec, + /// The at-most-once build gate (claims `SCHEDULE_BUILD`). + pub idempotency: IdempotencyGate, + /// Discriminates a later EXTEND build (a debit note adding deferred to a live + /// schedule) from the FIRST build (invoice-post): `None` for the invoice-post + /// (mints the schedule), `Some(note_id)` for a debit note — so its + /// `SCHEDULE_BUILD` claim does not collide with (and replay → skip) the base + /// build, and it EXTENDS the live schedule instead of minting a second one the + /// partial UNIQUE would reject. + pub build_discriminator: Option, +} + +impl ScheduleBuilderSidecar { + /// The `idempotency_dedup` business id for one schedule build: + /// `source_invoice_id:source_invoice_item_ref:revenue_stream` (design §3.2), + /// suffixed with the `build_discriminator` (a debit note's id) when set so an + /// EXTEND build does not collide with (and replay → skip) the base build. One + /// schedule per stream, so the stream tail keeps a multi-stream invoice's + /// builds distinct. + fn build_business_id(&self, item_ref: &str, revenue_stream: &str) -> String { + match &self.build_discriminator { + Some(d) => format!("{}:{item_ref}:{revenue_stream}:{d}", self.source_invoice_id), + None => format!("{}:{item_ref}:{revenue_stream}", self.source_invoice_id), + } + } + + /// Project one [`BuiltSchedule`] + its `source_invoice_item_ref` into the + /// repo insert shapes, minting the supplied `schedule_id`. Pure (no I/O); the + /// caller runs the inserts. + fn project( + &self, + schedule_id: &str, + plan: &PlannedScheduleMaterialization, + ) -> (NewSchedule, Vec) { + let s = &plan.schedule; + let new_schedule = NewSchedule { + tenant_id: self.tenant_id, + schedule_id: schedule_id.to_owned(), + payer_tenant_id: self.payer_tenant_id, + source_invoice_id: self.source_invoice_id.clone(), + source_invoice_item_ref: plan.source_invoice_item_ref.clone(), + po_allocation_group: s.po_allocation_group.clone(), + subscription_ref: s.subscription_ref.clone(), + revenue_stream: s.revenue_stream.clone(), + currency: s.currency.clone(), + total_deferred_minor: s.deferred_minor, + policy_ref: s.policy_ref.clone(), + ssp_snapshot_ref: s.ssp_snapshot_ref.clone(), + vc_estimate_ref: s.vc_estimate_ref.clone(), + vc_method_ref: s.vc_method_ref.clone(), + }; + let segments: Vec = s + .segments + .iter() + .map(|seg| NewSegment { + tenant_id: self.tenant_id, + schedule_id: schedule_id.to_owned(), + segment_no: seg.segment_no, + period_id: seg.period_id.clone(), + amount_minor: seg.amount_minor, + }) + .collect(); + (new_schedule, segments) + } + + /// EXTEND a live ACTIVE schedule with a later note's deferred part: add to its + /// `total_deferred_minor` and MERGE the note's segments — fold the amount into + /// an existing PENDING period, else append a fresh segment (continuing + /// `segment_no` past the current max). One ACTIVE schedule per key is preserved + /// (the partial UNIQUE), so the credit-note splitter + the recognition runner + /// see ONE aggregate releasable balance, not a skipped second schedule. + /// Extending a period already released / parked (non-`PENDING`) is rejected by + /// `add_pending_segment_amount` (rolls the post back) — a debit note normally + /// lands before the base schedule's first release. + async fn extend( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + schedule_id: &str, + plan: &PlannedScheduleMaterialization, + ) -> Result<(), DomainError> { + let s = &plan.schedule; + RecognitionRepo::increase_total_deferred( + txn, + scope, + self.tenant_id, + schedule_id, + s.deferred_minor, + ) + .await + .map_err(|e| DomainError::Internal(format!("extend total_deferred: {e}")))?; + + let existing = + RecognitionRepo::list_segments_in_txn(txn, scope, self.tenant_id, schedule_id) + .await + .map_err(|e| DomainError::Internal(format!("list segments for extend: {e}")))?; + let by_period: HashMap<&str, i32> = existing + .iter() + .map(|r| (r.period_id.as_str(), r.segment_no)) + .collect(); + let mut next_no = existing.iter().map(|r| r.segment_no).max().unwrap_or(0) + 1; + + for seg in &s.segments { + if let Some(&segment_no) = by_period.get(seg.period_id.as_str()) { + RecognitionRepo::add_pending_segment_amount( + txn, + scope, + self.tenant_id, + schedule_id, + segment_no, + seg.amount_minor, + ) + .await + .map_err(|e| DomainError::Internal(format!("extend segment: {e}")))?; + } else { + let appended = vec![NewSegment { + tenant_id: self.tenant_id, + schedule_id: schedule_id.to_owned(), + segment_no: next_no, + period_id: seg.period_id.clone(), + amount_minor: seg.amount_minor, + }]; + RecognitionRepo::insert_segments(txn, scope, &appended) + .await + .map_err(|e| DomainError::Internal(format!("append extend segment: {e}")))?; + next_no += 1; + } + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl PostSidecar for ScheduleBuilderSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + _posted: &PostedFacts, + ) -> Result<(), DomainError> { + let flow = SourceDocType::ScheduleBuild.as_str(); + for plan in &self.schedules { + let business_id = self + .build_business_id(&plan.source_invoice_item_ref, &plan.schedule.revenue_stream); + + // Claim the SCHEDULE_BUILD key. There is no journal entry of its own + // for this flow, so the payload hash is over the build business id + // (stable across retries). A `Replay` means the schedule was already + // built (a duplicate build) — skip; the ACTIVE schedule exists. + let payload_hash = IdempotencyGate::content_hash(&business_id); + match self + .idempotency + .claim(txn, self.tenant_id, flow, &business_id, &payload_hash) + .await + .map_err(|e| { + DomainError::Internal(format!("schedule-build idempotency claim: {e}")) + })? { + ClaimOutcome::Replay(_) => continue, + ClaimOutcome::Claimed => {} + } + + // Fresh claim: EXTEND the live schedule if one exists for this key (a + // later deferring note — a debit note — adds its deferred part to it; + // one ACTIVE schedule per key, the partial UNIQUE), else mint the FIRST + // schedule (the invoice-post). A failure rolls the whole post back. + if let Some(existing) = RecognitionRepo::read_active_schedule_in_txn( + txn, + scope, + self.tenant_id, + &self.source_invoice_id, + &plan.source_invoice_item_ref, + &plan.schedule.revenue_stream, + ) + .await + .map_err(|e| DomainError::Internal(format!("read active schedule: {e}")))? + { + self.extend(txn, scope, &existing.schedule_id, plan).await?; + } else { + let schedule_id = Uuid::now_v7().to_string(); + let (new_schedule, segments) = self.project(&schedule_id, plan); + RecognitionRepo::insert_schedule(txn, scope, &new_schedule) + .await + .map_err(|e| { + DomainError::Internal(format!("insert recognition_schedule: {e}")) + })?; + RecognitionRepo::insert_segments(txn, scope, &segments) + .await + .map_err(|e| { + DomainError::Internal(format!("insert recognition_segment: {e}")) + })?; + } + } + Ok(()) + } +} + +/// In-transaction [`PostSidecar`] for one released recognition segment (design +/// §4.3, Group D2). Threaded by the [`RecognitionRunner`](super::runner) into the +/// `DR CONTRACT_LIABILITY / CR REVENUE` post so the journal entry, the +/// `recognized_minor += amount` counter bump, and the segment `→ DONE` stamp +/// commit atomically in the SAME serializable transaction (or roll back +/// together) — the post engine runs this AFTER balance projection and BEFORE the +/// dedup finalize, on the fresh-claim path only (a `RECOGNITION` replay returns +/// before the sidecar, so a re-credit is structurally impossible). +/// +/// **Lock order (design §2 / §4.3).** The post's projection already locked the +/// `CONTRACT_LIABILITY` + `REVENUE` `account_balance` rows (rank 0). This sidecar +/// then takes the recognition rows in the global rank order: **`recognition_schedule` +/// (the `recognized_minor` delta, rank 6) BEFORE `recognition_segment` (the +/// `DONE` stamp, rank 7)** — acquire schedule before segment, one consistent +/// order across all recognition posts, so concurrent runs serialize and never +/// deadlock. +/// +/// **Over-recognition guard.** `add_recognized`'s per-schedule +/// `recognized_minor <= total_deferred_minor` cap CHECK is the authoritative, +/// in-txn, lock-ordered guard; a breach surfaces from the repo as +/// [`RepoError::MoneyOutCapExceeded`], which this sidecar refines to +/// [`DomainError::OverRecognition`] (the `OVER_RECOGNITION` 409). The post engine +/// encodes that as a non-retryable business rejection and rolls the whole release +/// back — the counter is never advanced past the deferred total. +pub struct RecognitionStampSidecar { + /// The seller tenant whose ledger this releases into (`= entry.tenant_id`). + pub tenant_id: Uuid, + /// The owning schedule's id (the `recognized_minor` counter grain + the first + /// segment of the `RECOGNITION` dedup business id). + pub schedule_id: String, + /// The released segment's number (immutable, 1:1 with `period_id`). + pub segment_no: i32, + /// The accounting period the recognized revenue lands in (`YYYYMM`) — the + /// release entry's period (the segment's own, or the current-open period on + /// an E-2 missed-close reassignment). Carried only for the + /// `billing.ledger.revenue.recognized` event payload. + pub period_id: String, + /// The segment's amount released this post (`= the entry's DR/CR amount`), + /// added to `recognized_minor` under the cap CHECK. + pub amount_minor: i64, + /// The revenue stream both legs draw (per-stream disaggregation). Carried for + /// the recognized-event payload. + pub revenue_stream: String, + /// ISO-4217 currency of the release entry. Carried for the recognized-event + /// payload. + pub currency: String, + /// The run that released this segment (stamped on the segment row for audit + /// linkage). + pub run_id: Uuid, + /// The event publisher: `billing.ledger.revenue.recognized` is published IN + /// this post txn (the transactional outbox) so it commits atomically with the + /// release entry + the counter bump + the segment `DONE` stamp, or rolls back + /// with them. Mirrors the payment + /// [sidecars](crate::infra::payment::sidecar). + pub publisher: Arc, + /// The security context for the in-txn outbox publish (the same `ctx` the + /// engine threads through; cloned by the runner into the sidecar). + pub ctx: SecurityContext, +} + +#[async_trait::async_trait] +impl PostSidecar for RecognitionStampSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + _posted: &PostedFacts, + ) -> Result<(), DomainError> { + // 1. Schedule first (rank 6): bump `recognized_minor` by the released + // amount. The per-schedule cap CHECK is the SERIALIZABLE backstop — an + // over-release surfaces as `MoneyOutCapExceeded`, refined to + // `OverRecognition` (409). A replay returned before the sidecar, so this + // is reached only on the first release of `(schedule, segment)`. + RecognitionRepo::add_recognized( + txn, + scope, + self.tenant_id, + &self.schedule_id, + self.amount_minor, + ) + .await + .map_err(map_recognition_repo_err)?; + + // 2. Segment next (rank 7): flip PENDING/QUEUED → DONE, stamping + // `recognized_at` (the infra wall clock — `Utc::now()` is allowed in + // infra, mirroring the payment sidecars' `allocated_at_utc`) + `run_id`. + // The status filter refuses an already-DONE row, so a stray re-stamp on + // the fresh-claim path is an invariant breach that rolls the post back + // (`RepoError::Db` → `Internal`) rather than silently double-crediting. + RecognitionRepo::stamp_segment_done( + txn, + scope, + self.tenant_id, + &self.schedule_id, + self.segment_no, + self.run_id, + Utc::now(), + ) + .await + .map_err(map_recognition_repo_err)?; + + // 3. Terminal completion (design §4.6): if THIS release drained the + // schedule (`recognized_minor == total_deferred_minor` after the bump + // above, all segments DONE), flip it `ACTIVE → COMPLETED` in the SAME + // txn — freeing the partial one-live UNIQUE slot, dropping it from the + // runner's ACTIVE-only feed + the `schedule_active_total` gauge. The + // filter is column-to-column equality, so this is a no-op on every + // non-final release and on a replay (idempotent); it never bumps + // `version` (COMPLETED is the same schedule reaching terminal, not a + // new lineage). RELEASE path only — the reversal sidecar must NOT + // complete (a reversal un-drains the schedule). + RecognitionRepo::complete_schedule_if_drained( + txn, + scope, + self.tenant_id, + &self.schedule_id, + ) + .await + .map_err(map_recognition_repo_err)?; + + // 4. Publish `billing.ledger.revenue.recognized` into the SAME post txn + // (transactional outbox): the event row commits atomically with the + // release entry + the counter bump + the segment `DONE` stamp, or a + // publish failure rolls the whole release back. Ids + amount + stream + + // period only (no PII). Reached only on the fresh-claim path (a replay + // returns before the sidecar), so the event fires once per release. + self.publisher + .publish_revenue_recognized( + &self.ctx, + txn, + LedgerRevenueRecognized { + tenant_id: self.tenant_id, + schedule_id: self.schedule_id.clone(), + segment_no: self.segment_no, + period_id: self.period_id.clone(), + amount_minor: self.amount_minor, + revenue_stream: self.revenue_stream.clone(), + currency: self.currency.clone(), + }, + ) + .await + .map_err(|e| DomainError::Internal(format!("publish revenue_recognized: {e}")))?; + + Ok(()) + } +} + +/// Map a recognition-counter [`RepoError`] into the sidecar's [`DomainError`]: +/// the per-schedule `recognized_minor <= total_deferred_minor` cap CHECK +/// violation (`add_recognized`) becomes [`DomainError::OverRecognition`] (the +/// `OVER_RECOGNITION` 409 — design §4.3 / §5); every other repo failure (incl. +/// the `stamp_segment_done` `rows_affected == 0` invariant breach) is an +/// infrastructure fault whose diagnostic stays server-side and rolls the post +/// back. Mirrors the payment sidecars' `map_*_repo_err` shape. +fn map_recognition_repo_err(e: RepoError) -> DomainError { + match e { + RepoError::MoneyOutCapExceeded(m) => DomainError::OverRecognition(m), + other => DomainError::Internal(format!("recognition stamp sidecar: {other}")), + } +} + +/// In-transaction [`PostSidecar`] for one recognition **reversal / clawback** +/// (design §4.3, Group F1). Threaded by the [`RecognitionRunner`](super::runner) +/// into the compensating `DR REVENUE / CR CONTRACT_LIABILITY` post so the +/// reversing journal entry and the `recognized_minor -= amount` counter +/// **decrement** commit in the SAME serializable transaction (or roll back +/// together). The reversal is the mirror of [`RecognitionStampSidecar`]: it +/// posts the opposite legs and applies a NEGATIVE delta to `recognized_minor`. +/// +/// **The reversed segment stays `DONE` (design §4.3).** A reversal compensates a +/// release that genuinely happened; the segment's release is a historical fact, +/// so its `recognition_segment` row is left untouched (`status = DONE`, +/// `recognized_at`/`run_id` preserved). Re-recognizing the period needs a NEW +/// schedule version (a fresh `schedule_id`, Phase 3) — never a re-flip of this +/// segment back to `PENDING`. This sidecar therefore writes ONLY the counter +/// decrement; it does not touch the segment row. +/// +/// **Lock order (design §2 / §4.3).** Same as the release: the post's projection +/// already locked the `REVENUE` + `CONTRACT_LIABILITY` `account_balance` rows +/// (rank 0); this sidecar then takes only the `recognition_schedule` row (the +/// `recognized_minor` delta, rank 6). It never touches `recognition_segment` +/// (rank 7), so it acquires a strict prefix of the release's lock set — no new +/// ordering edge, no deadlock. +/// +/// **Underflow guard.** `add_recognized` with a negative delta is guarded by the +/// per-schedule `recognized_minor >= 0` cap CHECK +/// (`chk_ledger_recognition_schedule_recognized_nonneg`): a reversal larger than +/// the cumulative recognized would drive the counter below zero and is rejected, +/// surfacing from the repo as [`RepoError::MoneyOutCapExceeded`] (both schedule +/// CHECKs share the `chk_ledger_recognition_schedule_` prefix the repo's +/// violation classifier keys on). This sidecar refines that to +/// [`DomainError::OverRecognition`] with a reversal-specific detail — a reversal +/// can never un-recognize more than was recognized. +pub struct RecognitionReversalSidecar { + /// The seller tenant whose ledger this reverses within (`= entry.tenant_id`). + pub tenant_id: Uuid, + /// The owning schedule's id (the `recognized_minor` counter grain + the first + /// segment of the `RECOGNITION` reversal dedup business id). + pub schedule_id: String, + /// The reversed segment's number (the segment stays `DONE`). Carried for the + /// `billing.ledger.revenue.recognition_reversed` event payload. + pub segment_no: i32, + /// The accounting period the reversal lands in (`YYYYMM`). Carried for the + /// reversed-event payload. + pub period_id: String, + /// The segment amount being reversed (`= the entry's DR/CR amount`), + /// SUBTRACTED from `recognized_minor` under the non-negative cap CHECK. + pub amount_minor: i64, + /// The revenue stream both legs draw. Carried for the reversed-event payload. + pub revenue_stream: String, + /// ISO-4217 currency of the reversal entry. Carried for the reversed-event + /// payload. + pub currency: String, + /// The event publisher: `billing.ledger.revenue.recognition_reversed` is + /// published IN this post txn (the transactional outbox) so it commits + /// atomically with the reversing entry + the counter decrement, or rolls back + /// with them. Mirrors [`RecognitionStampSidecar`]. + pub publisher: Arc, + /// The security context for the in-txn outbox publish (the same `ctx` the + /// engine threads through; cloned by the runner into the sidecar). + pub ctx: SecurityContext, +} + +#[async_trait::async_trait] +impl PostSidecar for RecognitionReversalSidecar { + async fn run( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + _posted: &PostedFacts, + ) -> Result<(), DomainError> { + // Schedule only (rank 6): DECREMENT `recognized_minor` by the reversed + // amount (a negative delta). The per-schedule `recognized_minor >= 0` + // CHECK is the SERIALIZABLE backstop — an over-reversal surfaces as + // `MoneyOutCapExceeded`, refined to `OverRecognition` (the reversal cannot + // un-recognize more than was recognized). The segment row is NOT touched: + // the reversed segment stays `DONE` (design §4.3). A `RECOGNITION` reversal + // replay returned before the sidecar, so this runs once per + // `(schedule, segment, reversal)`. + RecognitionRepo::add_recognized( + txn, + scope, + self.tenant_id, + &self.schedule_id, + -self.amount_minor, + ) + .await + .map_err(map_recognition_repo_err)?; + + // Publish `billing.ledger.revenue.recognition_reversed` into the SAME post + // txn (transactional outbox): the event row commits atomically with the + // reversing entry + the counter decrement, or a publish failure rolls the + // whole reversal back. Ids + amount + stream + period only (no PII). A + // reversal replay returns before the sidecar, so the event fires once per + // `(schedule, segment, reversal)`. + self.publisher + .publish_revenue_recognition_reversed( + &self.ctx, + txn, + LedgerRevenueRecognitionReversed { + tenant_id: self.tenant_id, + schedule_id: self.schedule_id.clone(), + segment_no: self.segment_no, + period_id: self.period_id.clone(), + // Signed delta to cumulative recognized revenue: NEGATIVE on a + // reversal, mirroring the counter decrement + // above. A consumer nets `recognized` against `recognition_reversed` + // by summing `amount_minor` across both, without special-casing + // the event type-id. + amount_minor: -self.amount_minor, + revenue_stream: self.revenue_stream.clone(), + currency: self.currency.clone(), + }, + ) + .await + .map_err(|e| { + DomainError::Internal(format!("publish revenue_recognition_reversed: {e}")) + })?; + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/reconciliation.rs b/gears/bss/ledger/ledger/src/infra/reconciliation.rs new file mode 100644 index 000000000..b2e6aed5c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/reconciliation.rs @@ -0,0 +1,879 @@ +//! `ReconciliationFramework` — the Slice 7 Phase 3 reconciliation engine (design §4.3). +//! +//! Runs the tenant-scoped reconciliation checks — **AR↔derived** (AC #7), **Payments↔PSP**, +//! and **invoice-completeness** (N-recon-1) — each producing a durable +//! `reconciliation_run` row with a variance result. An out-of-tolerance run opens a +//! close-blocking `exception_queue` row (via the [`ExceptionRouter`], additive + +//! fire-and-forget), raises the `ReconciliationVariance` / `MissedPosting` alarm, and +//! emits `billing.ledger.reconciliation.completed`. Slice 7 posts **no** financial +//! entries — it reads, reconciles, and gates close (design §0). +//! +//! Each check runs in ONE transaction: `start` the run (RUNNING) → read + compute the +//! variance → `finalize` (DONE) + emit the reconciliation-completed event, +//! all-or-nothing (default isolation; the SERIALIZABLE authority is the close gate, so a +//! recon-run is an audit record that self-heals via the tick). The close-blocking +//! exception + the +//! alarm are then raised out-of-band (their own transactions) — additive, never fails +//! the run. +//! +//! **Inert-until-the-feed-lands (decision 3).** The Payments↔PSP and invoice-completeness +//! checks read a control-feed port (`PspSettlementFeedV1` / `IssuedInvoiceManifestV1`): +//! an [`Unconfigured…`](bss_ledger_sdk::UnconfiguredIssuedInvoiceManifestV1) feed returns +//! `None` ⇒ the check is inert (no run, no block). A **configured** feed that errors +//! fails loud (the check returns `Err`), never silently passes. Whether a detected +//! invoice-completeness gap **blocks close** is gated by `manifest_enforcement` (default +//! OFF until the launch-blocking cross-team feed is live). + +use std::collections::HashSet; +use std::sync::Arc; + +use chrono::Utc; +use sea_orm::{ColumnTrait, Condition, EntityTrait, FromQueryResult, QuerySelect}; +use serde_json::json; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use bss_ledger_sdk::{IssuedInvoiceManifestV1, PspSettlementFeedV1, SourceDocType}; + +use crate::config::ReconConfig; +use crate::domain::error::DomainError; +use crate::domain::exception::ExceptionType; +use crate::domain::model::RepoError; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::events::alarm_catalog::severity; +use crate::infra::events::payloads::{ + AlarmCategory, LedgerInvariantAlarm, LedgerReconciliationCompleted, +}; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::exception::ExceptionRouter; +use crate::infra::jobs::tieout::{TieOutJob, TieOutReport}; +use crate::infra::storage::entity::journal_entry; +use crate::infra::storage::repo::{ + ExceptionQueueRepo, JournalRepo, RecognitionRepo, ReconciliationRunRepo, +}; + +/// `reconciliation_run.check_type` for the AR-ledger ↔ derived-projection tie-out (AC #7). +pub const CHECK_AR_DERIVED: &str = "AR_DERIVED"; +/// `reconciliation_run.check_type` for the Payments ↔ PSP settlement tie. +pub const CHECK_PAYMENTS_PSP: &str = "PAYMENTS_PSP"; +/// `reconciliation_run.check_type` for the upstream → ledger invoice-completeness check. +pub const CHECK_INVOICE_COMPLETENESS: &str = "INVOICE_COMPLETENESS"; + +/// `reconciliation_run.status` literal for a finalized (completed) run. +const RUN_STATUS_DONE: &str = "DONE"; + +/// Map a repo error into `DbError` for the in-txn run writes. +#[allow( + clippy::needless_pass_by_value, + reason = "error adapter used as a map_err fn-pointer; takes the error by value to match the closure signature" +)] +fn repo_to_db(e: RepoError) -> DbError { + DbError::Other(anyhow::anyhow!("reconciliation repo: {e}")) +} + +/// The decision carried out of a check's transaction to the out-of-band +/// metrics / exception-routing / alarm step. +struct ReconOutcome { + variance_minor: i64, + within_tolerance: bool, +} + +/// Runs the Slice 7 reconciliation checks, writing `reconciliation_run` rows and +/// routing out-of-tolerance results to the close-blocking exception queue. Holds the +/// control-feed read ports (resolved from `ClientHub`, fail-safe `Unconfigured…` +/// defaults) and the exception router. Built once in `init()` over `db.clone()` (the +/// jobs pattern), shared by the `ReconciliationJob` ticker + the REST trigger. +pub struct ReconciliationFramework { + db: DBProvider, + publisher: Arc, + metrics: Arc, + exceptions: Arc, + /// The exception-queue repo (list / resolve) — the invoice-completeness check + /// auto-resolves a `MISSED_POSTING` once the missing invoice's idempotent re-post + /// lands (design §4.6), alongside opening new ones via the router. + exception_repo: ExceptionQueueRepo, + manifest_feed: Arc, + psp_feed: Arc, + /// `current_open_period` resolution for the ticker (reuses the recognition repo's + /// fiscal-period read, like the `ExceptionRouter`). + periods: RecognitionRepo, + config: ReconConfig, +} + +impl ReconciliationFramework { + /// Build the framework over one database provider, the event publisher, the + /// metrics sink, the exception router, the two control-feed read ports, and the + /// recon config (tolerance + enforcement flags). + #[must_use] + pub fn new( + db: DBProvider, + publisher: Arc, + metrics: Arc, + exceptions: Arc, + manifest_feed: Arc, + psp_feed: Arc, + config: ReconConfig, + ) -> Self { + let periods = RecognitionRepo::new(db.clone()); + let exception_repo = ExceptionQueueRepo::new(db.clone()); + Self { + db, + publisher, + metrics, + exceptions, + exception_repo, + manifest_feed, + psp_feed, + periods, + config, + } + } + + /// Run one named reconciliation check for `(tenant, period)` — the REST trigger + /// entry (`POST /reconciliation-runs`) and the on-demand path. Returns the new + /// `run_id`. + /// + /// # Errors + /// [`DomainError::InvalidRequest`] for an unknown `check_type` or a check whose + /// control feed is not configured (inert ⇒ nothing to reconcile); the underlying + /// storage / feed error otherwise. + pub async fn run_check( + &self, + ctx: &SecurityContext, + tenant: Uuid, + period: &str, + check_type: &str, + ) -> Result { + match check_type { + CHECK_AR_DERIVED => self.check_ar_derived(ctx, tenant, period).await, + CHECK_PAYMENTS_PSP => self + .check_payments_psp(ctx, tenant, period) + .await? + .ok_or_else(|| { + DomainError::InvalidRequest( + "PSP settlement feed not configured for this period (check inert)" + .to_owned(), + ) + }), + CHECK_INVOICE_COMPLETENESS => self + .check_invoice_completeness(ctx, tenant, period) + .await? + .ok_or_else(|| { + DomainError::InvalidRequest( + "issued-invoice manifest not configured for this period (check inert)" + .to_owned(), + ) + }), + other => Err(DomainError::InvalidRequest(format!( + "unknown reconciliation check_type: {other}" + ))), + } + } + + /// The near-real-time ticker pass (cadence from `ReconConfig.recon_tick_secs`): + /// for every tenant with posted rows, reconcile its current OPEN period across all + /// three checks. AR↔derived always runs; Payments↔PSP + invoice-completeness are + /// inert until their control feeds land. A per-tenant failure is logged and skipped + /// (one flaky tenant must not starve the rest); the recon defects themselves are + /// reported via the runs / exceptions / alarms, not as `Err`. + /// + /// # Errors + /// Returns `Err` only if the up-front tenant enumeration fails (DB unreachable). + pub async fn run(&self) -> anyhow::Result<()> { + let ctx = SecurityContext::anonymous(); + let tenant_ids = self.enumerate_tenants().await?; + let mut failed = 0_usize; + for tenant in tenant_ids { + let scope = AccessScope::for_tenant(tenant); + let period = match self.periods.current_open_period(&scope, tenant).await { + Ok(Some(p)) => p, + // No open period (tenant not provisioned / between periods) — nothing to + // reconcile this tick. + Ok(None) => continue, + Err(e) => { + failed += 1; + tracing::warn!(target: "bss-ledger", %tenant, error = %e, "recon tick: open-period resolve failed; skipping tenant"); + continue; + } + }; + // AR↔derived (always available). PSP + completeness are inert until configured. + for check in [ + CHECK_AR_DERIVED, + CHECK_PAYMENTS_PSP, + CHECK_INVOICE_COMPLETENESS, + ] { + let result = match check { + CHECK_AR_DERIVED => { + self.check_ar_derived(&ctx, tenant, &period).await.map(Some) + } + CHECK_PAYMENTS_PSP => self.check_payments_psp(&ctx, tenant, &period).await, + _ => self.check_invoice_completeness(&ctx, tenant, &period).await, + }; + if let Err(e) = result { + failed += 1; + tracing::warn!(target: "bss-ledger", %tenant, check, error = %e, "recon tick: check failed; continuing"); + } + } + } + if failed > 0 { + tracing::warn!( + failed, + "bss-ledger: reconciliation tick completed with per-tenant/check failures" + ); + } + Ok(()) + } + + /// Enumerate every tenant with posted rows (the same all-tenants `allow_all` + /// enumeration the tie-out job uses). + async fn enumerate_tenants(&self) -> anyhow::Result> { + #[derive(Debug, FromQueryResult)] + struct TenantRow { + tenant_id: Uuid, + } + let conn = self.db.conn()?; + // Project to DISTINCT tenant_id rather than materializing every journal_entry + // header — this recon tick only needs the SET of tenants with posted rows, and + // journal_entry is the largest table in the gear. The scoped projection keeps + // the all-tenants system scope applied (avoids the full-table `.all()` scan). + let rows = journal_entry::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .project_all(&conn, |q| { + q.select_only() + .column(journal_entry::Column::TenantId) + .distinct() + .into_model::() + }) + .await + .map_err(|e| anyhow::anyhow!("recon: enumerate tenants: {e}"))?; + Ok(rows.into_iter().map(|r| r.tenant_id).collect()) + } + + /// **H2 — AR↔derived (AC #7).** Wrap [`TieOutJob::tie_out_on`] as the `AR_DERIVED` + /// run: the AR cache vs the journal-recomputed projection. Variance > tolerance + /// (X4) → `RECON_MISMATCH` + `ReconciliationVariance` alarm + blocks close. + async fn check_ar_derived( + &self, + ctx: &SecurityContext, + tenant: Uuid, + period: &str, + ) -> Result { + let run_id = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let publisher = Arc::clone(&self.publisher); + let db = self.db.clone(); + let ctx = ctx.clone(); + let period_owned = period.to_owned(); + let per_k = self.config.ar_tolerance_minor_per_k_lines; + + let outcome: Result = self + .db + .transaction(move |txn| { + let scope = scope.clone(); + let publisher = Arc::clone(&publisher); + let db = db.clone(); + let ctx = ctx.clone(); + let period_owned = period_owned.clone(); + Box::pin(async move { + ReconciliationRunRepo::start( + txn, + &scope, + tenant, + run_id, + &period_owned, + CHECK_AR_DERIVED, + ) + .await + .map_err(repo_to_db)?; + // VHP-1843: prefer the incremental tie-out (baseline + fold of + // the open period) over the all-time full fold; fall back to the + // full fold when there is no baseline yet (the tenant has never + // closed a period) or a period is transitional. The incremental + // path advances `reconciliation_run.watermark` to its verified + // boundary; the full fallback leaves it unset. + let job = TieOutJob::new(db.clone(), Arc::clone(&publisher)); + let (report, watermark) = + match job.tie_out_incremental(txn, tenant).await.map_err(|e| { + DbError::Other(anyhow::anyhow!("recon AR incremental tie-out: {e}")) + })? { + Some(inc) => { + let wm = inc.watermark; + (inc.into_tie_out_report(tenant), wm) + } + None => ( + job.tie_out_on(txn, tenant).await.map_err(|e| { + DbError::Other(anyhow::anyhow!("recon AR tie-out: {e}")) + })?, + None, + ), + }; + let (variance_minor, within_tolerance) = ar_tolerance_eval(&report, per_k); + let detail = json!({ + "summary": report.summary(), + "posted_line_count": report.posted_line_count, + "tolerance_minor_per_k_lines": per_k, + }); + ReconciliationRunRepo::finalize( + txn, + &scope, + tenant, + run_id, + RUN_STATUS_DONE, + variance_minor, + within_tolerance, + watermark, + Some(detail), + ) + .await + .map_err(repo_to_db)?; + Self::emit_completed_in_txn( + &publisher, + &ctx, + txn, + tenant, + run_id, + &period_owned, + CHECK_AR_DERIVED, + variance_minor, + within_tolerance, + ) + .await?; + Ok(ReconOutcome { + variance_minor, + within_tolerance, + }) + }) + }) + .await; + + let outcome = + outcome.map_err(|e| DomainError::Internal(format!("recon AR_DERIVED run: {e}")))?; + self.record_and_route( + tenant, + period, + CHECK_AR_DERIVED, + ExceptionType::ReconMismatch, + &outcome, + ) + .await; + Ok(run_id) + } + + /// **H3 — Payments↔PSP.** Reconcile the ledger's recorded settlements against the + /// PSP settlement report (the `PspSettlementFeedV1` control feed). `None` report ⇒ + /// inert (`Ok(None)`, no run). A divergence beyond tolerance → `PSP_VARIANCE` + + /// alarm + blocks close. (The stuck-refund-clearing leg of the design's Payments↔PSP + /// tie is owned by the aged-alarm job's `STUCK_REFUND_CLEARING` routing, Phase 2.) + async fn check_payments_psp( + &self, + ctx: &SecurityContext, + tenant: Uuid, + period: &str, + ) -> Result, DomainError> { + let report = self + .psp_feed + .settlement_report(tenant, period) + .await + .map_err(|e| DomainError::Internal(format!("recon PSP settlement feed: {e}")))?; + let Some(psp) = report else { + // Inert: no PSP report for the period (feed not configured / nothing pushed). + return Ok(None); + }; + + let run_id = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let publisher = Arc::clone(&self.publisher); + let ctx = ctx.clone(); + let period_owned = period.to_owned(); + let psp_settled = psp.settled_minor; + // PSP rounding tolerance rate (captured for the move-closure below). + let per_k = i64::from(self.config.ar_tolerance_minor_per_k_lines); + let db = self.db.clone(); + + let outcome: Result = self + .db + .transaction(move |txn| { + let scope = scope.clone(); + let publisher = Arc::clone(&publisher); + let ctx = ctx.clone(); + let period_owned = period_owned.clone(); + let db = db.clone(); + Box::pin(async move { + ReconciliationRunRepo::start( + txn, + &scope, + tenant, + run_id, + &period_owned, + CHECK_PAYMENTS_PSP, + ) + .await + .map_err(repo_to_db)?; + // Ledger-side settled total, PERIOD-SCOPED (C2): the net-of-returns + // sum of this period's PAYMENT_SETTLE / SETTLEMENT_RETURN UNALLOCATED + // legs, on the SAME net basis as the PSP report. The prior lifetime + // `payment_settlement.settled_minor` sum (PK tenant+payment, NO period + // column) compared a tenant-lifetime total against a per-period PSP + // figure — a multi-period tenant diverged. Folds in i128, narrowed + // checked inside the helper (no `unwrap_or(i64::MAX)` saturation). + let (ledger_settled, settle_count) = JournalRepo::new(db.clone()) + .sum_period_settled_net(txn, &scope, tenant, &period_owned) + .await + .map_err(repo_to_db)?; + // Store the variance MAGNITUDE in the shared `variance_minor` column + // (consistent with the AR check, which sums absolute divergences); the + // signed direction stays recoverable from the ledger/psp totals in `detail`. + let variance_minor = ledger_settled.saturating_sub(psp_settled).abs(); + // Rounding tolerance, mirroring AR (X4): exact-match is brittle for + // cross-system penny rounding, so allow `per_k` minor units per 1,000 + // settlements, floored at the statutory minimum (`per_k`). A divergence + // above the budget is a real variance and blocks close. + let budget = per_k + .saturating_mul(i64::try_from(settle_count / 1000).unwrap_or(i64::MAX)) + .max(per_k); + let within_tolerance = variance_minor <= budget; + let detail = json!({ + "ledger_settled_minor": ledger_settled, + "psp_settled_minor": psp_settled, + "psp_currency": psp.currency, + "psp_report_id": psp.report_id, + }); + ReconciliationRunRepo::finalize( + txn, + &scope, + tenant, + run_id, + RUN_STATUS_DONE, + variance_minor, + within_tolerance, + None, + Some(detail), + ) + .await + .map_err(repo_to_db)?; + Self::emit_completed_in_txn( + &publisher, + &ctx, + txn, + tenant, + run_id, + &period_owned, + CHECK_PAYMENTS_PSP, + variance_minor, + within_tolerance, + ) + .await?; + Ok(ReconOutcome { + variance_minor, + within_tolerance, + }) + }) + }) + .await; + + let outcome = + outcome.map_err(|e| DomainError::Internal(format!("recon PAYMENTS_PSP run: {e}")))?; + self.record_and_route( + tenant, + period, + CHECK_PAYMENTS_PSP, + ExceptionType::PspVariance, + &outcome, + ) + .await; + Ok(Some(run_id)) + } + + /// **I3 — invoice-completeness (N-recon-1).** Reconcile the independent + /// issued-invoice manifest (`IssuedInvoiceManifestV1`) against the set of + /// `INVOICE_POST` entries committed to the journal for `(tenant, period)`: + /// `issued − posted`. `None` manifest ⇒ inert (`Ok(None)`, no run). Any issued + /// `invoiceId` with no committed entry — or a count mismatch — opens a + /// `MISSED_POSTING` exception per missing id (close-blocking) + the `MissedPosting` + /// alarm. The close-blocking rows are gated by `manifest_enforcement` (default OFF + /// until the feed is live, design decision 3); the run + the variance are always + /// recorded (audit). + async fn check_invoice_completeness( + &self, + ctx: &SecurityContext, + tenant: Uuid, + period: &str, + ) -> Result, DomainError> { + let manifest = self + .manifest_feed + .latest_manifest(tenant, period) + .await + .map_err(|e| DomainError::Internal(format!("recon issued-invoice manifest: {e}")))?; + let Some(manifest) = manifest else { + // Inert: no manifest for the period (feed not configured / nothing pushed). + return Ok(None); + }; + + let run_id = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let publisher = Arc::clone(&self.publisher); + let ctx = ctx.clone(); + let period_owned = period.to_owned(); + let issued: Vec = manifest.invoice_ids.clone(); + let manifest_count = manifest.count; + + // The set of missing issued ids is computed inside the txn and carried out for + // the exception routing below. (Default isolation — the authoritative gate is the + // SERIALIZABLE close path; this run is an audit record, self-healing via the tick.) + let (run_outcome, missing): (Result, Vec) = { + let db_result = self + .db + .transaction({ + let scope = scope.clone(); + let publisher = Arc::clone(&publisher); + let ctx = ctx.clone(); + let period_owned = period_owned.clone(); + let issued = issued.clone(); + move |txn| { + let scope = scope.clone(); + let publisher = Arc::clone(&publisher); + let ctx = ctx.clone(); + let period_owned = period_owned.clone(); + let issued = issued.clone(); + Box::pin(async move { + ReconciliationRunRepo::start( + txn, + &scope, + tenant, + run_id, + &period_owned, + CHECK_INVOICE_COMPLETENESS, + ) + .await + .map_err(repo_to_db)?; + let posted = posted_invoice_ids(txn, &scope, tenant, &period_owned) + .await + .map_err(|e| { + DbError::Other(anyhow::anyhow!( + "recon completeness: read posted invoices: {e}" + )) + })?; + let missing: Vec = issued + .iter() + .filter(|id| !posted.contains(*id)) + .cloned() + .collect(); + let count_mismatch = + manifest_count != u64::try_from(posted.len()).unwrap_or(u64::MAX); + let within_tolerance = missing.is_empty() && !count_mismatch; + let variance_minor = i64::try_from(missing.len()).unwrap_or(i64::MAX); + let detail = json!({ + "issued_count": manifest_count, + "posted_count": posted.len(), + "missing_count": missing.len(), + "count_mismatch": count_mismatch, + }); + ReconciliationRunRepo::finalize( + txn, + &scope, + tenant, + run_id, + RUN_STATUS_DONE, + variance_minor, + within_tolerance, + None, + Some(detail), + ) + .await + .map_err(repo_to_db)?; + Self::emit_completed_in_txn( + &publisher, + &ctx, + txn, + tenant, + run_id, + &period_owned, + CHECK_INVOICE_COMPLETENESS, + variance_minor, + within_tolerance, + ) + .await?; + Ok(( + ReconOutcome { + variance_minor, + within_tolerance, + }, + missing, + )) + }) + } + }) + .await; + match db_result { + Ok((outcome, missing)) => (Ok(outcome), missing), + Err(e) => (Err(e), Vec::new()), + } + }; + + let outcome = run_outcome + .map_err(|e| DomainError::Internal(format!("recon INVOICE_COMPLETENESS run: {e}")))?; + + self.metrics.reconciliation_run(CHECK_INVOICE_COMPLETENESS); + self.metrics + .reconciliation_variance_minor(CHECK_INVOICE_COMPLETENESS, outcome.variance_minor); + // Clear any OPEN MISSED_POSTING whose invoice has since been posted (the §4.6 + // idempotent re-post). Runs regardless of tolerance — a now-complete period must + // clear its stale close-blocking rows even when `within_tolerance` is true. + self.resolve_landed_missed_postings(tenant, period, &issued, &missing) + .await; + if !outcome.within_tolerance { + self.metrics + .reconciliation_out_of_tolerance(CHECK_INVOICE_COMPLETENESS); + // The close-blocking rows + the page are flag-gated: a missed posting only + // blocks close (and pages) once `manifest_enforcement` is ON (the feed is + // live). With it OFF the run + variance are still recorded (audit), but the + // gap is inert (design decision 3 / §4.5 residual risk). + if self.config.manifest_enforcement { + for invoice_id in &missing { + self.exceptions + .route_for_period( + tenant, + ExceptionType::MissedPosting, + invoice_id, + period, + Some(json!({ "period_id": period, "check_type": CHECK_INVOICE_COMPLETENESS })), + ) + .await; + } + self.emit_alarm( + tenant, + CHECK_INVOICE_COMPLETENESS, + AlarmCategory::MissedPosting, + outcome.variance_minor, + ) + .await; + } + } + Ok(Some(run_id)) + } + + /// Emit `billing.ledger.reconciliation.completed` in the run's transaction (so the + /// event commits atomically with the `finalize` write). + #[allow( + clippy::too_many_arguments, + reason = "the event carries the full run identity + result" + )] + async fn emit_completed_in_txn( + publisher: &Arc, + ctx: &SecurityContext, + txn: &DbTx<'_>, + tenant: Uuid, + run_id: Uuid, + period: &str, + check_type: &str, + variance_minor: i64, + within_tolerance: bool, + ) -> Result<(), DbError> { + publisher + .publish_reconciliation_completed( + ctx, + txn, + LedgerReconciliationCompleted { + tenant_id: tenant, + run_id, + period_id: period.to_owned(), + check_type: check_type.to_owned(), + variance_minor, + within_tolerance, + at_utc: Utc::now(), + }, + ) + .await + .map_err(|e| DbError::Other(anyhow::anyhow!("publish reconciliation.completed: {e}"))) + } + + /// Record the run metrics and, on an out-of-tolerance result, route the + /// close-blocking exception (fire-and-forget) + raise the `ReconciliationVariance` + /// alarm. Used by the AR↔derived + Payments↔PSP checks (invoice-completeness routes + /// per-missing-id behind the manifest flag, so it does its own out-of-band step). + async fn record_and_route( + &self, + tenant: Uuid, + period: &str, + check_type: &str, + ex_type: ExceptionType, + outcome: &ReconOutcome, + ) { + self.metrics.reconciliation_run(check_type); + self.metrics + .reconciliation_variance_minor(check_type, outcome.variance_minor); + if !outcome.within_tolerance { + self.metrics.reconciliation_out_of_tolerance(check_type); + let business_ref = format!("recon:{period}:{check_type}"); + self.exceptions + .route_for_period( + tenant, + ex_type, + &business_ref, + period, + Some(json!({ + "check_type": check_type, + "period_id": period, + "variance_minor": outcome.variance_minor, + })), + ) + .await; + self.emit_alarm( + tenant, + check_type, + AlarmCategory::ReconciliationVariance, + outcome.variance_minor, + ) + .await; + } + } + + /// Raise a reconciliation alarm (fire-and-forget, out-of-band) with the catalog's + /// severity for `category`. + async fn emit_alarm( + &self, + tenant: Uuid, + check_type: &str, + category: AlarmCategory, + variance_minor: i64, + ) { + let alarm = LedgerInvariantAlarm { + category, + severity: severity(category), + tenant_id: tenant, + scope: format!("tenant:{tenant}"), + code: category.as_str().to_owned(), + detail: format!("check={check_type} variance_minor={variance_minor}"), + affected: Vec::new(), + }; + self.publisher + .emit_invariant_alarm(&SecurityContext::anonymous(), alarm) + .await; + } + + /// Resolve any OPEN `MISSED_POSTING` whose missing invoice has since been posted — + /// `issued − missing` is the now-committed set (design §4.6 idempotent re-post). + /// Fire-and-forget: a list/resolve failure is logged, never fails the run. + async fn resolve_landed_missed_postings( + &self, + tenant: Uuid, + period: &str, + issued: &[String], + missing: &[String], + ) { + let missing_set: HashSet<&str> = missing.iter().map(String::as_str).collect(); + let landed: HashSet<&str> = issued + .iter() + .map(String::as_str) + .filter(|id| !missing_set.contains(id)) + .collect(); + if landed.is_empty() { + return; + } + let scope = AccessScope::for_tenant(tenant); + let open = match self.exception_repo.list(&scope, tenant, Some("OPEN")).await { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(target: "bss-ledger", %tenant, error = %e, "recon completeness: list OPEN exceptions for auto-resolve failed"); + return; + } + }; + for row in open.iter().filter(|r| { + r.exception_type == ExceptionType::MissedPosting.as_str() + && r.period_id.as_deref() == Some(period) + && landed.contains(r.business_ref.as_str()) + }) { + if let Err(e) = self + .exception_repo + .resolve_one( + &scope, + tenant, + row.exception_id, + "RESOLVED", + "system:invoice-completeness", + ) + .await + { + tracing::warn!(target: "bss-ledger", %tenant, exception_id = %row.exception_id, error = %e, "recon completeness: auto-resolve MISSED_POSTING failed"); + } + } + } +} + +/// Evaluate the AR↔derived tie-out report against the X4 rounding tolerance. +/// Returns `(variance_minor, within_tolerance)`. +/// +/// A clean report ties out exactly (`0`, within). Otherwise the total **absolute** +/// monetary divergence (account-balance + sub-grain + payment-counter variances) is the +/// `variance_minor`, and it is within tolerance only when there is **no** structural +/// defect (an imbalanced entry / a negative guarded grain / a PENDING mapping line are +/// hard defects, never rounding) AND the divergence fits the rounding budget +/// `(posted_line_count / 1000) * per_k_lines` (X4: ≤ `per_k_lines` minor units per 1,000 +/// posted lines; statutory floors override — floored at `per_k_lines` minor units). +fn ar_tolerance_eval(report: &TieOutReport, per_k_lines: u32) -> (i64, bool) { + if report.is_clean() { + return (0, true); + } + let total: i128 = report + .account_balance_variances + .iter() + .map(|v| (i128::from(v.computed) - i128::from(v.cached)).abs()) + .chain( + report + .sub_grain_variances + .iter() + .map(|v| (i128::from(v.computed) - i128::from(v.cached)).abs()), + ) + .chain( + report + .payment_counter_variances + .iter() + .map(|v| (i128::from(v.computed) - i128::from(v.cached)).abs()), + ) + .sum(); + let variance_minor = i64::try_from(total).unwrap_or(i64::MAX); + let hard_defect = !report.imbalanced_entries.is_empty() + || !report.negative_grains.is_empty() + || report.pending_lines > 0; + // X4 per-1000-lines rounding allowance, FLOORED at a statutory minimum + // (`per_k_lines` minor units) so a sub-1,000-line period can still absorb the + // immaterial-rounding bucket the design grants ("statutory floors override") — + // integer division alone yields 0 under 1,000 lines, blocking on 1 minor of + // legitimate rounding. (A per-jurisdiction statutory registry remains future.) + let budget = i64::from(per_k_lines) + .saturating_mul(i64::try_from(report.posted_line_count / 1000).unwrap_or(i64::MAX)) + .max(i64::from(per_k_lines)); + let within_tolerance = !hard_defect && variance_minor <= budget; + (variance_minor, within_tolerance) +} + +/// The set of `INVOICE_POST` `source_business_id`s (invoiceIds) committed to the journal +/// for `(tenant, period)` — the "posted" side of the invoice-completeness set difference. +/// Shared with the close gate's pre-close completeness check (`infra::period_close`). +pub(crate) async fn posted_invoice_ids( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + period: &str, +) -> Result, DbError> { + let entries = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant)) + .add(journal_entry::Column::PeriodId.eq(period.to_owned())) + .add(journal_entry::Column::SourceDocType.eq(SourceDocType::InvoicePost.as_str())), + ) + .all(txn) + .await + .map_err(|e| DbError::Other(anyhow::anyhow!("read INVOICE_POST entries: {e}")))?; + Ok(entries.into_iter().map(|e| e.source_business_id).collect()) +} + +#[cfg(test)] +#[path = "reconciliation_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/reconciliation_tests.rs b/gears/bss/ledger/ledger/src/infra/reconciliation_tests.rs new file mode 100644 index 000000000..edbbb8566 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/reconciliation_tests.rs @@ -0,0 +1,120 @@ +//! Unit tests for the AR↔derived rounding-tolerance evaluation (`ar_tolerance_eval`, +//! the X4 logic) — the correctness-critical part of the reconciliation framework that +//! decides whether a tie-out variance blocks period close. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use uuid::Uuid; + +use super::ar_tolerance_eval; +use crate::infra::jobs::tieout::{AccountBalanceVariance, ImbalancedEntry, TieOutReport}; + +/// A clean report (no defects) with the given posted-line count. +fn clean(posted_line_count: u64) -> TieOutReport { + TieOutReport { + tenant_id: Uuid::from_u128(0xA1), + posted_line_count, + account_balance_variances: vec![], + sub_grain_variances: vec![], + imbalanced_entries: vec![], + negative_grains: vec![], + payment_counter_variances: vec![], + pending_lines: 0, + } +} + +fn balance_variance(computed: i64, cached: i64) -> AccountBalanceVariance { + AccountBalanceVariance { + account_id: Uuid::from_u128(0xB1), + currency: "USD".to_owned(), + computed, + cached, + } +} + +#[test] +fn clean_report_is_zero_variance_within_tolerance() { + let (variance, within) = ar_tolerance_eval(&clean(5_000), 1); + assert_eq!(variance, 0); + assert!(within); +} + +#[test] +fn monetary_variance_within_rounding_budget_is_within_tolerance() { + // 2000 posted lines, 1 minor/1000 → budget 2. A 1-minor divergence fits. + let mut report = clean(2_000); + report.account_balance_variances = vec![balance_variance(100, 99)]; + let (variance, within) = ar_tolerance_eval(&report, 1); + assert_eq!(variance, 1); + assert!(within, "1 minor <= budget 2"); +} + +#[test] +fn monetary_variance_exceeding_budget_is_out_of_tolerance() { + // 2000 posted lines, budget 2. A 5-minor divergence exceeds it. + let mut report = clean(2_000); + report.account_balance_variances = vec![balance_variance(100, 95)]; + let (variance, within) = ar_tolerance_eval(&report, 1); + assert_eq!(variance, 5); + assert!(!within, "5 minor > budget 2"); +} + +#[test] +fn small_tenant_gets_the_statutory_floor_budget() { + // 500 posted lines: 500/1000 = 0 by integer division, but the budget is FLOORED at + // the statutory minimum (`per_k_lines` = 1 minor) so a sub-1000-line period can still + // absorb the immaterial-rounding bucket the design grants — a 1-minor divergence is + // within tolerance instead of spuriously blocking close. + let mut report = clean(500); + report.account_balance_variances = vec![balance_variance(100, 99)]; + let (variance, within) = ar_tolerance_eval(&report, 1); + assert_eq!(variance, 1); + assert!(within, "1 minor <= statutory floor budget 1"); + + // A divergence ABOVE the floor still blocks. + let mut report = clean(500); + report.account_balance_variances = vec![balance_variance(100, 97)]; + let (variance, within) = ar_tolerance_eval(&report, 1); + assert_eq!(variance, 3); + assert!(!within, "3 minor > statutory floor budget 1"); +} + +#[test] +fn structural_defect_is_never_within_tolerance_even_at_zero_variance() { + // An imbalanced entry is a hard defect (not rounding): out of tolerance regardless + // of the monetary budget, and it carries no netted monetary variance here. + let mut report = clean(5_000); + report.imbalanced_entries = vec![ImbalancedEntry { + entry_id: Uuid::from_u128(0xE1), + currency: "USD".to_owned(), + net_minor: 10, + line_count: 2, + payer_count: 1, + }]; + let (variance, within) = ar_tolerance_eval(&report, 1); + assert_eq!( + variance, 0, + "imbalance is not a netted balance-cache divergence" + ); + assert!(!within, "a hard defect is never within rounding tolerance"); +} + +#[test] +fn pending_mapping_lines_block_even_with_no_variance() { + // PENDING suspense lines (mapping gap) make the report not-clean and are a hard + // defect — out of tolerance with zero monetary variance. + let mut report = clean(5_000); + report.pending_lines = 3; + let (variance, within) = ar_tolerance_eval(&report, 1); + assert_eq!(variance, 0); + assert!(!within); +} + +#[test] +fn multiple_grain_divergences_sum_in_absolute_value() { + // Two opposite-sign divergences must NOT net to zero — the tie-out variance is the + // total absolute divergence (each grain is independently wrong). + let mut report = clean(2_000); + report.account_balance_variances = vec![balance_variance(100, 98), balance_variance(50, 52)]; + let (variance, _within) = ar_tolerance_eval(&report, 1); + assert_eq!(variance, 4, "|+2| + |-2| = 4, not 0"); +} diff --git a/gears/bss/ledger/ledger/src/infra/retention.rs b/gears/bss/ledger/ledger/src/infra/retention.rs new file mode 100644 index 000000000..9c85cef3f --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/retention.rs @@ -0,0 +1,436 @@ +//! `CheckpointWriter` + [`DetachGate`] — the dormant retention seam (Slice 6 +//! design §4.8, Variant 2). +//! +//! Partitioning / rotation of the journal is Foundation (Slice-1) debt: nothing +//! in the MVP rotates a partition yet. This module ships the INTERFACE that +//! Foundation's rotation will call once it exists: +//! +//! - [`CheckpointWriter`] records a contiguous range of a tenant's +//! tamper-evidence hash chain into `bss.chain_checkpoint` (so a detached +//! partition can be proven anchored by a checkpoint). Signing / WORM storage +//! is post-MVP (Bucket A) — an MVP checkpoint is written UNSIGNED +//! (`signature` NULL). +//! +//! - [`DetachGate`] is the §4.8/E-5 detach gate. The fuller normative rule is +//! "a period MAY be detached only when it is COVERED by a signed checkpoint +//! AND retired". The checkpoint-coverage half is dormant until rotation +//! exists; the MVP gate enforces the half that has LIVE data today: the +//! period's chain must be fully sealed (every `journal_entry.row_hash` set). +//! An unsealed entry means the tamper chain for that period is not closed, so +//! detaching it would orphan an un-anchored row — the gate blocks it. +//! +//! The §4.8/E-7 "restore-event chain re-anchor" (re-link a restored partition +//! back onto the live chain) is a documented FUTURE interface; it is not built +//! here. +//! +//! A blocked detach MAPS to +//! [`AlarmCategory::PartitionDetachBlocked`](crate::infra::events::payloads::AlarmCategory::PartitionDetachBlocked). +//! This module does NOT emit that alarm — emission is wired by Foundation's +//! rotation when it calls [`DetachGate::may_detach`] and gets an `Err`; here the +//! gate only reports the blocking condition. + +use std::collections::HashSet; + +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{AccessScope, DbConn, SecureEntityExt, SecureInsertExt}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::model::RepoError; +use crate::infra::storage::entity::{chain_checkpoint, journal_entry}; + +/// Writer over `bss.chain_checkpoint`. Stateless over one [`DBProvider`] +/// (mirrors [`crate::infra::audit::retrieval::AuditRetrievalReader`]). +#[derive(Clone)] +pub struct CheckpointWriter { + db: DBProvider, +} + +impl CheckpointWriter { + /// Build the writer over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// The underlying provider. + #[must_use] + pub fn db(&self) -> &DBProvider { + &self.db + } + + /// Insert ONE `chain_checkpoint` row under `scope`, recording the contiguous + /// chain range `from_row_hash` .. `to_row_hash`. Returns the new + /// `checkpoint_id` ([`Uuid::now_v7`]-generated). SQL-level tenant isolation + /// via the secure ORM insert. + /// + /// `covered_entry_count` is **derived**: the writer walks the + /// chain from `to_row_hash` back to `from_row_hash` via `prev_hash`, counts + /// the entries, and verifies the range is contiguous. A caller-supplied count + /// is never trusted — a checkpoint that claims coverage it doesn't have would + /// let a partition detach over an un-anchored gap. + /// + /// `signature` is written NULL: signing / WORM storage is post-MVP + /// (Bucket A). In the MVP a checkpoint just records a contiguous range of + /// the chain, unsigned. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / scope failure, or when the range is not + /// contiguous (`to` does not reach `from` by following `prev_hash`, or a link + /// names a missing/unsealed entry, or a cycle is detected). + pub async fn write_checkpoint( + &self, + scope: &AccessScope, + tenant_id: Uuid, + from_row_hash: Vec, + to_row_hash: Vec, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + + // Derive + verify the covered count by walking the chain range. + let covered_entry_count = + count_chain_range(&conn, scope, tenant_id, &from_row_hash, &to_row_hash).await?; + + let checkpoint_id = Uuid::now_v7(); + let am = chain_checkpoint::ActiveModel { + checkpoint_id: Set(checkpoint_id), + tenant_id: Set(tenant_id), + from_row_hash: Set(from_row_hash), + to_row_hash: Set(to_row_hash), + covered_entry_count: Set(covered_entry_count), + // Signing / WORM is post-MVP (Bucket A) — an MVP checkpoint is unsigned. + signature: Set(None), + created_at_utc: Set(chrono::Utc::now()), + }; + + chain_checkpoint::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("write_checkpoint scope: {e}")))? + .exec(&conn) + .await + .map_err(|e| RepoError::Db(format!("write_checkpoint: {e}")))?; + + Ok(checkpoint_id) + } +} + +/// Walk a tenant's tamper-evidence chain from `to_row_hash` back to +/// `from_row_hash` (inclusive) via `prev_hash`, returning the number of entries +/// in the range. Verifies contiguity: every link must resolve to a sealed +/// `journal_entry`, and `from_row_hash` must be reached before genesis. Used to +/// derive a checkpoint's `covered_entry_count` so it can never +/// over-claim coverage. +/// +/// # Errors +/// [`RepoError::Db`] if a link names a missing/unsealed entry, the walk reaches +/// genesis without hitting `from_row_hash` (non-contiguous range), or a cycle is +/// detected. +async fn count_chain_range( + conn: &DbConn<'_>, + scope: &AccessScope, + tenant_id: Uuid, + from_row_hash: &[u8], + to_row_hash: &[u8], +) -> Result { + let mut current: Option> = Some(to_row_hash.to_vec()); + let mut count: i64 = 0; + let mut seen: HashSet> = HashSet::new(); + + while let Some(row_hash) = current { + if !seen.insert(row_hash.clone()) { + return Err(RepoError::Db(format!( + "checkpoint range walk hit a cycle at row_hash {} (tenant {tenant_id})", + hex(&row_hash) + ))); + } + + let entry = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::RowHash.eq(row_hash.clone())), + ) + .one(conn) + .await + .map_err(|e| RepoError::Db(format!("checkpoint range read: {e}")))? + .ok_or_else(|| { + RepoError::Db(format!( + "checkpoint range not contiguous: no sealed entry for row_hash {} \ + (tenant {tenant_id})", + hex(&row_hash) + )) + })?; + + count += 1; + if row_hash.as_slice() == from_row_hash { + return Ok(count); + } + current = entry.prev_hash; + } + + Err(RepoError::Db(format!( + "checkpoint range not contiguous: reached genesis without hitting from_row_hash {} \ + (tenant {tenant_id})", + hex(from_row_hash) + ))) +} + +/// Lowercase hex of a byte slice, for diagnostics only. +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// The §4.8/E-5 partition detach gate. Stateless over one [`DBProvider`]. +#[derive(Clone)] +pub struct DetachGate { + db: DBProvider, +} + +impl DetachGate { + /// Build the gate over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// The underlying provider. + #[must_use] + pub fn db(&self) -> &DBProvider { + &self.db + } + + /// Decide whether the period `(tenant_id, period_id)` MAY be detached. + /// + /// Both halves of §4.8/E-5 are enforced: + /// + /// 1. **Fully sealed** — EVERY `journal_entry` in the period under `scope` + /// must be sealed (non-NULL `row_hash`). An unsealed entry means the + /// tamper chain for the period is not closed → [`DetachBlocked`] naming the + /// unsealed count. + /// 2. **Checkpoint-covered** — every sealed entry in the period must fall + /// within the `created_seq` range of a recorded `chain_checkpoint` for the + /// tenant. A period with no covering checkpoint is un-anchored: detaching + /// it would orphan rows whose coverage was never recorded → blocked, + /// naming the uncovered count. (Checkpoint *signing* / WORM is still + /// post-MVP — coverage here requires a checkpoint, not yet a signed one.) + /// + /// A blocked detach maps to + /// [`AlarmCategory::PartitionDetachBlocked`](crate::infra::events::payloads::AlarmCategory::PartitionDetachBlocked); + /// alarm emission is wired by the caller (rotation), not here. + /// + /// # Errors + /// [`DetachBlocked`] when the period has unsealed entries OR entries not + /// covered by any checkpoint. A storage / scope fault is FAIL-SAFE: the gate + /// cannot prove the period detachable, so it BLOCKS (the underlying error is + /// logged and the counts are reported as `0` — "unknown, blocked anyway"). + /// Never returns `Ok` on an unverified period. + pub async fn may_detach( + &self, + scope: &AccessScope, + tenant_id: Uuid, + period_id: &str, + ) -> Result<(), DetachBlocked> { + let blocked = || DetachBlocked { + tenant_id, + period_id: period_id.to_owned(), + unsealed_count: 0, + uncovered_count: 0, + }; + + let conn = match self.db.conn() { + Ok(conn) => conn, + Err(e) => { + tracing::error!( + tenant_id = %tenant_id, + period_id, + error = %e, + "bss-ledger: detach-gate conn failed; blocking detach (fail-safe)" + ); + return Err(blocked()); + } + }; + + // 1. Fully-sealed half: any NULL `row_hash` in the period blocks. + let unsealed_count = match journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::PeriodId.eq(period_id)) + .add(journal_entry::Column::RowHash.is_null()), + ) + .count(&conn) + .await + { + Ok(count) => count, + Err(e) => { + tracing::error!( + tenant_id = %tenant_id, + period_id, + error = %e, + "bss-ledger: detach-gate unsealed-count read failed; blocking detach (fail-safe)" + ); + return Err(blocked()); + } + }; + + if unsealed_count > 0 { + return Err(DetachBlocked { + tenant_id, + period_id: period_id.to_owned(), + unsealed_count, + uncovered_count: 0, + }); + } + + // 2. Checkpoint-coverage half: every sealed entry's `created_seq` must + // fall inside some checkpoint's [from_seq, to_seq] range. + let uncovered_count = match self + .uncovered_entry_count(&conn, scope, tenant_id, period_id) + .await + { + Ok(n) => n, + Err(e) => { + tracing::error!( + tenant_id = %tenant_id, + period_id, + error = %e, + "bss-ledger: detach-gate coverage read failed; blocking detach (fail-safe)" + ); + return Err(blocked()); + } + }; + + if uncovered_count > 0 { + return Err(DetachBlocked { + tenant_id, + period_id: period_id.to_owned(), + unsealed_count: 0, + uncovered_count, + }); + } + Ok(()) + } + + /// Count the period's sealed entries NOT covered by any `chain_checkpoint` + /// range (by `created_seq`). Resolves each checkpoint's `from`/`to` row hash + /// to a `created_seq` to build the covered intervals, then counts period + /// entries whose seq falls in no interval. A period with zero entries is + /// trivially covered (returns 0). Unresolvable checkpoint endpoints (no + /// matching sealed entry) are skipped — they cover nothing. + async fn uncovered_entry_count( + &self, + conn: &DbConn<'_>, + scope: &AccessScope, + tenant_id: Uuid, + period_id: &str, + ) -> Result { + // The period's sealed entries (seq list). + let entries = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::PeriodId.eq(period_id)) + .add(journal_entry::Column::RowHash.is_not_null()), + ) + .all(conn) + .await + .map_err(|e| RepoError::Db(format!("coverage: read period entries: {e}")))?; + if entries.is_empty() { + return Ok(0); + } + + // All checkpoints for the tenant → covered `created_seq` intervals. + let checkpoints = chain_checkpoint::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(chain_checkpoint::Column::TenantId.eq(tenant_id))) + .all(conn) + .await + .map_err(|e| RepoError::Db(format!("coverage: read checkpoints: {e}")))?; + + let mut intervals: Vec<(i64, i64)> = Vec::with_capacity(checkpoints.len()); + for cp in &checkpoints { + let (Some(from_seq), Some(to_seq)) = ( + resolve_seq(conn, scope, tenant_id, &cp.from_row_hash).await?, + resolve_seq(conn, scope, tenant_id, &cp.to_row_hash).await?, + ) else { + // An endpoint with no matching sealed entry covers nothing — skip. + continue; + }; + intervals.push((from_seq.min(to_seq), from_seq.max(to_seq))); + } + + let uncovered = entries + .iter() + .filter(|e| { + !intervals + .iter() + .any(|(lo, hi)| e.created_seq >= *lo && e.created_seq <= *hi) + }) + .count(); + Ok(uncovered as u64) + } +} + +/// Resolve a sealed entry's `created_seq` by its `row_hash` under `scope`, or +/// `None` when no sealed entry matches (an unresolvable checkpoint endpoint). +async fn resolve_seq( + conn: &DbConn<'_>, + scope: &AccessScope, + tenant_id: Uuid, + row_hash: &[u8], +) -> Result, RepoError> { + let row = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::RowHash.eq(row_hash.to_vec())), + ) + .one(conn) + .await + .map_err(|e| RepoError::Db(format!("coverage: resolve seq: {e}")))?; + Ok(row.map(|r| r.created_seq)) +} + +/// Why a period detach was blocked (§4.8/E-5). Maps to the +/// [`AlarmCategory::PartitionDetachBlocked`](crate::infra::events::payloads::AlarmCategory::PartitionDetachBlocked) +/// alarm the caller (Foundation's rotation) raises; the gate itself only reports +/// the condition. +/// +/// `unsealed_count` is the number of `journal_entry` rows in the period that +/// still have a NULL `row_hash` (the chain for that period is not closed). +/// `uncovered_count` is the number of sealed entries not covered by any +/// `chain_checkpoint` range. Both are `0` when the gate blocked +/// fail-safe on an infrastructure fault (it could not prove the period +/// detachable); the underlying error is logged, not carried here. +// The message leads with the `PARTITION_DETACH_BLOCKED` alarm token the caller +// routes this to; the `entr{y|ies}` suffix pluralizes on the combined count. +#[derive(Debug, thiserror::Error)] +#[error( + "PARTITION_DETACH_BLOCKED: tenant {tenant_id} period {period_id} has \ + {unsealed_count} unsealed and {uncovered_count} checkpoint-uncovered entr{}", + if *unsealed_count + *uncovered_count == 1 { "y" } else { "ies" } +)] +pub struct DetachBlocked { + pub tenant_id: Uuid, + pub period_id: String, + pub unsealed_count: u64, + pub uncovered_count: u64, +} diff --git a/gears/bss/ledger/ledger/src/infra/seller_guard.rs b/gears/bss/ledger/ledger/src/infra/seller_guard.rs new file mode 100644 index 000000000..b14943168 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/seller_guard.rs @@ -0,0 +1,125 @@ +//! Seller-type guard for provisioning. Per the architecture's §4.12 predicate, +//! only a tenant whose TYPE owns a billing ledger (a "seller") may have its +//! ledger seeded. The guard reads the target tenant's chained GTS tenant-type +//! from AM (`get_tenant`) and checks it against the gear-configured seller set +//! (`BssLedgerConfig::seller_tenant_types`), rejecting a non-owner (buyer/leaf) +//! type. The seller predicate is owned by the ledger, not encoded as an AM +//! tenant-type trait — GTS mandates closed (`additionalProperties: false`) +//! trait schemas, so a downstream `bss_ledger_owner` trait is not registrable. + +use std::collections::HashSet; +use std::sync::Arc; + +use account_management_sdk::AccountManagementClient; +use async_trait::async_trait; +use toolkit::api::canonical_prelude::{CanonicalError, resource_error}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +/// Stamps `resource_type` on the rejection (the seller's ledger). +#[resource_error("gts.cf.bss.ledger.ledger.v1~")] +struct LedgerResource; + +/// Narrow port: resolve a tenant's chained GTS tenant-type id. Adapts AM's +/// `get_tenant` so the guard is unit-testable without faking the whole client. +#[async_trait] +pub(crate) trait TenantTypeReader: Send + Sync { + /// The tenant's chained `gts.cf.core.am.tenant_type.v1~…` id, or `None` + /// when AM resolved no type (the field is best-effort on a registry blip). + /// + /// # Errors + /// The AM `CanonicalError` — `NotFound` for an unknown / out-of-subtree + /// tenant, `PermissionDenied`, or a transient `Internal`/`Unavailable`. + async fn tenant_type( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + ) -> Result, CanonicalError>; +} + +/// [`TenantTypeReader`] backed by the AM [`AccountManagementClient`]. +pub(crate) struct AmTenantTypeReader { + am: Arc, +} + +impl AmTenantTypeReader { + pub(crate) fn new(am: Arc) -> Self { + Self { am } + } +} + +#[async_trait] +impl TenantTypeReader for AmTenantTypeReader { + async fn tenant_type( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + ) -> Result, CanonicalError> { + // `get_tenant` is itself subtree-scoped: a target outside the caller's + // PDP subtree surfaces as `NotFound` (a second BOLA layer beneath the + // provisioning authz gate). + Ok(self.am.get_tenant(ctx, tenant_id).await?.tenant_type) + } +} + +/// Gate: assert the target tenant's TYPE is a configured seller (ledger owner) +/// before its reference rows are seeded. +pub(crate) struct SellerGuard { + tenant_types: Arc, + /// Chained GTS tenant-type ids that own a ledger (from `BssLedgerConfig`). + seller_types: HashSet, +} + +impl SellerGuard { + pub(crate) fn new( + tenant_types: Arc, + seller_types: impl IntoIterator, + ) -> Self { + Self { + tenant_types, + seller_types: seller_types.into_iter().collect(), + } + } + + /// `Ok(())` when the target tenant's type is in the configured seller set. + /// + /// # Errors + /// `FailedPrecondition` when the tenant has no resolved type or its type is + /// not a seller (a buyer/leaf); otherwise propagates the AM `CanonicalError` + /// (`NotFound` for an unknown / out-of-subtree tenant, transient + /// `Internal`/`Unavailable`). + pub(crate) async fn assert_owns_ledger( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + ) -> Result<(), CanonicalError> { + let tenant_type = self + .tenant_types + .tenant_type(ctx, tenant_id) + .await? + .ok_or_else(|| { + LedgerResource::failed_precondition() + .with_precondition_violation( + "tenant_type", + "tenant has no resolved type; cannot confirm it owns a ledger".to_owned(), + "TENANT_TYPE_UNKNOWN", + ) + .create() + })?; + if self.seller_types.contains(&tenant_type) { + Ok(()) + } else { + Err(LedgerResource::failed_precondition() + .with_precondition_violation( + "tenant_type", + format!("tenant type {tenant_type} does not own a billing ledger"), + "TENANT_TYPE_NOT_LEDGER_OWNER", + ) + .create()) + } + } +} + +#[cfg(test)] +#[path = "seller_guard_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/seller_guard_tests.rs b/gears/bss/ledger/ledger/src/infra/seller_guard_tests.rs new file mode 100644 index 000000000..6719e3cc5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/seller_guard_tests.rs @@ -0,0 +1,99 @@ +use std::sync::Arc; + +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::*; + +const SELLER: &str = "gts.cf.core.am.tenant_type.v1~vz.ams.tenants.partner.v1~"; +const BUYER: &str = "gts.cf.core.am.tenant_type.v1~vz.ams.tenants.organization.v1~"; + +/// Canned tenant-type reader (stands in for the AM `get_tenant` adapter). +struct FakeReader(Option); + +#[async_trait] +impl TenantTypeReader for FakeReader { + async fn tenant_type( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + ) -> Result, CanonicalError> { + Ok(self.0.clone()) + } +} + +/// A guard whose configured seller set is exactly `{SELLER}`. +fn guard(tenant_type: Option<&str>) -> SellerGuard { + SellerGuard::new( + Arc::new(FakeReader(tenant_type.map(str::to_owned))), + [SELLER.to_owned()], + ) +} + +#[tokio::test] +async fn seller_type_passes() { + let ctx = SecurityContext::anonymous(); + assert!( + guard(Some(SELLER)) + .assert_owns_ledger(&ctx, Uuid::now_v7()) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn non_seller_type_is_rejected() { + let ctx = SecurityContext::anonymous(); + let err = guard(Some(BUYER)) + .assert_owns_ledger(&ctx, Uuid::now_v7()) + .await + .unwrap_err(); + assert!( + matches!(err, CanonicalError::FailedPrecondition { .. }), + "got {err:?}" + ); +} + +#[tokio::test] +async fn unresolved_type_is_rejected() { + let ctx = SecurityContext::anonymous(); + let err = guard(None) + .assert_owns_ledger(&ctx, Uuid::now_v7()) + .await + .unwrap_err(); + assert!( + matches!(err, CanonicalError::FailedPrecondition { .. }), + "got {err:?}" + ); +} + +/// A reader that surfaces a transient AM error (models `get_tenant` failing). +struct FailingReader; + +#[async_trait] +impl TenantTypeReader for FailingReader { + async fn tenant_type( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + ) -> Result, CanonicalError> { + Err(CanonicalError::service_unavailable().create()) + } +} + +/// A non-precondition AM error (transient / `NotFound`) must propagate +/// UNCHANGED, not be collapsed into the `FailedPrecondition` seller-reject — +/// otherwise a registry blip would masquerade as "not a ledger owner". +#[tokio::test] +async fn am_error_propagates_unchanged() { + let ctx = SecurityContext::anonymous(); + let g = SellerGuard::new(Arc::new(FailingReader), [SELLER.to_owned()]); + let err = g + .assert_owns_ledger(&ctx, Uuid::now_v7()) + .await + .unwrap_err(); + assert!( + matches!(err, CanonicalError::ServiceUnavailable { .. }), + "transient AM error must propagate, not collapse to FailedPrecondition; got {err:?}" + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/storage.rs b/gears/bss/ledger/ledger/src/infra/storage.rs new file mode 100644 index 000000000..ebb633525 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage.rs @@ -0,0 +1,8 @@ +//! Persistence layer: migrations, entities, repos (added incrementally). + +pub mod entity; +pub mod migrations; +/// `OData` field → `SeaORM` column mappers consumed by `paginate_odata` in the +/// list repos (the `FieldToColumn` / `ODataFieldMapping` impls). +pub mod odata_mapping; +pub mod repo; diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity.rs b/gears/bss/ledger/ledger/src/infra/storage/entity.rs new file mode 100644 index 000000000..b0e8d31e0 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity.rs @@ -0,0 +1,50 @@ +//! `SeaORM` entities for the bss-ledger gear (schema `bss`). + +pub mod account_balance; +pub mod ar_invoice_balance; +pub mod ar_payer_balance; +pub mod audit_chain_state; +pub mod audit_pack_export; +pub mod chain_checkpoint; +pub mod chain_state; +pub mod credit_note; +pub mod currency_scale_registry; +pub mod debit_note; +pub mod dispute; +pub mod dual_control_approval; +pub mod dual_control_comment; +pub mod dual_control_policy; +pub mod entry_annotation; +pub mod exception_queue; +pub mod fiscal_calendar; +pub mod fiscal_period; +pub mod fx_rate; +pub mod fx_rate_snapshot; +pub mod fx_revaluation_mode; +pub mod fx_revaluation_run; +pub mod idempotency_dedup; +pub mod invoice_exposure; +pub mod journal_entry; +pub mod journal_line; +pub mod payer_pii_map; +pub mod payer_state; +pub mod payment_allocation; +pub mod payment_allocation_refund; +pub mod payment_settlement; +pub mod pending_event_queue; +pub mod period_close; +pub mod posting_policy; +pub mod recognition_run; +pub mod recognition_schedule; +pub mod recognition_segment; +pub mod reconciliation_run; +pub mod refund; +pub mod reusable_credit_subbalance; +pub mod scope_freeze; +pub mod secured_audit_record; +pub mod tax_subbalance; +pub mod tenant_account; +pub mod tenant_posting_lock; +pub mod tenant_precedence_policy; +pub mod unallocated_balance; +pub mod verified_balance; diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/account_balance.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/account_balance.rs new file mode 100644 index 000000000..88d5cb999 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/account_balance.rs @@ -0,0 +1,34 @@ +//! `SeaORM` entity for `bss.ledger_account_balance` (per-account derived cache). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_account_balance")] +#[secure( + tenant_col = "tenant_id", + resource_col = "account_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub account_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub currency: String, + pub account_class: String, + pub normal_side: String, + pub balance_minor: i64, + pub functional_balance_minor: Option, + pub functional_currency: Option, + pub last_entry_seq: Option, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/ar_invoice_balance.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/ar_invoice_balance.rs new file mode 100644 index 000000000..58ffca1e3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/ar_invoice_balance.rs @@ -0,0 +1,44 @@ +//! `SeaORM` entity for `bss.ledger_ar_invoice_balance` (per-invoice AR cache). + +use chrono::{DateTime, NaiveDate, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_ar_invoice_balance")] +#[secure( + tenant_col = "tenant_id", + resource_col = "account_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub payer_tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub account_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub invoice_id: String, + pub currency: String, + pub balance_minor: i64, + /// Disputed sub-portion of the open AR (`0 <= disputed_minor <= balance_minor`). + /// `balance_minor` stays the FULL open AR through a dispute reclass + /// (AR-class-neutral); this carries the disputed slice. The per-invoice + /// `ar_status` flag is DERIVED: `DISPUTED` iff `disputed_minor == balance_minor` + /// (with `balance_minor > 0`), else `ACTIVE`. + pub disputed_minor: i64, + pub functional_balance_minor: Option, + pub functional_currency: Option, + pub original_posted_at: Option>, + pub due_date: Option, + pub last_entry_seq: Option, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/ar_payer_balance.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/ar_payer_balance.rs new file mode 100644 index 000000000..57b1a87aa --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/ar_payer_balance.rs @@ -0,0 +1,34 @@ +//! `SeaORM` entity for `bss.ledger_ar_payer_balance` (per-payer AR cache). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_ar_payer_balance")] +#[secure( + tenant_col = "tenant_id", + resource_col = "account_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub payer_tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub account_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub currency: String, + pub balance_minor: i64, + pub functional_balance_minor: Option, + pub functional_currency: Option, + pub last_entry_seq: Option, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/audit_chain_state.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/audit_chain_state.rs new file mode 100644 index 000000000..7ba18d0f8 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/audit_chain_state.rs @@ -0,0 +1,24 @@ +//! `SeaORM` entity for `bss.audit_chain_state` (per-tenant secured-audit chain +//! tip). One row per tenant pins the last sealed `row_hash`, audit id, and +//! sequence so the next audit append links onto it. Mirrors `chain_state` but +//! keyed to the audit store's own chain. + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "audit_chain_state")] +#[secure(tenant_col = "tenant_id", no_resource, no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + pub last_row_hash: Vec, + pub last_audit_id: Uuid, + pub last_seq: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/audit_pack_export.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/audit_pack_export.rs new file mode 100644 index 000000000..06fe80589 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/audit_pack_export.rs @@ -0,0 +1,61 @@ +//! `SeaORM` entity for `bss.audit_pack_export` — one materialized audit-pack +//! export job (Slice 6 §5/§10). `POST …/audit/packs` creates a row and returns +//! `202 Accepted` + a `Location` to `GET …/audit/packs/{exportId}`, which polls +//! this row for the job `status` and, once `succeeded`, the materialized CSV. +//! +//! The row is owned by the **requester's home tenant** (`tenant_id`) — the same +//! tenant the cross-tenant-access forensic record is written under — so the +//! requester polls it under its own scope. `target_tenant_id` records whose +//! ledger was opened (equal to `tenant_id` on a routine same-tenant export). +//! +//! **MVP is contract-only (§10 interim).** The CSV is still built synchronously +//! in the create request, so a created row is born `succeeded` with its `csv` +//! set. The wire contract (202 + `Location` + polling) is the durable part; a +//! background worker that flips `accepted` → `processing` → `succeeded` is a +//! future extension that needs no migration (the `status` values already model +//! it). +//! +//! `SQLite` (the non-production test backend) mirrors the shape with the +//! systematic transforms (drop the `bss.` prefix; `uuid` → `text`; +//! `bytea` → `blob`; `timestamptz` → `text`). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "audit_pack_export")] +#[secure(tenant_col = "tenant_id", no_resource, no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub export_id: Uuid, + /// Owner tenant = the requester's home tenant (the row is polled under this + /// scope). + pub tenant_id: Uuid, + /// The tenant whose ledger was exported (= `tenant_id` for a routine + /// same-tenant export; a different tenant on the forensic cross-tenant path). + pub target_tenant_id: Uuid, + /// `accepted` | `processing` | `succeeded` | `failed`. MVP rows are born + /// `succeeded` (the build is synchronous); the other states are reserved for + /// the future background-worker path. + pub status: String, + /// Machine-readable investigation reason code (cross-tenant exports only). + pub reason_code: Option, + /// The authenticated subject that requested the export. + pub actor_ref: String, + /// The materialized CSV document (UTF-8 bytes); present once `succeeded`. + pub csv: Option>, + /// Data-row count of the CSV (excludes the header row). + pub row_count: i64, + /// Failure diagnostic when `status = failed` (id-only / no PII). + pub error_detail: Option, + pub created_at_utc: DateTime, + /// Set when the job reaches a terminal state (`succeeded` / `failed`). + pub completed_at_utc: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/chain_checkpoint.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/chain_checkpoint.rs new file mode 100644 index 000000000..084a73b00 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/chain_checkpoint.rs @@ -0,0 +1,33 @@ +//! `SeaORM` entity for `bss.chain_checkpoint` (per-tenant retention +//! checkpoint). One row records a contiguous range of the tamper-evidence hash +//! chain (`from_row_hash` .. `to_row_hash`) plus the number of journal entries +//! it covers, so a future partition-rotation pass can prove a detached +//! partition is anchored by a checkpoint before it retires the rows. +//! +//! Dormant seam (Slice 6 §4.8): partitioning / rotation is Foundation debt, so +//! nothing writes a checkpoint on a schedule yet. `signature` is `Option` — +//! signing / WORM is post-MVP (Bucket A); an MVP checkpoint is unsigned. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "chain_checkpoint")] +#[secure(tenant_col = "tenant_id", no_resource, no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub checkpoint_id: Uuid, + pub tenant_id: Uuid, + pub from_row_hash: Vec, + pub to_row_hash: Vec, + pub covered_entry_count: i64, + pub signature: Option>, + pub created_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/chain_state.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/chain_state.rs new file mode 100644 index 000000000..fcbf10d4b --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/chain_state.rs @@ -0,0 +1,24 @@ +//! `SeaORM` entity for `bss.chain_state` (per-tenant tamper-evidence chain +//! tip). One row per tenant pins the last sealed `row_hash`, entry id, period, +//! and sequence so the next seal links onto it. + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "chain_state")] +#[secure(tenant_col = "tenant_id", no_resource, no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + pub last_row_hash: Vec, + pub last_entry_id: Uuid, + pub last_period_id: String, + pub last_seq: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/credit_note.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/credit_note.rs new file mode 100644 index 000000000..313712e8d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/credit_note.rs @@ -0,0 +1,48 @@ +//! `SeaORM` entity for `bss.ledger_credit_note` (the record linking a posted +//! credit note to its originating posted invoice item, revenue stream, and the +//! recognized/deferred split basis, keyed by `(tenant_id, credit_note_id)`). +//! Tenant-scoped via `SecureORM`; the resource col is the business +//! `credit_note_id` (mirrors `recognition_schedule`'s `schedule_id`). +//! +//! `amount_minor` is incl-tax; `recognized_part_minor` + `deferred_part_minor` +//! are the ex-tax split parts recorded by the `RecognizedDeferredSplitter` +//! (Phase 1, Group B) — they do NOT sum to `amount_minor`, so there is +//! deliberately no `recognized + deferred == amount` CHECK. The headroom cap +//! lives on `invoice_exposure`; the deferred-portion schedule reduction +//! (`recognized_minor <= total_deferred_minor`) is guarded on +//! `recognition_schedule` (Slice 4), both written under the lock order. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_credit_note")] +#[secure( + tenant_col = "tenant_id", + resource_col = "credit_note_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub credit_note_id: String, + pub origin_invoice_id: String, + pub origin_invoice_item_ref: Option, + pub revenue_stream: String, + pub currency: String, + pub amount_minor: i64, + pub recognized_part_minor: i64, + pub deferred_part_minor: i64, + pub split_basis_ref: Option, + pub reason_code: String, + pub created_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/currency_scale_registry.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/currency_scale_registry.rs new file mode 100644 index 000000000..32628a96d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/currency_scale_registry.rs @@ -0,0 +1,23 @@ +//! `SeaORM` entity for `bss.ledger_currency_scale_registry` (per-tenant minor units). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_currency_scale_registry")] +#[secure(tenant_col = "tenant_id", resource_col = "currency", no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub currency: String, + pub minor_units: i16, + pub plausible_max_major: i64, + pub source: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/debit_note.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/debit_note.rs new file mode 100644 index 000000000..291e07bed --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/debit_note.rs @@ -0,0 +1,42 @@ +//! `SeaORM` entity for `bss.ledger_debit_note` (the record linking a posted debit +//! note — an additional charge — to its originating posted invoice and its +//! recognized/deferred split, keyed by `(tenant_id, debit_note_id)`). +//! Tenant-scoped via `SecureORM`; the resource col is the business +//! `debit_note_id` (mirrors `recognition_schedule`'s `schedule_id`). +//! +//! `amount_minor` is incl-tax; `recognized_part_minor` + `deferred_part_minor` +//! are the ex-tax split parts — as with `credit_note`, they do NOT sum to +//! `amount_minor`, so there is deliberately no `recognized + deferred == amount` +//! CHECK. A debit note raises the invoice's headroom +//! (`invoice_exposure.debit_note_total_minor += amount`) under the lock order. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_debit_note")] +#[secure( + tenant_col = "tenant_id", + resource_col = "debit_note_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub debit_note_id: String, + pub origin_invoice_id: String, + pub currency: String, + pub amount_minor: i64, + pub recognized_part_minor: i64, + pub deferred_part_minor: i64, + pub created_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/dispute.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/dispute.rs new file mode 100644 index 000000000..c4680f5d2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/dispute.rs @@ -0,0 +1,42 @@ +//! `SeaORM` entity for `bss.ledger_dispute` (chargeback dispute current-state: +//! the variant + cycle + last phase + disputed amount, keyed by +//! `(tenant_id, dispute_id)`). Tenant-scoped via `SecureORM`; the resource col +//! is the business `dispute_id` (mirrors `pending_event_queue`'s `business_id`). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_dispute")] +#[secure( + tenant_col = "tenant_id", + resource_col = "dispute_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub dispute_id: String, + pub payment_id: String, + pub currency: String, + pub variant: String, + pub last_phase: String, + pub cycle: i32, + pub disputed_amount_minor: i64, + /// The cash actually moved into `DISPUTE_HOLD` at `opened` for a `CASH_HOLD` + /// dispute (`min(disputed, net)`, Model N) — the size the `won`/`lost` + /// outcome releases / forfeits. Persisted at `opened` so a settlement-return + /// that lowers the payment's `net` between `opened` and the outcome cannot + /// strand the hold (the outcome sizes off THIS stored amount, not a re-read + /// `settled − fee`). `0` for `AR_RECLASS` (no cash leg). + pub cash_hold_minor: i64, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_approval.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_approval.rs new file mode 100644 index 000000000..0a39b6994 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_approval.rs @@ -0,0 +1,45 @@ +//! `SeaORM` entity for `bss.ledger_approval` — the dual-control approval +//! request current-state, one row per governed mutation that crossed a policy +//! threshold (§4.1 of the dual-control impl-design spec). Keyed by `approval_id`; +//! tenant-scoped via `SecureORM` (`resource_col = approval_id`). The lifecycle is +//! `PENDING → APPROVED | REJECTED | NEEDS_REWORK | CANCELLED | EXPIRED`; `intent` +//! is the deterministic replay payload executed on `approve`. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde_json::Value as JsonValue; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_approval")] +#[secure( + tenant_col = "tenant_id", + resource_col = "approval_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub approval_id: Uuid, + pub tenant_id: Uuid, + pub kind: String, + pub state: String, + pub revision: i32, + pub business_key: String, + pub intent: JsonValue, + pub amount_usd_eq_minor: Option, + pub threshold_snapshot: JsonValue, + pub reason_code: String, + pub prepared_by: Uuid, + pub prepared_at: DateTime, + pub approved_by: Option, + pub decided_at: Option>, + pub correlation_id: Uuid, + pub expires_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_comment.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_comment.rs new file mode 100644 index 000000000..6f0268a3c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_comment.rs @@ -0,0 +1,36 @@ +//! `SeaORM` entity for `bss.ledger_approval_comment` — the append-only +//! preparer↔approver thread on a dual-control approval (§4.5). Carries free +//! comments / questions (no state change) and the mandatory reason attached to a +//! `reject` / `request-changes` decision. Append-only: `UPDATE`/`DELETE` are +//! revoked at the DB role (same encapsulation as posted financial facts); +//! tenant-scoped via `SecureORM`, resource col is the parent `approval_id`. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_approval_comment")] +#[secure( + tenant_col = "tenant_id", + resource_col = "approval_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub comment_id: Uuid, + pub approval_id: Uuid, + pub tenant_id: Uuid, + /// The approval `revision` this comment was made against. + pub revision: i32, + pub author_actor: Uuid, + pub body: String, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_policy.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_policy.rs new file mode 100644 index 000000000..9086553b3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/dual_control_policy.rs @@ -0,0 +1,40 @@ +//! `SeaORM` entity for `bss.ledger_dual_control_policy` — per-tenant, +//! append-only effective-dated dual-control threshold versions (§4.2): the D2 +//! amount threshold, the A6 backdating window, and the pending-approval TTL. The +//! resolver picks the row in effect at decision time (latest `effective_from <= +//! now`, highest `version` on a tie); absent a row, the ratified platform +//! defaults apply. Mirrors the `tenant_precedence_policy` append-only shape. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_dual_control_policy")] +#[secure( + tenant_col = "tenant_id", + resource_col = "tenant_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub version: i64, + pub effective_from: DateTime, + /// D2 threshold in USD-equivalent minor units; validated `[10000 .. 100000000]` + /// (100 .. 1,000,000 USD at scale 2) — out-of-range config is rejected. + pub d2_threshold_minor: i64, + /// A6 material-backdating window in business days; validated `[1 .. 30]`. + pub a6_backdating_biz_days: i32, + /// TTL applied to a fresh `PENDING`/`NEEDS_REWORK` record before it expires. + pub pending_ttl_seconds: i64, + pub created_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/entry_annotation.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/entry_annotation.rs new file mode 100644 index 000000000..4d31f256b --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/entry_annotation.rs @@ -0,0 +1,31 @@ +//! `SeaORM` entity for `bss.entry_annotation` (the typed controlled +//! non-financial annotation overlay). MUTABLE current-state: each row holds the +//! CURRENT `description` for one journal entry / line, upserted in place. The +//! append-only history of changes lives in the secured-audit chain +//! (`metadata-change` records) — this table carries no append-only trigger. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "entry_annotation")] +#[secure(tenant_col = "tenant_id", no_resource, no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub target_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub target_kind: String, + pub target_period_id: String, + pub description: Option, + pub actor_ref: String, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/exception_queue.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/exception_queue.rs new file mode 100644 index 000000000..5ad31e407 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/exception_queue.rs @@ -0,0 +1,41 @@ +//! `SeaORM` entity for `bss.ledger_exception_queue` (durable, close-blocking +//! exceptions, keyed by `(tenant_id, exception_id)` — Slice 7). The per-slice +//! exception stubs + the reconciliation framework open rows here; the close gate +//! blocks while any OPEN close-blocking row exists for the period. +//! `GL_WRITEOFF_VARIANCE` → `APPROVED_EXCEPTION` is the one non-blocking +//! disposition. Tenant-scoped via `SecureORM`; the resource col is the +//! synthetic `exception_id`. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde_json::Value as JsonValue; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_exception_queue")] +#[secure( + tenant_col = "tenant_id", + resource_col = "exception_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub exception_id: Uuid, + pub exception_type: String, + pub business_ref: String, + pub status: String, + pub period_id: Option, + pub detail: Option, + pub opened_at: DateTime, + pub resolved_at: Option>, + pub resolved_by: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/fiscal_calendar.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/fiscal_calendar.rs new file mode 100644 index 000000000..9f7812d1f --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/fiscal_calendar.rs @@ -0,0 +1,31 @@ +//! `SeaORM` entity for `bss.ledger_fiscal_calendar` (per-legal-entity calendar config). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_fiscal_calendar")] +#[secure( + tenant_col = "tenant_id", + resource_col = "legal_entity_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub legal_entity_id: Uuid, + pub fiscal_tz: String, + pub granularity: String, + pub fy_start_month: i16, + /// The legal entity's functional (books) currency, ISO-4217 (Slice 5 / S5-F3). + /// NULL → the tenant is treated as single-currency (no FX translation). + pub functional_currency: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/fiscal_period.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/fiscal_period.rs new file mode 100644 index 000000000..903b78b45 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/fiscal_period.rs @@ -0,0 +1,29 @@ +//! `SeaORM` entity for `bss.ledger_fiscal_period` (per-legal-entity period status). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_fiscal_period")] +#[secure( + tenant_col = "tenant_id", + resource_col = "period_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub legal_entity_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub period_id: String, + pub fiscal_tz: String, + pub status: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/fx_rate.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/fx_rate.rs new file mode 100644 index 000000000..a22c6128e --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/fx_rate.rs @@ -0,0 +1,41 @@ +//! `SeaORM` entity for `bss.ledger_fx_rate` (mutable "latest known" rate store). +//! +//! Reference data upserted by the `RateSyncJob` / ingest endpoint and read by +//! `RateSource` at lock time (the per-lock freeze lands in `fx_rate_snapshot`). +//! Exchange rates are not BOLA-sensitive object data, so there is no resource +//! axis — but every read/write from gear code must still flow through the +//! `SecureORM` runner (the `DBRunner`/`DBRunnerInternal` executor is sealed; a +//! gear cannot obtain a raw `&DatabaseConnection`). So this carries a +//! tenant-only `Scopable` (`no_resource`/`no_owner`/`no_type`, the same shape as +//! `chain_state`): the `tenant_id` predicate is applied at the SQL level via +//! `.secure().scope_with(&AccessScope::for_tenant(tenant))` rather than a manual +//! `.filter(tenant_id)`, which is the only mechanically available way to run a +//! query against the toolkit connection. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_fx_rate")] +#[secure(tenant_col = "tenant_id", no_resource, no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub base_currency: String, + #[sea_orm(primary_key, auto_increment = false)] + pub quote_currency: String, + #[sea_orm(primary_key, auto_increment = false)] + pub provider: String, + pub rate_micro: i64, + pub as_of: DateTime, + pub fallback_order: i32, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/fx_rate_snapshot.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/fx_rate_snapshot.rs new file mode 100644 index 000000000..b64dc987b --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/fx_rate_snapshot.rs @@ -0,0 +1,30 @@ +//! `SeaORM` entity for `bss.ledger_fx_rate_snapshot` (immutable per-lock FX +//! rate, frozen on a journal line via `journal_line.rate_snapshot_ref`). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_fx_rate_snapshot")] +#[secure(tenant_col = "tenant_id", resource_col = "rate_id", no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub rate_id: Uuid, + pub base_currency: String, + pub quote_currency: String, + pub rate_micro: i64, + pub as_of: DateTime, + pub provider: String, + pub stale: bool, + pub fallback_order: i32, + pub triangulated_via: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/fx_revaluation_mode.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/fx_revaluation_mode.rs new file mode 100644 index 000000000..82c0ce910 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/fx_revaluation_mode.rs @@ -0,0 +1,39 @@ +//! `SeaORM` entity for `bss.ledger_tenant_fx_revaluation_mode` — per-tenant, +//! append-only effective-dated FX revaluation-mode versions (VHP-1986): whether +//! BSS runs the period-end unrealized revaluation (`MODE_B` = ledger of record) +//! or defers to the tenant's ERP (`MODE_A`, the fail-safe default). The +//! revaluation job / period-close pick the row in effect at decision time (latest +//! `effective_from <= now`, highest `version` on a tie); absent a row, the gear +//! default (`MODE_A`) applies. Mirrors the `tenant_posting_policy` append-only +//! shape. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_tenant_fx_revaluation_mode")] +#[secure( + tenant_col = "tenant_id", + resource_col = "tenant_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub version: i64, + pub effective_from: DateTime, + /// Revaluation mode: `MODE_A` (defer to the tenant's ERP — the fail-safe + /// default) or `MODE_B` (BSS = ledger of record, runs the period-end + /// unrealized revaluation). DB CHECK-constrained to those two literals. + pub revaluation_mode: String, + pub created_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/fx_revaluation_run.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/fx_revaluation_run.rs new file mode 100644 index 000000000..b66da2c1d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/fx_revaluation_run.rs @@ -0,0 +1,42 @@ +//! `SeaORM` entity for `bss.ledger_fx_revaluation_run` — the Mode-B +//! FX-revaluation completion marker (VHP-1859 review C3). One COMPLETE row per +//! `(tenant_id, period_id)`, written by the period-end revaluation job after a +//! clean `run_period`; the close gate requires it when Mode-B is enabled, so a +//! failed/lagged run BLOCKS close instead of leaving a forever-unpostable missing +//! `FX_REVALUATION`. Tenant-scoped via `SecureORM`. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +/// The only persisted status — the period-end run finished every scope cleanly. +pub const STATUS_COMPLETE: &str = "COMPLETE"; +/// Forward-compat `scope` value the whole-period run records (the marker is keyed +/// per period; the column is retained for a future per-scope granularity). +pub const SCOPE_PERIOD: &str = "PERIOD"; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_fx_revaluation_run")] +#[secure( + tenant_col = "tenant_id", + resource_col = "period_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub period_id: String, + /// Forward-compat scope discriminator (`PERIOD` for the whole-period run). + pub scope: String, + /// Lifecycle status — only [`STATUS_COMPLETE`] is persisted. + pub status: String, + pub completed_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/idempotency_dedup.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/idempotency_dedup.rs new file mode 100644 index 000000000..fbe6a0f3a --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/idempotency_dedup.rs @@ -0,0 +1,33 @@ +//! `SeaORM` entity for `bss.ledger_idempotency_dedup` (per-flow request dedup). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_idempotency_dedup")] +#[secure( + tenant_col = "tenant_id", + resource_col = "business_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub flow: String, + #[sea_orm(primary_key, auto_increment = false)] + pub business_id: String, + pub payload_hash: String, + pub result_entry_id: Option, + pub posted_at_utc: Option>, + pub status: String, + pub retain_until: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/invoice_exposure.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/invoice_exposure.rs new file mode 100644 index 000000000..d8cb244b6 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/invoice_exposure.rs @@ -0,0 +1,42 @@ +//! `SeaORM` entity for `bss.ledger_invoice_exposure` (the per-invoice credit-note +//! **headroom** counter: `original_total_minor` seeded = posted AR incl. tax, +//! plus the running `debit_note_total_minor` / `credit_note_total_minor`, keyed +//! by `(tenant_id, invoice_id)`). Tenant-scoped via `SecureORM`; the resource col +//! is the business `invoice_id`. +//! +//! `credit_note_total_minor <= original_total_minor + debit_note_total_minor` is +//! the authoritative headroom guard (design §4.7 / §7, AC #24); the +//! `CreditNoteHandler` (Phase 1) bumps `credit_note_total_minor` by an in-place +//! delta under the lock order with the CHECK evaluated post-delta. The +//! `DebitNoteHandler` raises `debit_note_total_minor` to lift the cap. +//! `original_total_minor` is seeded at first touch via `INSERT … ON CONFLICT DO +//! UPDATE` (the Slice 1 first-touch upsert), so concurrent creators serialize. + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_invoice_exposure")] +#[secure( + tenant_col = "tenant_id", + resource_col = "invoice_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub invoice_id: String, + pub currency: String, + pub original_total_minor: i64, + pub debit_note_total_minor: i64, + pub credit_note_total_minor: i64, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/journal_entry.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/journal_entry.rs new file mode 100644 index 000000000..2ba801a69 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/journal_entry.rs @@ -0,0 +1,41 @@ +//! `SeaORM` entity for `bss.ledger_journal_entry` (append-only truth header). + +use chrono::{DateTime, NaiveDate, Utc}; +use sea_orm::entity::prelude::*; +use serde_json::Value as JsonValue; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_journal_entry")] +#[secure(tenant_col = "tenant_id", resource_col = "entry_id", no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub entry_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + pub legal_entity_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub period_id: String, + pub entry_currency: String, + pub source_doc_type: String, + pub source_business_id: String, + pub reverses_entry_id: Option, + pub reverses_period_id: Option, + pub posted_at_utc: DateTime, + pub effective_at: NaiveDate, + pub origin: String, + pub posted_by_actor_id: Uuid, + pub correlation_id: Uuid, + pub rounding_evidence: JsonValue, + pub created_seq: i64, + pub row_hash: Option>, + pub prev_hash: Option>, + pub prev_entry_id: Option, + pub prev_period_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/journal_line.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/journal_line.rs new file mode 100644 index 000000000..a51c057a7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/journal_line.rs @@ -0,0 +1,57 @@ +//! `SeaORM` entity for `bss.ledger_journal_line` (append-only truth detail). + +use chrono::NaiveDate; +use sea_orm::entity::prelude::*; +use uuid::Uuid; + +use toolkit_db_macros::Scopable; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_journal_line")] +#[secure(tenant_col = "tenant_id", resource_col = "line_id", no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub line_id: Uuid, + pub entry_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub period_id: String, + pub payer_tenant_id: Uuid, + pub seller_tenant_id: Option, + pub resource_tenant_id: Option, + pub account_id: Uuid, + pub account_class: String, + pub gl_code: Option, + pub side: String, + pub amount_minor: i64, + pub currency: String, + pub currency_scale: i16, + pub invoice_id: Option, + pub due_date: Option, + pub revenue_stream: Option, + pub mapping_status: String, + pub functional_amount_minor: Option, + pub functional_currency: Option, + pub tax_jurisdiction: Option, + pub tax_filing_period: Option, + pub tax_rate_ref: Option, + pub legal_entity_id: Option, + pub invoice_item_ref: Option, + pub sku_or_plan_ref: Option, + pub price_id: Option, + pub pricing_snapshot_ref: Option, + pub po_allocation_group: Option, + pub credit_grant_event_type: Option, + /// AR dispute sub-class snapshot (`ACTIVE`/`DISPUTED`), set on AR lines that + /// participate in a chargeback reclass; `NULL` on every other line. + pub ar_status: Option, + /// Locked FX rate for this line (FK → `ledger_fx_rate_snapshot.rate_id`); + /// set on cross-currency posts, `NULL` on single-currency lines (Slice 5). + pub rate_snapshot_ref: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/payer_pii_map.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/payer_pii_map.rs new file mode 100644 index 000000000..92b3337ac --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/payer_pii_map.rs @@ -0,0 +1,29 @@ +//! `SeaORM` entity for `bss.payer_pii_map` (the per-payer PII reference + +//! erasure tombstone, Slice 6 Phase 3 Group 3A, architecture §4.5 / AC #22). +//! +//! One row per `(tenant_id, payer_tenant_id)`. `pii_ref` is an opaque pointer +//! into the external PII store (never the PII itself); `erased` is the GDPR +//! right-to-erasure tombstone the [`crate::infra::pii::ErasureService`] flips in +//! place. UNLIKE the audit / metadata-change tables this row IS mutated (the +//! tombstone), so there is no append-only trigger on its table. + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "payer_pii_map")] +#[secure(tenant_col = "tenant_id", no_resource, no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub payer_tenant_id: Uuid, + pub pii_ref: String, + pub erased: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/payer_state.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/payer_state.rs new file mode 100644 index 000000000..c6c6cb369 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/payer_state.rs @@ -0,0 +1,30 @@ +//! `SeaORM` entity for `bss.ledger_payer_state` (per-payer lifecycle). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_payer_state")] +#[secure( + tenant_col = "tenant_id", + resource_col = "payer_tenant_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub payer_tenant_id: Uuid, + pub lifecycle_state: String, + pub closed_with_open_balance: bool, + pub approved_by: Option, + pub changed_at: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/payment_allocation.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/payment_allocation.rs new file mode 100644 index 000000000..746436e8e --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/payment_allocation.rs @@ -0,0 +1,35 @@ +//! `SeaORM` entity for `bss.ledger_payment_allocation` (one row per +//! `(payment, invoice)` allocation split). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_payment_allocation")] +#[secure( + tenant_col = "tenant_id", + resource_col = "allocation_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub allocation_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub invoice_id: String, + pub payer_tenant_id: Uuid, + pub payment_id: String, + pub amount_minor: i64, + pub currency: String, + pub precedence_policy_ref: String, + pub allocated_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/payment_allocation_refund.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/payment_allocation_refund.rs new file mode 100644 index 000000000..feeff4b97 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/payment_allocation_refund.rs @@ -0,0 +1,31 @@ +//! `SeaORM` entity for `bss.ledger_payment_allocation_refund` (per-`(payment, +//! invoice)` allocated/refunded counter feeding Slice 3's refund cap). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_payment_allocation_refund")] +#[secure( + tenant_col = "tenant_id", + resource_col = "tenant_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub payment_id: String, + #[sea_orm(primary_key, auto_increment = false)] + pub invoice_id: String, + pub allocated_minor: i64, + pub refunded_minor: i64, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/payment_settlement.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/payment_settlement.rs new file mode 100644 index 000000000..5531942d5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/payment_settlement.rs @@ -0,0 +1,34 @@ +//! `SeaORM` entity for `bss.ledger_payment_settlement` (per-payment money-out +//! serialization counters). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_payment_settlement")] +#[secure( + tenant_col = "tenant_id", + resource_col = "tenant_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub payment_id: String, + pub currency: String, + pub settled_minor: i64, + pub fee_minor: i64, + pub allocated_minor: i64, + pub refunded_minor: i64, + pub refunded_unallocated_minor: i64, + pub clawed_back_minor: i64, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/pending_event_queue.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/pending_event_queue.rs new file mode 100644 index 000000000..ea020f262 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/pending_event_queue.rs @@ -0,0 +1,37 @@ +//! `SeaORM` entity for `bss.ledger_pending_event_queue` (durable queued / +//! quarantined deferred-apply work items, keyed by `(tenant_id, flow, +//! business_id)`). Tenant-scoped via `SecureORM`; the `payload` is a PII-free +//! JSON snapshot of the financial keys the apply path needs. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde_json::Value as JsonValue; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_pending_event_queue")] +#[secure( + tenant_col = "tenant_id", + resource_col = "business_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub flow: String, + #[sea_orm(primary_key, auto_increment = false)] + pub business_id: String, + pub payload: JsonValue, + pub queued_at: DateTime, + pub apply_after: Option>, + pub status: String, + pub attempts: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/period_close.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/period_close.rs new file mode 100644 index 000000000..f7a81ec3a --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/period_close.rs @@ -0,0 +1,40 @@ +//! `SeaORM` entity for `bss.ledger_period_close` (the close-process owner: +//! status lifecycle OPEN→CLOSING→CLOSED→REOPENED, the last gate result in +//! `blocked_reasons`, the CLOSING recompute `recon_watermark`, and the REOPEN +//! audit linkage, keyed by `(tenant_id, legal_entity_id, period_id)` — Slice 7). +//! Tenant-scoped via `SecureORM`; the resource col is the business `period_id`. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde_json::Value as JsonValue; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_period_close")] +#[secure( + tenant_col = "tenant_id", + resource_col = "period_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub legal_entity_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub period_id: String, + pub status: String, + pub initiated_by: String, + pub blocked_reasons: Option, + pub recon_watermark: Option, + pub reopen_approval_id: Option, + pub reopened_by: Option, + pub closed_at: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/posting_policy.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/posting_policy.rs new file mode 100644 index 000000000..89767a346 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/posting_policy.rs @@ -0,0 +1,42 @@ +//! `SeaORM` entity for `bss.ledger_tenant_posting_policy` — per-tenant, +//! append-only effective-dated invoice-posting policy versions (VHP-1853): the +//! missing-mapping mode (`SUSPENSE` | `HARD_BLOCK`) and the AR-aging bucket +//! thresholds (CSV upper-bounds). The orchestrator / aging read picks the row in +//! effect at decision time (latest `effective_from <= now`, highest `version` on +//! a tie); absent a row, the gear's built-in defaults apply (`SUSPENSE` + +//! `30,60,90`). Mirrors the `tenant_precedence_policy` / `dual_control_policy` +//! append-only shape. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_tenant_posting_policy")] +#[secure( + tenant_col = "tenant_id", + resource_col = "tenant_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub version: i64, + pub effective_from: DateTime, + /// Missing-mapping policy: `SUSPENSE` (route an unmapped item to suspense — + /// the default) or `HARD_BLOCK` (reject the post `ACCOUNT_MAPPING_MISSING`). + /// DB CHECK-constrained to those two literals. + pub missing_mapping_mode: String, + /// AR-aging bucket upper-bounds — a CSV of strict-increasing positive day + /// counts (e.g. `30,60,90`). Parsed + validated in the domain on read/write. + pub ar_aging_thresholds: String, + pub created_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/recognition_run.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/recognition_run.rs new file mode 100644 index 000000000..f01db406e --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/recognition_run.rs @@ -0,0 +1,39 @@ +//! `SeaORM` entity for `bss.ledger_recognition_run` (an orchestration wrapper +//! that releases due `recognition_segment`s for a period, keyed by +//! `(tenant_id, period_id, run_id)` — `period_id` is folded into the key so a +//! client reusing one `run_id` across two periods runs BOTH; +//! the entity PK MUST mirror the migration's 3-column PK so a per-row write +//! cannot match the wrong period's run). The run is **not** itself the at-most-once dedup key +//! (that is the per-segment `(tenant, RECOGNITION, schedule_id:segment_no)` +//! gate); run-trigger dedup `(tenant_id, period_id, run_id)` + a per-`(tenant, +//! period_id)` single-active-run advisory lock live at the orchestration layer +//! (Phase 2, design §4.3). Tenant-scoped via `SecureORM`; the resource col is +//! the business `run_id`. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_recognition_run")] +#[secure(tenant_col = "tenant_id", resource_col = "run_id", no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + // PK column order mirrors the migration's `PRIMARY KEY (tenant_id, period_id, + // run_id)`: `period_id` is part of the key, so a per-run + // write must qualify by it (a bare `(tenant, run_id)` filter would touch the + // other period's run row when a client reuses one `run_id` across periods). + #[sea_orm(primary_key, auto_increment = false)] + pub period_id: String, + #[sea_orm(primary_key, auto_increment = false)] + pub run_id: Uuid, + pub started_at_utc: DateTime, + pub status: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/recognition_schedule.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/recognition_schedule.rs new file mode 100644 index 000000000..560e02149 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/recognition_schedule.rs @@ -0,0 +1,55 @@ +//! `SeaORM` entity for `bss.ledger_recognition_schedule` (the ASC 606 documented +//! release plan for one single-revenue-stream deferred Contract-liability +//! balance: links to the originating posted invoice item, PO/allocation group, +//! currency, total deferred + recognized-to-date, immutable policy/SSP/VC refs, +//! and status, keyed by `(tenant_id, schedule_id)`). Tenant-scoped via +//! `SecureORM`; the resource col is the business `schedule_id` (mirrors +//! `dispute`'s `dispute_id`). +//! +//! `recognized_minor <= total_deferred_minor` is the authoritative +//! over-recognition guard (design §7); the `RecognitionRunner` (Phase 2) bumps +//! `recognized_minor` by an in-place delta under the lock order with the CHECK +//! evaluated post-delta. The partial `UNIQUE (tenant_id, source_invoice_id, +//! source_invoice_item_ref, revenue_stream) WHERE status='ACTIVE'` is the +//! at-most-one-live guard; build-idempotency is decoupled from `status` and +//! lives in `idempotency_dedup` (Rev3 / S4-F2), so a terminal `COMPLETED` +//! schedule stays archivable without re-opening a duplicate-build hole. + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_recognition_schedule")] +#[secure( + tenant_col = "tenant_id", + resource_col = "schedule_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub schedule_id: String, + pub payer_tenant_id: Uuid, + pub source_invoice_id: String, + pub source_invoice_item_ref: String, + pub po_allocation_group: Option, + pub subscription_ref: Option, + pub revenue_stream: String, + pub currency: String, + pub total_deferred_minor: i64, + pub recognized_minor: i64, + pub policy_ref: String, + pub ssp_snapshot_ref: Option, + pub vc_estimate_ref: Option, + pub vc_method_ref: Option, + pub status: String, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/recognition_segment.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/recognition_segment.rs new file mode 100644 index 000000000..d109389e7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/recognition_segment.rs @@ -0,0 +1,45 @@ +//! `SeaORM` entity for `bss.ledger_recognition_segment` (one time- or +//! milestone-slice of a [`recognition_schedule`](super::recognition_schedule) — +//! the **at-most-once unit**, one per `(schedule, period)`, keyed by +//! `(tenant_id, schedule_id, segment_no)`). Tenant-scoped via `SecureORM`; the +//! resource col is the parent `schedule_id` (a segment is owned by its +//! schedule). +//! +//! `segment_no` is immutable and 1:1 with `period_id` +//! (`UNIQUE (tenant_id, schedule_id, period_id)`), so the dedup grain and the +//! UNIQUE grain are provably identical (design §4.1 / §7). The +//! `RecognitionRunner` (Phase 2) stamps `status=DONE`, `recognized_at`, and +//! `run_id` in the same transaction as the `DR CL / CR Revenue` post; the +//! `status=DONE`/`run_id` + the period UNIQUE prevent a second credit. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_recognition_segment")] +#[secure( + tenant_col = "tenant_id", + resource_col = "schedule_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub schedule_id: String, + #[sea_orm(primary_key, auto_increment = false)] + pub segment_no: i32, + pub period_id: String, + pub amount_minor: i64, + pub status: String, + pub recognized_at: Option>, + pub run_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/reconciliation_run.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/reconciliation_run.rs new file mode 100644 index 000000000..1279461dd --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/reconciliation_run.rs @@ -0,0 +1,35 @@ +//! `SeaORM` entity for `bss.ledger_reconciliation_run` (one reconciliation +//! check execution + its variance result, keyed by `(tenant_id, run_id)` — +//! Slice 7). An out-of-tolerance run opens an `exception_queue` row and feeds +//! the period-close gate; `watermark` is the max in-period `created_seq` the run +//! covered. Tenant-scoped via `SecureORM`; the resource col is the business +//! `run_id`. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde_json::Value as JsonValue; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_reconciliation_run")] +#[secure(tenant_col = "tenant_id", resource_col = "run_id", no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub run_id: Uuid, + pub period_id: String, + pub check_type: String, + pub variance_minor: i64, + pub within_tolerance: bool, + pub status: String, + pub watermark: Option, + pub detail: Option, + pub at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/refund.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/refund.rs new file mode 100644 index 000000000..cbc84ed8e --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/refund.rs @@ -0,0 +1,49 @@ +//! `SeaORM` entity for `bss.ledger_refund` (the record of a PSP refund's +//! two-stage lifecycle, keyed by the surrogate `(tenant_id, refund_id)`). +//! Tenant-scoped via `SecureORM`; the resource col is the business `refund_id` +//! (mirrors the sibling note tables' `credit_note_id`/`debit_note_id` — a +//! `varchar(128)`, NOT a `uuid` column). +//! +//! The idempotency grain is the natural `(tenant_id, psp_refund_id, phase)` (a +//! separate `UNIQUE` index, NOT the PK): one PSP refund advances through several +//! `phase` rows. Both patterns carry origin `payment_id`; `invoice_id` is NULL +//! for Pattern A (`A_UNALLOCATED`) and required for Pattern B (`B_RESTORE_AR`). +//! `reverses_entry_id` is set ONLY on a stage-1 line-negation (PSP reject/void); +//! `relates_to_refund_id` is the refund-of-refund forward link. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_refund")] +#[secure( + tenant_col = "tenant_id", + resource_col = "refund_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub refund_id: String, + pub psp_refund_id: String, + pub phase: String, + pub pattern: String, + pub payment_id: String, + pub invoice_id: Option, + pub currency: String, + pub amount_minor: i64, + pub clearing_state: String, + pub relates_to_refund_id: Option, + pub reverses_entry_id: Option, + pub created_at_utc: DateTime, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/reusable_credit_subbalance.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/reusable_credit_subbalance.rs new file mode 100644 index 000000000..7aab0a818 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/reusable_credit_subbalance.rs @@ -0,0 +1,40 @@ +//! `SeaORM` entity for `bss.ledger_reusable_credit_subbalance` (reusable-credit cache). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_reusable_credit_subbalance")] +#[secure( + tenant_col = "tenant_id", + resource_col = "account_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub payer_tenant_id: Uuid, + // The grain is one account per (tenant, payer, currency, event-type); + // `account_id` is a resolved attribute (the SecureORM `resource_col`), NOT a + // key dimension. + pub account_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub currency: String, + #[sea_orm(primary_key, auto_increment = false)] + pub credit_grant_event_type: String, + pub first_granted_at: Option>, + pub balance_minor: i64, + pub functional_balance_minor: Option, + pub functional_currency: Option, + pub last_entry_seq: Option, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/scope_freeze.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/scope_freeze.rs new file mode 100644 index 000000000..2989764b3 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/scope_freeze.rs @@ -0,0 +1,32 @@ +//! `SeaORM` entity for `bss.scope_freeze` (per-tenant tamper-freeze switch). +//! One row per `(tenant_id, scope, period_id)` STOPS further posting into the +//! frozen scope after the integrity verifier finds a broken chain. A row is +//! ACTIVE while `cleared_at IS NULL`; `period_id` is `'ALL'` for a tenant-wide +//! freeze or a concrete period to freeze just that period. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "scope_freeze")] +#[secure(tenant_col = "tenant_id", no_resource, no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub scope: String, + #[sea_orm(primary_key, auto_increment = false)] + pub period_id: String, + pub reason: String, + pub frozen_at: DateTime, + pub set_by: String, + pub cleared_by: Option, + pub cleared_at: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/secured_audit_record.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/secured_audit_record.rs new file mode 100644 index 000000000..948d5cf20 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/secured_audit_record.rs @@ -0,0 +1,33 @@ +//! `SeaORM` entity for `bss.secured_audit_record` (the secured audit store). +//! Append-only: each row is born sealed (`row_hash` / `prev_hash` non-NULL) +//! linked into the tenant's own per-tenant audit hash chain, and is never +//! updated or deleted (the Postgres `reject_mutation()` trigger enforces it). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde_json::Value as JsonValue; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "secured_audit_record")] +#[secure(tenant_col = "tenant_id", no_resource, no_owner, no_type)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub audit_id: Uuid, + pub tenant_id: Uuid, + pub event_type: String, + pub actor_ref: Option, + pub reason_code: Option, + pub before_after: JsonValue, + pub correlation_id: Option, + pub row_hash: Vec, + pub prev_hash: Vec, + pub at_utc: DateTime, + pub retain_until: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/tax_subbalance.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/tax_subbalance.rs new file mode 100644 index 000000000..bbee51238 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/tax_subbalance.rs @@ -0,0 +1,32 @@ +//! `SeaORM` entity for `bss.ledger_tax_subbalance` (per-jurisdiction tax cache). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_tax_subbalance")] +#[secure( + tenant_col = "tenant_id", + resource_col = "account_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub account_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub tax_jurisdiction: String, + #[sea_orm(primary_key, auto_increment = false)] + pub tax_filing_period: String, + pub balance_minor: i64, + pub last_entry_seq: Option, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/tenant_account.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/tenant_account.rs new file mode 100644 index 000000000..cfadffa6a --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/tenant_account.rs @@ -0,0 +1,31 @@ +//! `SeaORM` entity for `bss.ledger_tenant_account` (chart of accounts). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_tenant_account")] +#[secure( + tenant_col = "tenant_id", + resource_col = "account_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub account_id: Uuid, + pub tenant_id: Uuid, + pub legal_entity_id: Uuid, + pub account_class: String, + pub currency: String, + pub revenue_stream: Option, + pub normal_side: String, + pub may_go_negative: bool, + pub lifecycle_state: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/tenant_posting_lock.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/tenant_posting_lock.rs new file mode 100644 index 000000000..656cd6d4b --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/tenant_posting_lock.rs @@ -0,0 +1,30 @@ +//! `SeaORM` entity for `bss.ledger_tenant_posting_lock` (per-tenant posting kill switch). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_tenant_posting_lock")] +#[secure( + tenant_col = "tenant_id", + resource_col = "tenant_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + pub locked: bool, + pub reason_code: Option, + pub set_by: Option, + pub set_at: Option>, + pub cleared_by: Option, + pub cleared_at: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/tenant_precedence_policy.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/tenant_precedence_policy.rs new file mode 100644 index 000000000..c69c70897 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/tenant_precedence_policy.rs @@ -0,0 +1,30 @@ +//! `SeaORM` entity for `bss.ledger_tenant_precedence_policy` (per-tenant, +//! append-only effective-dated allocation precedence policy versions). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_tenant_precedence_policy")] +#[secure( + tenant_col = "tenant_id", + resource_col = "tenant_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub version: i64, + pub effective_from: DateTime, + pub strategy: String, + pub created_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/unallocated_balance.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/unallocated_balance.rs new file mode 100644 index 000000000..3a48e9fe9 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/unallocated_balance.rs @@ -0,0 +1,35 @@ +//! `SeaORM` entity for `bss.ledger_unallocated_balance` (unapplied-cash cache). + +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_unallocated_balance")] +#[secure( + tenant_col = "tenant_id", + resource_col = "account_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub payer_tenant_id: Uuid, + // The grain is one account per (tenant, payer, currency); `account_id` is a + // resolved attribute (and the SecureORM `resource_col`), NOT a key dimension. + pub account_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub currency: String, + pub balance_minor: i64, + pub functional_balance_minor: Option, + pub functional_currency: Option, + pub last_entry_seq: Option, + pub version: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/entity/verified_balance.rs b/gears/bss/ledger/ledger/src/infra/storage/entity/verified_balance.rs new file mode 100644 index 000000000..4143aae22 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/entity/verified_balance.rs @@ -0,0 +1,55 @@ +//! `SeaORM` entity for `bss.ledger_verified_balance` — the cumulative VERIFIED +//! tie-out baseline per grain through the last closed period (VHP-1843 +//! incremental tie-out). One row per `(tenant_id, grain, grain_key)`; the daily +//! job and the `AR_DERIVED` recon check verify `baseline + fold(open periods) == +//! cache` instead of folding all-time. Written in the period-close txn right +//! after the clean full tie-out passes. Tenant-scoped via `SecureORM`. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +/// Grain discriminators — mirror the derived caches the tie-out folds, and the +/// `chk_verified_balance_grain` CHECK. `ar_invoice` and `ar_invoice_disputed` +/// share the invoice cache row but verify two independent columns. +pub const GRAIN_ACCOUNT: &str = "account"; +pub const GRAIN_AR_PAYER: &str = "ar_payer"; +pub const GRAIN_AR_INVOICE: &str = "ar_invoice"; +pub const GRAIN_AR_INVOICE_DISPUTED: &str = "ar_invoice_disputed"; +pub const GRAIN_TAX: &str = "tax"; +pub const GRAIN_UNALLOCATED: &str = "unallocated"; +pub const GRAIN_REUSABLE_CREDIT: &str = "reusable_credit"; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "ledger_verified_balance")] +#[secure( + tenant_col = "tenant_id", + resource_col = "tenant_id", + no_owner, + no_type +)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: Uuid, + /// Cache discriminator — one of the `GRAIN_*` constants. + #[sea_orm(primary_key, auto_increment = false)] + pub grain: String, + /// Canonical per-instance key the tie-out fold produces for this grain + /// (e.g. `account_id|currency`), so the verify compares like-for-like. + #[sea_orm(primary_key, auto_increment = false)] + pub grain_key: String, + /// Cumulative verified balance through `through_period`, in minor units. + pub verified_balance_minor: i64, + /// The last closed period this baseline is verified through. + pub through_period: String, + /// Max in-period `created_seq` covered by this baseline (the incremental + /// boundary — the open fold starts strictly after the closed periods). + pub watermark_seq: i64, + pub updated_at_utc: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations.rs new file mode 100644 index 000000000..fda6b2c7b --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations.rs @@ -0,0 +1,137 @@ +//! Migration set for the bss-ledger gear (schema `bss`). Greenfield +//! chain; migrations are added by subsequent tasks. + +pub mod m20260619_000001_create_bss_schema; +pub mod m20260619_000002_create_journal_tables; +pub mod m20260619_000003_create_balance_caches; +pub mod m20260619_000004_create_idempotency_and_reference; +pub mod m20260619_000005_create_fiscal_calendar; +pub mod m20260622_000006_create_payment_tables; +pub mod m20260623_000007_create_precedence_policy; +pub mod m20260623_000008_create_pending_event_queue; +pub mod m20260623_000009_add_ar_status; +pub mod m20260623_000010_create_dispute; +pub mod m20260624_000011_create_chain_state; +pub mod m20260624_000011_create_recognition_tables; +pub mod m20260624_000012_create_dual_control_tables; +pub mod m20260624_000012_relax_journal_entry_trigger; +pub mod m20260624_000013_create_scope_freeze; +pub mod m20260624_000014_create_secured_audit; +pub mod m20260624_000015_create_entry_annotation; +pub mod m20260624_000016_create_payer_pii_map; +pub mod m20260624_000017_create_chain_checkpoint; +pub mod m20260624_000018_create_audit_pack_export; +pub mod m20260625_000013_dual_control_approving_state; +pub mod m20260626_000019_create_invoice_exposure; +pub mod m20260626_000020_create_credit_note; +pub mod m20260626_000021_create_debit_note; +pub mod m20260626_000022_create_refund; +pub mod m20260626_000023_refund_approval_kind; +pub mod m20260626_000024_manual_adjustment_approval_kind; +pub mod m20260626_000025_note_approval_kinds; +pub mod m20260627_000026_create_fx_rate_tables; +pub mod m20260627_000027_journal_line_rate_ref; +pub mod m20260627_000028_wide_cache_functional_cols; +pub mod m20260627_000029_dual_column_commit_check; +pub mod m20260627_000030_fiscal_calendar_functional_ccy; +pub mod m20260628_000031_cache_functional_consistency; +pub mod m20260628_000032_snapshot_identity_rate_micro; +pub mod m20260628_000033_create_period_close; +pub mod m20260628_000034_create_exception_queue; +pub mod m20260628_000035_create_reconciliation_run; +pub mod m20260628_000036_exception_queue_open_uniq; +pub mod m20260629_000037_create_posting_policy; +pub mod m20260629_000038_create_verified_balance; +pub mod m20260629_000039_create_fx_revaluation_run; +pub mod m20260630_000040_create_fx_revaluation_mode; +pub mod m20260706_000041_currency_scale_immutable; + +use sea_orm_migration::prelude::*; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20260619_000001_create_bss_schema::Migration), + Box::new(m20260619_000002_create_journal_tables::Migration), + Box::new(m20260619_000003_create_balance_caches::Migration), + Box::new(m20260619_000004_create_idempotency_and_reference::Migration), + Box::new(m20260619_000005_create_fiscal_calendar::Migration), + Box::new(m20260622_000006_create_payment_tables::Migration), + Box::new(m20260623_000007_create_precedence_policy::Migration), + Box::new(m20260623_000008_create_pending_event_queue::Migration), + Box::new(m20260623_000009_add_ar_status::Migration), + Box::new(m20260623_000010_create_dispute::Migration), + Box::new(m20260624_000011_create_recognition_tables::Migration), + Box::new(m20260624_000012_create_dual_control_tables::Migration), + Box::new(m20260625_000013_dual_control_approving_state::Migration), + Box::new(m20260626_000019_create_invoice_exposure::Migration), + Box::new(m20260626_000020_create_credit_note::Migration), + Box::new(m20260626_000021_create_debit_note::Migration), + Box::new(m20260626_000022_create_refund::Migration), + Box::new(m20260626_000023_refund_approval_kind::Migration), + Box::new(m20260626_000024_manual_adjustment_approval_kind::Migration), + Box::new(m20260626_000025_note_approval_kinds::Migration), + // Shared `coord_leases` table (the single-active recognition-run + // lease). Owned by the `coord` crate. Qualified into `bss` (like the + // gear's other domain DDL) so it lands in `bss` regardless of the + // connection's `search_path` order. NOTE: the toolkit migration runner + // applies migrations in NAME order, so this `m0001_…` name actually + // sorts FIRST — before `…000001_create_bss_schema`; coord's `in_schema` + // `up` therefore runs `CREATE SCHEMA IF NOT EXISTS bss` itself before + // the `CREATE TABLE`, so the qualification is safe despite running + // first (and idempotent with the schema migration that follows). + Box::new(coord::migration::Migration::in_schema("bss")), + Box::new(m20260624_000011_create_chain_state::Migration), + Box::new(m20260624_000012_relax_journal_entry_trigger::Migration), + Box::new(m20260624_000013_create_scope_freeze::Migration), + Box::new(m20260624_000014_create_secured_audit::Migration), + Box::new(m20260624_000015_create_entry_annotation::Migration), + Box::new(m20260624_000016_create_payer_pii_map::Migration), + Box::new(m20260624_000017_create_chain_checkpoint::Migration), + Box::new(m20260624_000018_create_audit_pack_export::Migration), + // --- Slice 5 (FX & multi-currency) substrate, appended at the Vec + // end (positions 30–34) so the down-magic counts in + // postgres_migration_idempotency.rs shift by the +5 only. --- + Box::new(m20260627_000026_create_fx_rate_tables::Migration), + Box::new(m20260627_000027_journal_line_rate_ref::Migration), + Box::new(m20260627_000028_wide_cache_functional_cols::Migration), + Box::new(m20260627_000029_dual_column_commit_check::Migration), + // S5-F3: the legal-entity functional-currency source (rides the + // existing per-LE fiscal-calendar row). + Box::new(m20260627_000030_fiscal_calendar_functional_ccy::Migration), + Box::new(m20260628_000031_cache_functional_consistency::Migration), + Box::new(m20260628_000032_snapshot_identity_rate_micro::Migration), + // --- Slice 7 (reconciliation & period-close) substrate, appended at the + // Vec end (after the Slice-5 remediation migrations) so the down-magic + // counts in postgres_migration_idempotency.rs shift by the +4 only. --- + Box::new(m20260628_000033_create_period_close::Migration), + Box::new(m20260628_000034_create_exception_queue::Migration), + Box::new(m20260628_000035_create_reconciliation_run::Migration), + // Remediation: enforce single-OPEN-row dedup at the DB level. + Box::new(m20260628_000036_exception_queue_open_uniq::Migration), + // VHP-1853: tenant-configurable invoice-posting policies (missing-mapping + // mode + AR-aging buckets), appended at the Vec end so the down-magic + // counts in postgres_migration_idempotency.rs shift by +1 only. + Box::new(m20260629_000037_create_posting_policy::Migration), + // VHP-1843: incremental tie-out baseline (ledger_verified_balance), + // appended at the Vec end so the down-magic counts in + // postgres_migration_idempotency.rs shift by +1 only. + Box::new(m20260629_000038_create_verified_balance::Migration), + // VHP-1859 review C3: Mode-B FX-revaluation completion marker + // (ledger_fx_revaluation_run), appended at the Vec end so the + // down-magic counts in postgres_migration_idempotency.rs shift by +1. + Box::new(m20260629_000039_create_fx_revaluation_run::Migration), + // VHP-1986 per-tenant FX revaluation-mode table, appended at the Vec + // end so the down-magic counts in postgres_migration_idempotency.rs + // shift by +1 (43 → 44 applied; before-payment / before-queue downs +1). + Box::new(m20260630_000040_create_fx_revaluation_mode::Migration), + // Defense-in-depth: DB trigger enforcing currency-scale immutability + // once a posting exists (design §3.7). Appended at the Vec end so the + // down-magic counts in postgres_migration_idempotency.rs shift by +1. + Box::new(m20260706_000041_currency_scale_immutable::Migration), + ] + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000001_create_bss_schema.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000001_create_bss_schema.rs new file mode 100644 index 000000000..a71c1bfbb --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000001_create_bss_schema.rs @@ -0,0 +1,46 @@ +//! Create the `bss` Postgres schema. Postgres-only: `SQLite` has a single +//! namespace, so the `SQLite` branch is a no-op and later table migrations +//! create unqualified tables that resolve into `bss` via `search_path`. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + match backend { + sea_orm::DatabaseBackend::Postgres => { + conn.execute(Statement::from_string( + backend, + "CREATE SCHEMA IF NOT EXISTS bss".to_owned(), + )) + .await?; + } + sea_orm::DatabaseBackend::Sqlite => { /* single namespace; no-op */ } + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + if backend == sea_orm::DatabaseBackend::Postgres { + conn.execute(Statement::from_string( + backend, + "DROP SCHEMA IF EXISTS bss CASCADE".to_owned(), + )) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000002_create_journal_tables.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000002_create_journal_tables.rs new file mode 100644 index 000000000..9a78775e6 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000002_create_journal_tables.rs @@ -0,0 +1,358 @@ +//! Create the append-only truth tables `journal_entry` and `journal_line` +//! in schema `bss`. Postgres carries the append-only REVOKE-equivalent +//! reject-mutation triggers and a DEFERRABLE balanced/single-payer/ +//! single-currency constraint trigger; `SQLite` (non-production test +//! backend) omits all triggers and PL/pgSQL — those invariants are +//! re-asserted in application code in a later phase (P3). Every CHECK, +//! index, PK, and FK is preserved on both backends. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +pub(crate) const PG_UP_STATEMENTS: &[&str] = &[ + // --- journal_entry (append-only truth header) --- + "CREATE TABLE bss.ledger_journal_entry ( + entry_id uuid NOT NULL, + tenant_id uuid NOT NULL, + legal_entity_id uuid NOT NULL, + period_id varchar(6) NOT NULL, + entry_currency varchar(16) NOT NULL, + source_doc_type text NOT NULL, + source_business_id varchar(256) NOT NULL, + reverses_entry_id uuid, + reverses_period_id varchar(6), + posted_at_utc timestamptz NOT NULL DEFAULT now(), + effective_at date NOT NULL, + origin text NOT NULL CHECK (origin IN ('SYSTEM','USER')), + posted_by_actor_id uuid NOT NULL, + correlation_id uuid NOT NULL, + rounding_evidence jsonb NOT NULL DEFAULT '{}'::jsonb, + created_seq bigserial NOT NULL, + row_hash bytea, + prev_hash bytea, + PRIMARY KEY (tenant_id, period_id, entry_id), + CONSTRAINT chk_journal_entry_reversal_co_null CHECK ( + (reverses_entry_id IS NULL) = (reverses_period_id IS NULL)) + )", + "ALTER TABLE bss.ledger_journal_entry + ADD CONSTRAINT chk_journal_entry_source_doc_type CHECK (source_doc_type IN ( + 'INVOICE_POST','REVERSAL','MAPPING_CORRECTION','PAYMENT_SETTLE','PAYMENT_ALLOCATE', + 'CHARGEBACK','CREDIT_APPLY','SETTLEMENT_RETURN','MANUAL_ADJUSTMENT','SCHEDULE_BUILD', + 'RECOGNITION','CREDIT_NOTE','DEBIT_NOTE','REFUND','FX_REVALUATION','FX_REVAL_REVERSAL'))", + "CREATE UNIQUE INDEX uq_journal_entry_reversal + ON bss.ledger_journal_entry (tenant_id, reverses_period_id, reverses_entry_id) + WHERE reverses_entry_id IS NOT NULL", + "CREATE INDEX idx_journal_entry_source + ON bss.ledger_journal_entry (tenant_id, source_doc_type, source_business_id)", + "CREATE INDEX idx_journal_entry_created_seq + ON bss.ledger_journal_entry (tenant_id, created_seq)", + "CREATE INDEX idx_journal_entry_chain_scan + ON bss.ledger_journal_entry (tenant_id, posted_at_utc, created_seq) WHERE row_hash IS NULL", + // --- journal_line (append-only truth detail; sole source of truth) --- + "CREATE TABLE bss.ledger_journal_line ( + line_id uuid NOT NULL, + entry_id uuid NOT NULL, + tenant_id uuid NOT NULL, + period_id varchar(6) NOT NULL, + payer_tenant_id uuid NOT NULL, + seller_tenant_id uuid, + resource_tenant_id uuid, + account_id uuid NOT NULL, + account_class text NOT NULL, + gl_code varchar(128), + side text NOT NULL CHECK (side IN ('DR','CR')), + amount_minor bigint NOT NULL, + currency varchar(16) NOT NULL, + currency_scale smallint NOT NULL, + invoice_id varchar(128), + due_date date, + revenue_stream text, + mapping_status text NOT NULL CHECK (mapping_status IN ('RESOLVED','PENDING')), + functional_amount_minor bigint, + functional_currency varchar(16), + tax_jurisdiction varchar(128), + tax_filing_period varchar(32), + tax_rate_ref varchar(128), + legal_entity_id uuid, + invoice_item_ref varchar(128), + sku_or_plan_ref varchar(128), + price_id varchar(128), + pricing_snapshot_ref varchar(128), + po_allocation_group varchar(128), + credit_grant_event_type text, + PRIMARY KEY (tenant_id, period_id, line_id), + FOREIGN KEY (tenant_id, period_id, entry_id) + REFERENCES bss.ledger_journal_entry (tenant_id, period_id, entry_id), + CONSTRAINT chk_journal_line_account_class CHECK (account_class IN ( + 'AR','CASH_CLEARING','UNALLOCATED','REUSABLE_CREDIT','CONTRACT_LIABILITY','REVENUE', + 'TAX_PAYABLE','SUSPENSE','DISPUTE_HOLD','REFUND_CLEARING','CONTRA_REVENUE','GOODWILL', + 'DISPUTE_LOSS_EXPENSE','PSP_FEE_EXPENSE','FX_GAIN_LOSS','FX_UNREALIZED')), + CONSTRAINT chk_journal_line_amount CHECK ( + amount_minor > 0 OR (amount_minor = 0 AND functional_amount_minor IS NOT NULL)), + CONSTRAINT chk_journal_line_tax_dims CHECK ( + account_class <> 'TAX_PAYABLE' + OR (tax_jurisdiction IS NOT NULL AND tax_filing_period IS NOT NULL)), + CONSTRAINT chk_journal_line_revenue_stream CHECK ( + account_class NOT IN ('REVENUE','CONTRACT_LIABILITY') OR revenue_stream IS NOT NULL), + CONSTRAINT chk_journal_line_credit_grant CHECK ( + (account_class = 'REUSABLE_CREDIT') = (credit_grant_event_type IS NOT NULL)) + )", + "CREATE INDEX idx_journal_line_account + ON bss.ledger_journal_line (tenant_id, account_id, currency)", + "CREATE INDEX idx_journal_line_ar + ON bss.ledger_journal_line (tenant_id, payer_tenant_id, invoice_id)", + "CREATE INDEX idx_journal_line_item + ON bss.ledger_journal_line (tenant_id, invoice_id, invoice_item_ref)", + "CREATE INDEX idx_journal_line_entry + ON bss.ledger_journal_line (tenant_id, period_id, entry_id)", + // --- append-only enforcement (Postgres-only; REVOKE-equivalent) --- + "CREATE OR REPLACE FUNCTION bss.reject_mutation() RETURNS trigger AS $$ + BEGIN RAISE EXCEPTION 'append-only table: % not permitted', TG_OP; END; + $$ LANGUAGE plpgsql", + "CREATE TRIGGER trg_journal_entry_append_only + BEFORE UPDATE OR DELETE ON bss.ledger_journal_entry + FOR EACH ROW EXECUTE FUNCTION bss.reject_mutation()", + "CREATE TRIGGER trg_journal_line_append_only + BEFORE UPDATE OR DELETE ON bss.ledger_journal_line + FOR EACH ROW EXECUTE FUNCTION bss.reject_mutation()", + // --- deferrable balanced / >=1-line / single-payer / single-currency trigger --- + // A single non-grouped aggregate computes line_count / payer_count / + // currency_mismatch so a ZERO-line entry is detected (a GROUP BY on the + // entry would yield no row and silently pass). Distinct canonical RFC-9457 + // codes are raised so the P3 API layer can map each to its own status. + "CREATE OR REPLACE FUNCTION bss.check_entry_balanced() RETURNS trigger AS $$ + DECLARE + line_count int; + payer_count int; + currency_mismatch int; + unbalanced int; + BEGIN + SELECT count(*), + count(DISTINCT l.payer_tenant_id), + count(*) FILTER (WHERE l.currency <> NEW.entry_currency + AND NOT (l.amount_minor = 0 + AND l.functional_amount_minor IS NOT NULL)) + INTO line_count, payer_count, currency_mismatch + FROM bss.ledger_journal_line l + WHERE (l.tenant_id, l.period_id, l.entry_id) + = (NEW.tenant_id, NEW.period_id, NEW.entry_id); + + IF line_count < 1 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_EMPTY entry=%', NEW.entry_id; + END IF; + IF payer_count > 1 THEN + RAISE EXCEPTION 'MIXED_PAYER_TENANT entry=%', NEW.entry_id; + END IF; + IF currency_mismatch > 0 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_CURRENCY_MISMATCH entry=%', NEW.entry_id; + END IF; + + -- zero-sum per (currency, currency_scale), exact (zero tolerance) + SELECT count(*) INTO unbalanced FROM ( + SELECT 1 + FROM bss.ledger_journal_line l + WHERE (l.tenant_id, l.period_id, l.entry_id) + = (NEW.tenant_id, NEW.period_id, NEW.entry_id) + GROUP BY l.currency, l.currency_scale + HAVING sum(CASE WHEN l.side = 'DR' THEN l.amount_minor + ELSE -l.amount_minor END) <> 0 + ) u; + IF unbalanced > 0 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_UNBALANCED entry=%', NEW.entry_id; + END IF; + + RETURN NULL; + END; + $$ LANGUAGE plpgsql", + "CREATE CONSTRAINT TRIGGER trg_journal_entry_balanced + AFTER INSERT ON bss.ledger_journal_entry + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION bss.check_entry_balanced()", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS bss.ledger_journal_line", + "DROP TABLE IF EXISTS bss.ledger_journal_entry", + "DROP FUNCTION IF EXISTS bss.check_entry_balanced()", + "DROP FUNCTION IF EXISTS bss.reject_mutation()", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- +// +// Systematic transforms from the Postgres variant: +// * schema prefix `bss.` dropped (single namespace); +// * `jsonb` → `text`, JSON default `'{}'::jsonb` → `'{}'`; +// * `timestamptz NOT NULL DEFAULT now()` → `text NOT NULL DEFAULT (CURRENT_TIMESTAMP)`; +// * `bigserial` → `integer` (SQLite ROWID alias auto-increments); +// * `bytea` → `blob`; +// * append-only + balance triggers and the PL/pgSQL functions are +// DROPPED — those invariants are re-asserted in application code (P3). +// Every CHECK, index, PK, and FK is preserved. + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_journal_entry ( + entry_id text NOT NULL, + tenant_id text NOT NULL, + legal_entity_id text NOT NULL, + period_id varchar(6) NOT NULL, + entry_currency varchar(16) NOT NULL, + source_doc_type text NOT NULL, + source_business_id varchar(256) NOT NULL, + reverses_entry_id text, + reverses_period_id varchar(6), + posted_at_utc text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + effective_at date NOT NULL, + origin text NOT NULL CHECK (origin IN ('SYSTEM','USER')), + posted_by_actor_id text NOT NULL, + correlation_id text NOT NULL, + rounding_evidence text NOT NULL DEFAULT '{}', + created_seq integer NOT NULL DEFAULT 0, + row_hash blob, + prev_hash blob, + PRIMARY KEY (tenant_id, period_id, entry_id), + CONSTRAINT chk_journal_entry_source_doc_type CHECK (source_doc_type IN ( + 'INVOICE_POST','REVERSAL','MAPPING_CORRECTION','PAYMENT_SETTLE','PAYMENT_ALLOCATE', + 'CHARGEBACK','CREDIT_APPLY','SETTLEMENT_RETURN','MANUAL_ADJUSTMENT','SCHEDULE_BUILD', + 'RECOGNITION','CREDIT_NOTE','DEBIT_NOTE','REFUND','FX_REVALUATION','FX_REVAL_REVERSAL')), + CONSTRAINT chk_journal_entry_reversal_co_null CHECK ( + (reverses_entry_id IS NULL) = (reverses_period_id IS NULL)) + )", + "CREATE UNIQUE INDEX uq_journal_entry_reversal + ON ledger_journal_entry (tenant_id, reverses_period_id, reverses_entry_id) + WHERE reverses_entry_id IS NOT NULL", + "CREATE INDEX idx_journal_entry_source + ON ledger_journal_entry (tenant_id, source_doc_type, source_business_id)", + "CREATE INDEX idx_journal_entry_created_seq + ON ledger_journal_entry (tenant_id, created_seq)", + "CREATE INDEX idx_journal_entry_chain_scan + ON ledger_journal_entry (tenant_id, posted_at_utc, created_seq) WHERE row_hash IS NULL", + "CREATE TABLE ledger_journal_line ( + line_id text NOT NULL, + entry_id text NOT NULL, + tenant_id text NOT NULL, + period_id varchar(6) NOT NULL, + payer_tenant_id text NOT NULL, + seller_tenant_id text, + resource_tenant_id text, + account_id text NOT NULL, + account_class text NOT NULL, + gl_code varchar(128), + side text NOT NULL CHECK (side IN ('DR','CR')), + amount_minor bigint NOT NULL, + currency varchar(16) NOT NULL, + currency_scale smallint NOT NULL, + invoice_id varchar(128), + due_date date, + revenue_stream text, + mapping_status text NOT NULL CHECK (mapping_status IN ('RESOLVED','PENDING')), + functional_amount_minor bigint, + functional_currency varchar(16), + tax_jurisdiction varchar(128), + tax_filing_period varchar(32), + tax_rate_ref varchar(128), + legal_entity_id text, + invoice_item_ref varchar(128), + sku_or_plan_ref varchar(128), + price_id varchar(128), + pricing_snapshot_ref varchar(128), + po_allocation_group varchar(128), + credit_grant_event_type text, + PRIMARY KEY (tenant_id, period_id, line_id), + FOREIGN KEY (tenant_id, period_id, entry_id) + REFERENCES ledger_journal_entry (tenant_id, period_id, entry_id), + CONSTRAINT chk_journal_line_account_class CHECK (account_class IN ( + 'AR','CASH_CLEARING','UNALLOCATED','REUSABLE_CREDIT','CONTRACT_LIABILITY','REVENUE', + 'TAX_PAYABLE','SUSPENSE','DISPUTE_HOLD','REFUND_CLEARING','CONTRA_REVENUE','GOODWILL', + 'DISPUTE_LOSS_EXPENSE','PSP_FEE_EXPENSE','FX_GAIN_LOSS','FX_UNREALIZED')), + CONSTRAINT chk_journal_line_amount CHECK ( + amount_minor > 0 OR (amount_minor = 0 AND functional_amount_minor IS NOT NULL)), + CONSTRAINT chk_journal_line_tax_dims CHECK ( + account_class <> 'TAX_PAYABLE' + OR (tax_jurisdiction IS NOT NULL AND tax_filing_period IS NOT NULL)), + CONSTRAINT chk_journal_line_revenue_stream CHECK ( + account_class NOT IN ('REVENUE','CONTRACT_LIABILITY') OR revenue_stream IS NOT NULL), + CONSTRAINT chk_journal_line_credit_grant CHECK ( + (account_class = 'REUSABLE_CREDIT') = (credit_grant_event_type IS NOT NULL)) + )", + "CREATE INDEX idx_journal_line_account + ON ledger_journal_line (tenant_id, account_id, currency)", + "CREATE INDEX idx_journal_line_ar + ON ledger_journal_line (tenant_id, payer_tenant_id, invoice_id)", + "CREATE INDEX idx_journal_line_item + ON ledger_journal_line (tenant_id, invoice_id, invoice_item_ref)", + "CREATE INDEX idx_journal_line_entry + ON ledger_journal_line (tenant_id, period_id, entry_id)", + // SQLite has no `bigserial`: emulate the monotonic, positive + // `created_seq` sequence from the row's implicit `rowid` so reads + // round-trip a DB-generated value (Postgres uses `bigserial`). + "CREATE TRIGGER trg_journal_entry_created_seq + AFTER INSERT ON ledger_journal_entry + FOR EACH ROW WHEN NEW.created_seq = 0 + BEGIN + UPDATE ledger_journal_entry SET created_seq = NEW.rowid + WHERE rowid = NEW.rowid; + END", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS ledger_journal_line", + "DROP TABLE IF EXISTS ledger_journal_entry", +]; + +#[cfg(test)] +#[path = "m20260619_000002_create_journal_tables_tests.rs"] +mod check_drift_tests; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000002_create_journal_tables_tests.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000002_create_journal_tables_tests.rs new file mode 100644 index 000000000..44377b85b --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000002_create_journal_tables_tests.rs @@ -0,0 +1,55 @@ +//! Anti-drift: the `account_class` / `source_doc_type` literal sets are +//! maintained independently in the SDK enums (`AccountClass::ALL` / +//! `SourceDocType::ALL`) and in this migration's raw-SQL `CHECK` constraints. +//! A variant added to one side but not the other would let the DB reject a +//! "valid" enum value or accept an unlisted one. These tests pin the two +//! sets equal by parsing the literals straight out of the migration SQL. + +#![allow(clippy::panic, clippy::expect_used)] + +use bss_ledger_sdk::{AccountClass, SourceDocType}; + +use super::PG_UP_STATEMENTS; + +/// Extract the single-quoted literals from the first ` IN ( … )` clause +/// across the Postgres up-statements (the `CHECK` constraint for `col`). +fn check_literals(col: &str) -> Vec { + let key = format!("{col} IN ("); + let stmt = PG_UP_STATEMENTS + .iter() + .find(|s| s.contains(&key)) + .unwrap_or_else(|| panic!("no PG up-statement defines a CHECK on `{col}`")); + let start = stmt.find(&key).expect("key present") + key.len(); + let rest = &stmt[start..]; + let end = rest.find(')').expect("CHECK IN list must be closed"); + let mut literals: Vec = rest[..end] + .split(',') + .map(|t| t.trim().trim_matches('\'').to_owned()) + .collect(); + literals.sort(); + literals +} + +fn sorted(all: &[&str]) -> Vec { + let mut v: Vec = all.iter().map(|s| (*s).to_owned()).collect(); + v.sort(); + v +} + +#[test] +fn account_class_enum_matches_migration_check() { + assert_eq!( + check_literals("account_class"), + sorted(AccountClass::ALL), + "AccountClass::ALL drifted from chk_journal_line_account_class" + ); +} + +#[test] +fn source_doc_type_enum_matches_migration_check() { + assert_eq!( + check_literals("source_doc_type"), + sorted(SourceDocType::ALL), + "SourceDocType::ALL drifted from chk_journal_entry_source_doc_type" + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000003_create_balance_caches.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000003_create_balance_caches.rs new file mode 100644 index 000000000..6b9c8d959 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000003_create_balance_caches.rs @@ -0,0 +1,290 @@ +//! Create the six derived balance-cache tables in schema `bss`. These are +//! materialized projections of the journal truth; each carries a grain PK, +//! a `version`/`last_entry_seq` for optimistic concurrency, and a +//! conditional no-negative CHECK where the account class forbids negatives. +//! `SQLite` mirrors the same shape with the systematic transforms. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_account_balance ( + tenant_id uuid NOT NULL, + account_id uuid NOT NULL, + currency varchar(16) NOT NULL, + account_class text NOT NULL, + normal_side text NOT NULL CHECK (normal_side IN ('DR','CR')), + balance_minor bigint NOT NULL DEFAULT 0, + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, account_id, currency), + CONSTRAINT chk_account_balance_no_negative CHECK ( + account_class NOT IN + ('AR','CASH_CLEARING','UNALLOCATED','CONTRACT_LIABILITY','DISPUTE_HOLD','REFUND_CLEARING') + OR balance_minor >= 0) + )", + "CREATE TABLE bss.ledger_ar_invoice_balance ( + tenant_id uuid NOT NULL, + payer_tenant_id uuid NOT NULL, + account_id uuid NOT NULL, + invoice_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + balance_minor bigint NOT NULL DEFAULT 0, + original_posted_at timestamptz, + due_date date, + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payer_tenant_id, account_id, invoice_id), + CONSTRAINT chk_ar_invoice_balance_no_negative CHECK (balance_minor >= 0) + )", + "CREATE TABLE bss.ledger_ar_payer_balance ( + tenant_id uuid NOT NULL, + payer_tenant_id uuid NOT NULL, + account_id uuid NOT NULL, + currency varchar(16) NOT NULL, + balance_minor bigint NOT NULL DEFAULT 0, + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payer_tenant_id, account_id, currency), + CONSTRAINT chk_ar_payer_balance_no_negative CHECK (balance_minor >= 0) + )", + "CREATE TABLE bss.ledger_tax_subbalance ( + tenant_id uuid NOT NULL, + account_id uuid NOT NULL, + tax_jurisdiction varchar(128) NOT NULL, + tax_filing_period varchar(32) NOT NULL, + balance_minor bigint NOT NULL DEFAULT 0, + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, account_id, tax_jurisdiction, tax_filing_period) + )", + "CREATE TABLE bss.ledger_unallocated_balance ( + tenant_id uuid NOT NULL, + payer_tenant_id uuid NOT NULL, + account_id uuid NOT NULL, + currency varchar(16) NOT NULL, + balance_minor bigint NOT NULL DEFAULT 0, + functional_balance_minor bigint, + functional_currency varchar(16), + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payer_tenant_id, currency), + CONSTRAINT chk_unallocated_balance_no_negative CHECK (balance_minor >= 0) + )", + "CREATE TABLE bss.ledger_reusable_credit_subbalance ( + tenant_id uuid NOT NULL, + payer_tenant_id uuid NOT NULL, + account_id uuid NOT NULL, + currency varchar(16) NOT NULL, + credit_grant_event_type text NOT NULL, + first_granted_at timestamptz, + balance_minor bigint NOT NULL DEFAULT 0, + functional_balance_minor bigint, + functional_currency varchar(16), + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payer_tenant_id, currency, credit_grant_event_type), + CONSTRAINT chk_reusable_credit_subbalance_no_negative CHECK (balance_minor >= 0) + )", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS bss.ledger_reusable_credit_subbalance", + "DROP TABLE IF EXISTS bss.ledger_unallocated_balance", + "DROP TABLE IF EXISTS bss.ledger_tax_subbalance", + "DROP TABLE IF EXISTS bss.ledger_ar_payer_balance", + "DROP TABLE IF EXISTS bss.ledger_ar_invoice_balance", + "DROP TABLE IF EXISTS bss.ledger_account_balance", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; all CHECKs + PKs preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_account_balance ( + tenant_id text NOT NULL, + account_id text NOT NULL, + currency varchar(16) NOT NULL, + account_class text NOT NULL, + normal_side text NOT NULL CHECK (normal_side IN ('DR','CR')), + balance_minor bigint NOT NULL DEFAULT 0, + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, account_id, currency), + CONSTRAINT chk_account_balance_no_negative CHECK ( + account_class NOT IN + ('AR','CASH_CLEARING','UNALLOCATED','CONTRACT_LIABILITY','DISPUTE_HOLD','REFUND_CLEARING') + OR balance_minor >= 0) + )", + "CREATE TABLE ledger_ar_invoice_balance ( + tenant_id text NOT NULL, + payer_tenant_id text NOT NULL, + account_id text NOT NULL, + invoice_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + balance_minor bigint NOT NULL DEFAULT 0, + original_posted_at text, + due_date date, + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payer_tenant_id, account_id, invoice_id), + CONSTRAINT chk_ar_invoice_balance_no_negative CHECK (balance_minor >= 0) + )", + "CREATE TABLE ledger_ar_payer_balance ( + tenant_id text NOT NULL, + payer_tenant_id text NOT NULL, + account_id text NOT NULL, + currency varchar(16) NOT NULL, + balance_minor bigint NOT NULL DEFAULT 0, + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payer_tenant_id, account_id, currency), + CONSTRAINT chk_ar_payer_balance_no_negative CHECK (balance_minor >= 0) + )", + "CREATE TABLE ledger_tax_subbalance ( + tenant_id text NOT NULL, + account_id text NOT NULL, + tax_jurisdiction varchar(128) NOT NULL, + tax_filing_period varchar(32) NOT NULL, + balance_minor bigint NOT NULL DEFAULT 0, + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, account_id, tax_jurisdiction, tax_filing_period) + )", + "CREATE TABLE ledger_unallocated_balance ( + tenant_id text NOT NULL, + payer_tenant_id text NOT NULL, + account_id text NOT NULL, + currency varchar(16) NOT NULL, + balance_minor bigint NOT NULL DEFAULT 0, + functional_balance_minor bigint, + functional_currency varchar(16), + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payer_tenant_id, currency), + CONSTRAINT chk_unallocated_balance_no_negative CHECK (balance_minor >= 0) + )", + "CREATE TABLE ledger_reusable_credit_subbalance ( + tenant_id text NOT NULL, + payer_tenant_id text NOT NULL, + account_id text NOT NULL, + currency varchar(16) NOT NULL, + credit_grant_event_type text NOT NULL, + first_granted_at text, + balance_minor bigint NOT NULL DEFAULT 0, + functional_balance_minor bigint, + functional_currency varchar(16), + last_entry_seq bigint, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payer_tenant_id, currency, credit_grant_event_type), + CONSTRAINT chk_reusable_credit_subbalance_no_negative CHECK (balance_minor >= 0) + )", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS ledger_reusable_credit_subbalance", + "DROP TABLE IF EXISTS ledger_unallocated_balance", + "DROP TABLE IF EXISTS ledger_tax_subbalance", + "DROP TABLE IF EXISTS ledger_ar_payer_balance", + "DROP TABLE IF EXISTS ledger_ar_invoice_balance", + "DROP TABLE IF EXISTS ledger_account_balance", +]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::panic)] + + use bss_ledger_sdk::AccountClass; + + use super::PG_UP_STATEMENTS; + + /// Parse the single-quoted literals from the `account_class NOT IN ( … )` + /// clause of the `account_balance` no-negative CHECK. + fn guarded_check_literals() -> Vec { + let key = "account_class NOT IN"; + let stmt = PG_UP_STATEMENTS + .iter() + .find(|s| s.contains(key)) + .expect("account_balance up-statement defines the no-negative CHECK"); + let after = &stmt[stmt.find(key).expect("key present") + key.len()..]; + let open = after.find('(').expect("NOT IN list opens"); + let close = after.find(')').expect("NOT IN list closes"); + let mut v: Vec = after[open + 1..close] + .split(',') + .map(|t| t.trim().trim_matches('\'').to_owned()) + .collect(); + v.sort(); + v + } + + /// Anti-drift: the no-negative CHECK's guarded-class list must equal the + /// single source of truth `AccountClass::GUARDED` (the bug this pins: + /// the tie-out backstop once duplicated this set and inverted it). + #[test] + fn no_negative_check_matches_guarded_set() { + let mut expected: Vec = AccountClass::GUARDED + .iter() + .map(|c| c.as_str().to_owned()) + .collect(); + expected.sort(); + assert_eq!( + guarded_check_literals(), + expected, + "chk_account_balance_no_negative drifted from AccountClass::GUARDED" + ); + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000004_create_idempotency_and_reference.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000004_create_idempotency_and_reference.rs new file mode 100644 index 000000000..b83fac1ba --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000004_create_idempotency_and_reference.rs @@ -0,0 +1,208 @@ +//! Create the idempotency-dedup table and the reference/control tables +//! (chart of accounts, fiscal periods, currency-scale registry, posting +//! locks, payer state) in schema `bss`. `SQLite` mirrors the same shape with +//! the systematic transforms; every CHECK, PK, and unique index is kept. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_idempotency_dedup ( + tenant_id uuid NOT NULL, + flow text NOT NULL, + business_id varchar(256) NOT NULL, + payload_hash varchar(128) NOT NULL, + result_entry_id uuid, + posted_at_utc timestamptz, + status text NOT NULL, + retain_until timestamptz, + PRIMARY KEY (tenant_id, flow, business_id) + )", + "CREATE TABLE bss.ledger_tenant_account ( + account_id uuid NOT NULL, + tenant_id uuid NOT NULL, + legal_entity_id uuid NOT NULL, + account_class text NOT NULL, + currency varchar(16) NOT NULL, + revenue_stream text, + normal_side text NOT NULL CHECK (normal_side IN ('DR','CR')), + may_go_negative boolean NOT NULL DEFAULT false, + lifecycle_state text NOT NULL DEFAULT 'OPEN' CHECK (lifecycle_state IN ('OPEN','CLOSED')), + PRIMARY KEY (account_id) + )", + "CREATE UNIQUE INDEX uq_tenant_account_coa + ON bss.ledger_tenant_account (tenant_id, legal_entity_id, account_class, currency, COALESCE(revenue_stream,'-'))", + "CREATE TABLE bss.ledger_fiscal_period ( + tenant_id uuid NOT NULL, + legal_entity_id uuid NOT NULL, + period_id varchar(6) NOT NULL, + fiscal_tz varchar(64) NOT NULL, + status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN','CLOSED')), + PRIMARY KEY (tenant_id, legal_entity_id, period_id) + )", + "CREATE TABLE bss.ledger_currency_scale_registry ( + tenant_id uuid NOT NULL, + currency varchar(16) NOT NULL, + minor_units smallint NOT NULL, + plausible_max_major bigint NOT NULL DEFAULT 1000000000000 CHECK (plausible_max_major > 0), + source text NOT NULL, + PRIMARY KEY (tenant_id, currency) + )", + "CREATE TABLE bss.ledger_tenant_posting_lock ( + tenant_id uuid NOT NULL, + locked boolean NOT NULL DEFAULT false, + reason_code text, + set_by uuid, + set_at timestamptz, + cleared_by uuid, + cleared_at timestamptz, + PRIMARY KEY (tenant_id) + )", + "CREATE TABLE bss.ledger_payer_state ( + tenant_id uuid NOT NULL, + payer_tenant_id uuid NOT NULL, + lifecycle_state text NOT NULL DEFAULT 'OPEN' CHECK (lifecycle_state IN ('OPEN','CLOSED')), + closed_with_open_balance boolean NOT NULL DEFAULT false, + approved_by uuid, + changed_at timestamptz, + PRIMARY KEY (tenant_id, payer_tenant_id) + )", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS bss.ledger_payer_state", + "DROP TABLE IF EXISTS bss.ledger_tenant_posting_lock", + "DROP TABLE IF EXISTS bss.ledger_currency_scale_registry", + "DROP TABLE IF EXISTS bss.ledger_fiscal_period", + "DROP TABLE IF EXISTS bss.ledger_tenant_account", + "DROP TABLE IF EXISTS bss.ledger_idempotency_dedup", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; all CHECKs + PKs + unique index preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_idempotency_dedup ( + tenant_id text NOT NULL, + flow text NOT NULL, + business_id varchar(256) NOT NULL, + payload_hash varchar(128) NOT NULL, + result_entry_id text, + posted_at_utc text, + status text NOT NULL, + retain_until text, + PRIMARY KEY (tenant_id, flow, business_id) + )", + "CREATE TABLE ledger_tenant_account ( + account_id text NOT NULL, + tenant_id text NOT NULL, + legal_entity_id text NOT NULL, + account_class text NOT NULL, + currency varchar(16) NOT NULL, + revenue_stream text, + normal_side text NOT NULL CHECK (normal_side IN ('DR','CR')), + may_go_negative boolean NOT NULL DEFAULT false, + lifecycle_state text NOT NULL DEFAULT 'OPEN' CHECK (lifecycle_state IN ('OPEN','CLOSED')), + PRIMARY KEY (account_id) + )", + "CREATE UNIQUE INDEX uq_tenant_account_coa + ON ledger_tenant_account (tenant_id, legal_entity_id, account_class, currency, COALESCE(revenue_stream,'-'))", + "CREATE TABLE ledger_fiscal_period ( + tenant_id text NOT NULL, + legal_entity_id text NOT NULL, + period_id varchar(6) NOT NULL, + fiscal_tz varchar(64) NOT NULL, + status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN','CLOSED')), + PRIMARY KEY (tenant_id, legal_entity_id, period_id) + )", + "CREATE TABLE ledger_currency_scale_registry ( + tenant_id text NOT NULL, + currency varchar(16) NOT NULL, + minor_units smallint NOT NULL, + plausible_max_major bigint NOT NULL DEFAULT 1000000000000 CHECK (plausible_max_major > 0), + source text NOT NULL, + PRIMARY KEY (tenant_id, currency) + )", + "CREATE TABLE ledger_tenant_posting_lock ( + tenant_id text NOT NULL, + locked boolean NOT NULL DEFAULT false, + reason_code text, + set_by text, + set_at text, + cleared_by text, + cleared_at text, + PRIMARY KEY (tenant_id) + )", + "CREATE TABLE ledger_payer_state ( + tenant_id text NOT NULL, + payer_tenant_id text NOT NULL, + lifecycle_state text NOT NULL DEFAULT 'OPEN' CHECK (lifecycle_state IN ('OPEN','CLOSED')), + closed_with_open_balance boolean NOT NULL DEFAULT false, + approved_by text, + changed_at text, + PRIMARY KEY (tenant_id, payer_tenant_id) + )", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS ledger_payer_state", + "DROP TABLE IF EXISTS ledger_tenant_posting_lock", + "DROP TABLE IF EXISTS ledger_currency_scale_registry", + "DROP TABLE IF EXISTS ledger_fiscal_period", + "DROP TABLE IF EXISTS ledger_tenant_account", + "DROP TABLE IF EXISTS ledger_idempotency_dedup", +]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000005_create_fiscal_calendar.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000005_create_fiscal_calendar.rs new file mode 100644 index 000000000..7a671e507 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260619_000005_create_fiscal_calendar.rs @@ -0,0 +1,86 @@ +//! Create the `fiscal_calendar` reference table (per-legal-entity calendar +//! config: timezone, granularity, FY-start month) in schema `bss`. `SQLite` +//! mirrors the same shape with the systematic transforms; every CHECK and PK +//! is kept. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &["CREATE TABLE bss.ledger_fiscal_calendar ( + tenant_id uuid NOT NULL, + legal_entity_id uuid NOT NULL, + fiscal_tz varchar(64) NOT NULL, + granularity text NOT NULL CHECK (granularity IN ('MONTH')), + fy_start_month smallint NOT NULL CHECK (fy_start_month BETWEEN 1 AND 12), + PRIMARY KEY (tenant_id, legal_entity_id) + )"]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_fiscal_calendar"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`; +// all CHECKs + PK preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE ledger_fiscal_calendar ( + tenant_id text NOT NULL, + legal_entity_id text NOT NULL, + fiscal_tz varchar(64) NOT NULL, + granularity text NOT NULL CHECK (granularity IN ('MONTH')), + fy_start_month smallint NOT NULL CHECK (fy_start_month BETWEEN 1 AND 12), + PRIMARY KEY (tenant_id, legal_entity_id) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_fiscal_calendar"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260622_000006_create_payment_tables.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260622_000006_create_payment_tables.rs new file mode 100644 index 000000000..d15fe8331 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260622_000006_create_payment_tables.rs @@ -0,0 +1,193 @@ +//! Create the three payment counter tables in schema `bss`: +//! `payment_settlement` (the per-payment money-out serialization point — +//! `settled`/`fee`/`allocated`/`refunded`/`refunded_unallocated`/`clawed_back` +//! minor-unit counters guarded by cap CHECKs), `payment_allocation` (one +//! row per `(payment, invoice)` split + two read indexes), and +//! `payment_allocation_refund` (per-`(payment, invoice)` allocated/refunded +//! counter for Slice 3's refund cap). All CHECKs are created in final form +//! up-front (Foundation §7.2). `SQLite` mirrors the same shape with the +//! systematic transforms (`uuid`→`text`, `timestamptz`→`text`). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_payment_settlement ( + tenant_id uuid NOT NULL, + payment_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + settled_minor bigint NOT NULL DEFAULT 0, + fee_minor bigint NOT NULL DEFAULT 0, + allocated_minor bigint NOT NULL DEFAULT 0, + refunded_minor bigint NOT NULL DEFAULT 0, + refunded_unallocated_minor bigint NOT NULL DEFAULT 0, + clawed_back_minor bigint NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payment_id), + CONSTRAINT chk_payment_settlement_alloc_le_settled + CHECK (allocated_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_alloc_refu_le_settled + CHECK (allocated_minor + refunded_unallocated_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_fee_le_settled + CHECK (fee_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_moneyout_le_settled + CHECK (refunded_minor + clawed_back_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_refunded_le_settled + CHECK (refunded_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_nonneg CHECK ( + settled_minor >= 0 AND fee_minor >= 0 AND allocated_minor >= 0 + AND refunded_minor >= 0 AND refunded_unallocated_minor >= 0 + AND clawed_back_minor >= 0) + )", + "CREATE TABLE bss.ledger_payment_allocation ( + tenant_id uuid NOT NULL, + allocation_id uuid NOT NULL, + payer_tenant_id uuid NOT NULL, + payment_id varchar(128) NOT NULL, + invoice_id varchar(128) NOT NULL, + amount_minor bigint NOT NULL, + currency varchar(16) NOT NULL, + precedence_policy_ref varchar(128) NOT NULL, + allocated_at_utc timestamptz NOT NULL, + PRIMARY KEY (tenant_id, allocation_id, invoice_id), + CONSTRAINT chk_payment_allocation_amount_pos CHECK (amount_minor > 0) + )", + "CREATE INDEX ix_payment_allocation_payment ON bss.ledger_payment_allocation (tenant_id, payment_id)", + "CREATE INDEX ix_payment_allocation_invoice ON bss.ledger_payment_allocation (tenant_id, invoice_id)", + "CREATE TABLE bss.ledger_payment_allocation_refund ( + tenant_id uuid NOT NULL, + payment_id varchar(128) NOT NULL, + invoice_id varchar(128) NOT NULL, + allocated_minor bigint NOT NULL DEFAULT 0, + refunded_minor bigint NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payment_id, invoice_id), + CONSTRAINT chk_par_refunded_le_allocated CHECK (refunded_minor <= allocated_minor), + CONSTRAINT chk_par_nonneg CHECK (allocated_minor >= 0 AND refunded_minor >= 0) + )", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS bss.ledger_payment_allocation_refund", + "DROP TABLE IF EXISTS bss.ledger_payment_allocation", + "DROP TABLE IF EXISTS bss.ledger_payment_settlement", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; all CHECKs + PKs + indexes preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_payment_settlement ( + tenant_id text NOT NULL, + payment_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + settled_minor bigint NOT NULL DEFAULT 0, + fee_minor bigint NOT NULL DEFAULT 0, + allocated_minor bigint NOT NULL DEFAULT 0, + refunded_minor bigint NOT NULL DEFAULT 0, + refunded_unallocated_minor bigint NOT NULL DEFAULT 0, + clawed_back_minor bigint NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payment_id), + CONSTRAINT chk_payment_settlement_alloc_le_settled + CHECK (allocated_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_alloc_refu_le_settled + CHECK (allocated_minor + refunded_unallocated_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_fee_le_settled + CHECK (fee_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_moneyout_le_settled + CHECK (refunded_minor + clawed_back_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_refunded_le_settled + CHECK (refunded_minor <= settled_minor), + CONSTRAINT chk_payment_settlement_nonneg CHECK ( + settled_minor >= 0 AND fee_minor >= 0 AND allocated_minor >= 0 + AND refunded_minor >= 0 AND refunded_unallocated_minor >= 0 + AND clawed_back_minor >= 0) + )", + "CREATE TABLE ledger_payment_allocation ( + tenant_id text NOT NULL, + allocation_id text NOT NULL, + payer_tenant_id text NOT NULL, + payment_id varchar(128) NOT NULL, + invoice_id varchar(128) NOT NULL, + amount_minor bigint NOT NULL, + currency varchar(16) NOT NULL, + precedence_policy_ref varchar(128) NOT NULL, + allocated_at_utc text NOT NULL, + PRIMARY KEY (tenant_id, allocation_id, invoice_id), + CONSTRAINT chk_payment_allocation_amount_pos CHECK (amount_minor > 0) + )", + "CREATE INDEX ix_payment_allocation_payment ON ledger_payment_allocation (tenant_id, payment_id)", + "CREATE INDEX ix_payment_allocation_invoice ON ledger_payment_allocation (tenant_id, invoice_id)", + "CREATE TABLE ledger_payment_allocation_refund ( + tenant_id text NOT NULL, + payment_id varchar(128) NOT NULL, + invoice_id varchar(128) NOT NULL, + allocated_minor bigint NOT NULL DEFAULT 0, + refunded_minor bigint NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, payment_id, invoice_id), + CONSTRAINT chk_par_refunded_le_allocated CHECK (refunded_minor <= allocated_minor), + CONSTRAINT chk_par_nonneg CHECK (allocated_minor >= 0 AND refunded_minor >= 0) + )", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS ledger_payment_allocation_refund", + "DROP TABLE IF EXISTS ledger_payment_allocation", + "DROP TABLE IF EXISTS ledger_payment_settlement", +]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000007_create_precedence_policy.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000007_create_precedence_policy.rs new file mode 100644 index 000000000..c9d017908 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000007_create_precedence_policy.rs @@ -0,0 +1,101 @@ +//! Create the effective-dated precedence-policy table in schema `bss`: +//! `tenant_precedence_policy` (per-tenant, append-only `version`ed rows that +//! pin an allocation precedence `strategy` from an `effective_from` instant +//! onward). The allocator resolves the row in effect at decision time (latest +//! `effective_from <= now`, highest `version` on a tie) and stamps its +//! `strategy#version` onto the allocation's audit trail; absent a row it falls +//! back to oldest-first. Tenant scoping is via `SecureORM` at query time (no PG +//! RLS policy block — same as the payment tables). `SQLite` mirrors the same +//! shape with the systematic transforms (`uuid`→`text`, `timestamptz`→`text`). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_tenant_precedence_policy ( + tenant_id uuid NOT NULL, + version bigint NOT NULL, + effective_from timestamptz NOT NULL, + strategy varchar(64) NOT NULL, + created_at_utc timestamptz NOT NULL, + PRIMARY KEY (tenant_id, version), + CONSTRAINT chk_precedence_policy_version_nonneg CHECK (version >= 0) + )", + "CREATE INDEX ix_precedence_policy_effective + ON bss.ledger_tenant_precedence_policy (tenant_id, effective_from)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_tenant_precedence_policy"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; the CHECK + PK + index preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_tenant_precedence_policy ( + tenant_id text NOT NULL, + version bigint NOT NULL, + effective_from text NOT NULL, + strategy varchar(64) NOT NULL, + created_at_utc text NOT NULL, + PRIMARY KEY (tenant_id, version), + CONSTRAINT chk_precedence_policy_version_nonneg CHECK (version >= 0) + )", + "CREATE INDEX ix_precedence_policy_effective + ON ledger_tenant_precedence_policy (tenant_id, effective_from)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_tenant_precedence_policy"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000008_create_pending_event_queue.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000008_create_pending_event_queue.rs new file mode 100644 index 000000000..0e592d4d5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000008_create_pending_event_queue.rs @@ -0,0 +1,121 @@ +//! Create the durable pending-event queue table in schema `bss`: +//! `pending_event_queue` (one row per in-flight cross-flow work item, keyed by +//! `(tenant_id, flow, business_id)`). A row is a queued/quarantined unit of +//! deferred ledger work: an event whose financial effect cannot be applied +//! inline (out-of-order arrival, an `apply_after` embargo, or a transient +//! apply failure awaiting retry). Owned by Slice 2 and shared by Slices 2+3 — +//! Slice 2 (intake) durably enqueues the PII-free `payload` in its own +//! transaction; Slice 3 (apply) drains it in a *second*, separate transaction, +//! flipping `status` `QUEUED`→`APPLIED` (or `CANCELLED`) and bumping `attempts` +//! on a retry. The two-transaction intake/apply split keeps the durable queue +//! the system-of-record for deferred work, so a crash between intake and apply +//! never loses (nor double-applies) an event. `payload` is JSON and PII-free by +//! construction — it carries only the financial keys the apply path needs. +//! +//! `apply_after` (nullable) embargoes a row until an instant; the drain index +//! `(tenant_id, flow, status, queued_at)` lets the apply path scan a tenant's +//! oldest still-`QUEUED` rows per flow without a full-table sweep. Tenant +//! scoping is via `SecureORM` at query time (no PG RLS policy block — same as +//! the payment / precedence-policy tables). `SQLite` mirrors the same shape +//! with the systematic transforms (`uuid`→`text`, `timestamptz`→`text`, +//! `jsonb`→`text`); the status CHECK + PK + drain index are preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_pending_event_queue ( + tenant_id uuid NOT NULL, + flow varchar(64) NOT NULL, + business_id varchar(255) NOT NULL, + payload jsonb NOT NULL, + queued_at timestamptz NOT NULL, + apply_after timestamptz, + status varchar(16) NOT NULL + CONSTRAINT chk_pending_event_queue_status + CHECK (status IN ('QUEUED','APPLIED','CANCELLED')), + attempts integer NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, flow, business_id) + )", + "CREATE INDEX ix_pending_event_queue_drain + ON bss.ledger_pending_event_queue (tenant_id, flow, status, queued_at)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_pending_event_queue"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`, `jsonb`→`text`; the CHECK + PK + drain index preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_pending_event_queue ( + tenant_id text NOT NULL, + flow varchar(64) NOT NULL, + business_id varchar(255) NOT NULL, + payload text NOT NULL, + queued_at text NOT NULL, + apply_after text, + status varchar(16) NOT NULL + CONSTRAINT chk_pending_event_queue_status + CHECK (status IN ('QUEUED','APPLIED','CANCELLED')), + attempts integer NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, flow, business_id) + )", + "CREATE INDEX ix_pending_event_queue_drain + ON ledger_pending_event_queue (tenant_id, flow, status, queued_at)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_pending_event_queue"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000009_add_ar_status.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000009_add_ar_status.rs new file mode 100644 index 000000000..a44251ccf --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000009_add_ar_status.rs @@ -0,0 +1,129 @@ +//! Add the `ar_status (ACTIVE|DISPUTED)` foundation seam used by the +//! chargeback `opened` AR-reclass (design §4.5, Group A / D3). Two additive, +//! backward-compatible columns: +//! +//! * `journal_line.ar_status varchar(16)` — a per-line snapshot, set on AR +//! lines that participate in a dispute reclass (`ACTIVE`/`DISPUTED`), `NULL` +//! on every other line. Nullable + a NULL-tolerant CHECK, so all existing +//! (untagged) lines stay valid. `journal_line` is append-only (the P1 +//! reject-mutation trigger fires on UPDATE/DELETE of *rows*); `ALTER TABLE … +//! ADD COLUMN` is DDL, not a row mutation, so the trigger does not block it. +//! * `ledger_ar_invoice_balance.disputed_minor bigint NOT NULL DEFAULT 0` — the +//! disputed sub-portion of the invoice's open AR. `balance_minor` stays the +//! FULL open AR (a reclass nets ZERO on it → AR-class-neutral); the projector +//! routes the disputed delta here instead. Guarded by `>= 0` and +//! `<= balance_minor` CHECKs (its own no-negative / no-over-dispute backstop; +//! matches the existing `chk_ar_invoice_balance_*` naming). +//! +//! The per-invoice `ar_status` flag is DERIVED, not stored: an invoice is +//! `DISPUTED` exactly when `disputed_minor == balance_minor` (with +//! `balance_minor > 0`), computable from the two columns wherever it is needed. +//! Storing it would add a third column the atomic `on_conflict` net-update path +//! cannot maintain without a CASE expression — deriving is the simpler choice +//! given the projector code. +//! +//! `SQLite` mirrors the same shape with the systematic transforms (here only +//! the `bss.` schema prefix is dropped; the column types + CHECKs are identical). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_journal_line + ADD COLUMN ar_status varchar(16) + CONSTRAINT chk_journal_line_ar_status + CHECK (ar_status IS NULL OR ar_status IN ('ACTIVE','DISPUTED'))", + "ALTER TABLE bss.ledger_ar_invoice_balance + ADD COLUMN disputed_minor bigint NOT NULL DEFAULT 0", + "ALTER TABLE bss.ledger_ar_invoice_balance + ADD CONSTRAINT chk_ar_invoice_balance_disputed_no_negative + CHECK (disputed_minor >= 0)", + "ALTER TABLE bss.ledger_ar_invoice_balance + ADD CONSTRAINT chk_ar_invoice_balance_disputed_le_balance + CHECK (disputed_minor <= balance_minor)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_ar_invoice_balance + DROP CONSTRAINT IF EXISTS chk_ar_invoice_balance_disputed_le_balance", + "ALTER TABLE bss.ledger_ar_invoice_balance + DROP CONSTRAINT IF EXISTS chk_ar_invoice_balance_disputed_no_negative", + "ALTER TABLE bss.ledger_ar_invoice_balance DROP COLUMN IF EXISTS disputed_minor", + "ALTER TABLE bss.ledger_journal_line DROP COLUMN IF EXISTS ar_status", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; column types + CHECKs +// preserved). SQLite folds the column-level CHECK into `ADD COLUMN`; the two +// table-level CHECKs on `disputed_minor` are likewise expressed inline on the +// added column (SQLite's `ALTER TABLE ADD COLUMN` cannot add a standalone +// table constraint, but a column CHECK referencing a sibling column is valid). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "ALTER TABLE ledger_journal_line + ADD COLUMN ar_status varchar(16) + CONSTRAINT chk_journal_line_ar_status + CHECK (ar_status IS NULL OR ar_status IN ('ACTIVE','DISPUTED'))", + "ALTER TABLE ledger_ar_invoice_balance + ADD COLUMN disputed_minor bigint NOT NULL DEFAULT 0 + CONSTRAINT chk_ar_invoice_balance_disputed_no_negative + CHECK (disputed_minor >= 0 AND disputed_minor <= balance_minor)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE ledger_ar_invoice_balance DROP COLUMN disputed_minor", + "ALTER TABLE ledger_journal_line DROP COLUMN ar_status", +]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000010_create_dispute.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000010_create_dispute.rs new file mode 100644 index 000000000..054e99c58 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260623_000010_create_dispute.rs @@ -0,0 +1,135 @@ +//! Create the chargeback dispute-state table in schema `bss`: +//! `ledger_dispute` (one row per dispute, keyed by `(tenant_id, dispute_id)`). +//! The PK holds the *current* state; `cycle` increments in place on a re-open +//! (pre-arbitration → arbitration), and per-phase history lives in the journal +//! and `idempotency_dedup` (`dispute_id:cycle:phase`). The `variant` +//! (`CASH_HOLD` | `AR_RECLASS`) is chosen by the LEDGER at `opened` from the +//! request's `funds_at_open` fact and recorded here; the `won`/`lost` outcomes +//! branch on it (design §0 D1 / §1 / §2). +//! +//! Lock order (reconciled with the `PostingService` project→sidecar flow, Slice 2): +//! the balance caches are projected FIRST in the post txn, then the +//! in-txn sidecar writes the counter rows (`ledger_payment_settlement`, +//! `ledger_dispute`, `payment_allocation_refund`) — i.e. the counters are taken +//! AFTER the balance grains, not before. All Slice 2 posts share this one order, +//! so the total lock order stays acyclic; Slice 3 must follow it. +//! +//! All CHECKs are created in final form up-front (Foundation §7.2). `SQLite` +//! mirrors the same shape with the systematic transforms (`uuid`→`text`); the +//! CHECKs + PK + index are preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_dispute ( + tenant_id uuid NOT NULL, + dispute_id varchar(128) NOT NULL, + payment_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + variant varchar(16) NOT NULL, + last_phase varchar(16) NOT NULL, + cycle integer NOT NULL DEFAULT 1, + disputed_amount_minor bigint NOT NULL DEFAULT 0, + cash_hold_minor bigint NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, dispute_id), + CONSTRAINT chk_ledger_dispute_variant + CHECK (variant IN ('CASH_HOLD','AR_RECLASS')), + CONSTRAINT chk_ledger_dispute_last_phase + CHECK (last_phase IN ('OPENED','WON','LOST','PARTIAL')), + CONSTRAINT chk_ledger_dispute_cycle CHECK (cycle >= 1), + CONSTRAINT chk_ledger_dispute_amount_nonneg CHECK (disputed_amount_minor >= 0), + CONSTRAINT chk_ledger_dispute_cash_hold_nonneg CHECK (cash_hold_minor >= 0), + CONSTRAINT chk_ledger_dispute_cash_hold_le_disputed + CHECK (cash_hold_minor <= disputed_amount_minor) + )", + "CREATE INDEX ledger_dispute_payment_idx ON bss.ledger_dispute (tenant_id, payment_id)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_dispute"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`; the +// CHECKs + PK + index preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_dispute ( + tenant_id text NOT NULL, + dispute_id varchar(128) NOT NULL, + payment_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + variant varchar(16) NOT NULL, + last_phase varchar(16) NOT NULL, + cycle integer NOT NULL DEFAULT 1, + disputed_amount_minor bigint NOT NULL DEFAULT 0, + cash_hold_minor bigint NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, dispute_id), + CONSTRAINT chk_ledger_dispute_variant + CHECK (variant IN ('CASH_HOLD','AR_RECLASS')), + CONSTRAINT chk_ledger_dispute_last_phase + CHECK (last_phase IN ('OPENED','WON','LOST','PARTIAL')), + CONSTRAINT chk_ledger_dispute_cycle CHECK (cycle >= 1), + CONSTRAINT chk_ledger_dispute_amount_nonneg CHECK (disputed_amount_minor >= 0), + CONSTRAINT chk_ledger_dispute_cash_hold_nonneg CHECK (cash_hold_minor >= 0), + CONSTRAINT chk_ledger_dispute_cash_hold_le_disputed + CHECK (cash_hold_minor <= disputed_amount_minor) + )", + "CREATE INDEX ledger_dispute_payment_idx ON ledger_dispute (tenant_id, payment_id)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_dispute"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000011_create_chain_state.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000011_create_chain_state.rs new file mode 100644 index 000000000..ef3e63f2c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000011_create_chain_state.rs @@ -0,0 +1,116 @@ +//! Add the tamper-evidence chain pointer columns to `bss.ledger_journal_entry` +//! (`prev_entry_id` / `prev_period_id`) and create the per-tenant +//! `bss.chain_state` tip table (last sealed `row_hash` / entry id / period / +//! sequence). The chain-pointer index carries `period_id` so the tip can be +//! resolved by `(tenant_id, entry_id)` without touching the heap on Postgres; +//! `SQLite` (non-production test backend) omits the `INCLUDE` payload (it is +//! unsupported there) and `bytea` becomes `blob`. Every column/PK is preserved +//! on both backends. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_journal_entry ADD COLUMN prev_entry_id uuid", + "ALTER TABLE bss.ledger_journal_entry ADD COLUMN prev_period_id varchar(6)", + "CREATE INDEX idx_journal_entry_chain_ptr + ON bss.ledger_journal_entry (tenant_id, entry_id) INCLUDE (period_id)", + "CREATE TABLE bss.chain_state ( + tenant_id uuid NOT NULL PRIMARY KEY, + last_row_hash bytea NOT NULL, + last_entry_id uuid NOT NULL, + last_period_id varchar(6) NOT NULL, + last_seq bigint NOT NULL + )", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS bss.chain_state", + "DROP INDEX IF EXISTS bss.idx_journal_entry_chain_ptr", + "ALTER TABLE bss.ledger_journal_entry DROP COLUMN prev_period_id", + "ALTER TABLE bss.ledger_journal_entry DROP COLUMN prev_entry_id", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- +// +// Systematic transforms from the Postgres variant: +// * schema prefix `bss.` dropped (single namespace); +// * `uuid` → `text`; `bytea` → `blob`; `bigint` → `integer`; +// * `INCLUDE (...)` is unsupported, so the chain-pointer index is a plain +// `(tenant_id, entry_id)` index (the covered `period_id` is read from the +// heap on SQLite — acceptable for the test backend). +// Every column and PK is preserved. + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "ALTER TABLE ledger_journal_entry ADD COLUMN prev_entry_id text", + "ALTER TABLE ledger_journal_entry ADD COLUMN prev_period_id varchar(6)", + "CREATE INDEX idx_journal_entry_chain_ptr ON ledger_journal_entry (tenant_id, entry_id)", + "CREATE TABLE chain_state ( + tenant_id text NOT NULL PRIMARY KEY, + last_row_hash blob NOT NULL, + last_entry_id text NOT NULL, + last_period_id varchar(6) NOT NULL, + last_seq integer NOT NULL + )", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS chain_state", + "DROP INDEX IF EXISTS idx_journal_entry_chain_ptr", + "ALTER TABLE ledger_journal_entry DROP COLUMN prev_period_id", + "ALTER TABLE ledger_journal_entry DROP COLUMN prev_entry_id", +]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000011_create_recognition_tables.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000011_create_recognition_tables.rs new file mode 100644 index 000000000..794e81aef --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000011_create_recognition_tables.rs @@ -0,0 +1,245 @@ +//! Create the three ASC 606 revenue-recognition tables in schema `bss` +//! (Slice 4): `ledger_recognition_schedule` (the documented release plan for one +//! single-revenue-stream deferred Contract-liability balance, keyed by +//! `(tenant_id, schedule_id)`), `ledger_recognition_segment` (one time- or +//! milestone-slice of a schedule — the **at-most-once unit**, one per +//! `(schedule, period)`, keyed by `(tenant_id, schedule_id, segment_no)`), and +//! `ledger_recognition_run` (an orchestration wrapper that releases due segments +//! for a period — **not** itself the dedup key, keyed by +//! `(tenant_id, period_id, run_id)`). +//! +//! `recognized_minor <= total_deferred_minor` on the schedule is the +//! **authoritative** in-transaction over-recognition guard (design §7); the +//! `RecognitionRunner` (Phase 2) bumps `recognized_minor` by an in-place delta +//! under the lock order and the CHECK is evaluated post-delta. The partial +//! `UNIQUE (tenant_id, source_invoice_id, source_invoice_item_ref, +//! revenue_stream) WHERE status='ACTIVE'` is the **at-most-one-live** guard (one +//! current schedule per business key); build-idempotency is decoupled from +//! `status` and lives in `idempotency_dedup` (Rev3 / S4-F2), so a terminal +//! `COMPLETED` schedule is archivable without re-opening a duplicate-build hole. +//! The `segment_no` is immutable and 1:1 with `period_id` +//! (`UNIQUE (tenant_id, schedule_id, period_id)`), so the dedup grain and the +//! UNIQUE grain are provably identical (design §4.1 / §7). +//! +//! **Lock order.** A recognition post first locks the `CONTRACT_LIABILITY` + +//! `REVENUE` `account_balance` rows via the Slice 1 projection (the existing +//! balance-grain order), then the stamp sidecar takes `recognition_schedule` +//! (the `recognized_minor` delta) BEFORE `recognition_segment` (the `DONE` +//! stamp) — one consistent order across all recognition posts, enforced +//! PROCEDURALLY by the sidecar's call order. These tables are NOT projector +//! balance grains, so they carry no `GrainTable` rank (the projector ranks stay +//! balance-only; see `grain_lock_order_ranks_are_pinned`). +//! +//! All CHECKs are created in final form up-front (Foundation §7.2). `SQLite` +//! mirrors the same shape with the systematic transforms (`uuid`→`text`, +//! `timestamptz`→`text`); the CHECKs + PKs + indexes (incl. the partial UNIQUE, +//! which both backends support via `CREATE UNIQUE INDEX … WHERE`) are preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_recognition_schedule ( + tenant_id uuid NOT NULL, + schedule_id varchar(128) NOT NULL, + payer_tenant_id uuid NOT NULL, + source_invoice_id varchar(128) NOT NULL, + source_invoice_item_ref varchar(128) NOT NULL, + po_allocation_group varchar(128), + subscription_ref varchar(128), + revenue_stream varchar(64) NOT NULL, + currency varchar(16) NOT NULL, + total_deferred_minor bigint NOT NULL, + recognized_minor bigint NOT NULL DEFAULT 0, + policy_ref varchar(256) NOT NULL, + ssp_snapshot_ref varchar(256), + vc_estimate_ref varchar(256), + vc_method_ref varchar(256), + status varchar(16) NOT NULL, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, schedule_id), + CONSTRAINT chk_ledger_recognition_schedule_recognized_nonneg + CHECK (recognized_minor >= 0), + CONSTRAINT chk_ledger_recognition_schedule_deferred_nonneg + CHECK (total_deferred_minor >= 0), + CONSTRAINT chk_ledger_recognition_schedule_recognized_le_deferred + CHECK (recognized_minor <= total_deferred_minor), + CONSTRAINT chk_ledger_recognition_schedule_status + CHECK (status IN ('ACTIVE','COMPLETED','REPLACED','CANCELLED')) + )", + // Partial UNIQUE — the at-most-one-live guard (one current schedule per + // business key); `REPLACED`/`COMPLETED`/`CANCELLED` rows keep history + // without colliding. Build-idempotency lives in `idempotency_dedup`, not + // `status` (Rev3 / S4-F2). + "CREATE UNIQUE INDEX ledger_recognition_schedule_live_idx + ON bss.ledger_recognition_schedule + (tenant_id, source_invoice_id, source_invoice_item_ref, revenue_stream) + WHERE status = 'ACTIVE'", + "CREATE TABLE bss.ledger_recognition_segment ( + tenant_id uuid NOT NULL, + schedule_id varchar(128) NOT NULL, + segment_no integer NOT NULL, + period_id varchar(64) NOT NULL, + amount_minor bigint NOT NULL, + status varchar(16) NOT NULL, + recognized_at timestamptz, + run_id uuid, + PRIMARY KEY (tenant_id, schedule_id, segment_no), + CONSTRAINT chk_ledger_recognition_segment_amount_nonneg + CHECK (amount_minor >= 0), + CONSTRAINT chk_ledger_recognition_segment_status + CHECK (status IN ('PENDING','QUEUED','DONE')) + )", + // `segment_no` is 1:1 with `period_id` — the dedup grain ≡ the UNIQUE grain. + "CREATE UNIQUE INDEX ledger_recognition_segment_period_idx + ON bss.ledger_recognition_segment (tenant_id, schedule_id, period_id)", + "CREATE TABLE bss.ledger_recognition_run ( + tenant_id uuid NOT NULL, + run_id uuid NOT NULL, + period_id varchar(64) NOT NULL, + started_at_utc timestamptz NOT NULL, + status varchar(16) NOT NULL, + PRIMARY KEY (tenant_id, period_id, run_id), + CONSTRAINT chk_ledger_recognition_run_status + CHECK (status IN ('RUNNING','DONE','FAILED')) + )", + // Run-trigger dedup `(tenant, period_id, run_id)` + the single-active-run + // advisory lock live at the orchestration layer (Phase 2); this index serves + // the per-`(tenant, period_id)` scan that selects in-flight runs. + "CREATE INDEX ledger_recognition_run_period_idx + ON bss.ledger_recognition_run (tenant_id, period_id)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS bss.ledger_recognition_run", + "DROP TABLE IF EXISTS bss.ledger_recognition_segment", + "DROP TABLE IF EXISTS bss.ledger_recognition_schedule", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; all CHECKs + PKs + indexes (incl. the partial UNIQUE) +// preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_recognition_schedule ( + tenant_id text NOT NULL, + schedule_id varchar(128) NOT NULL, + payer_tenant_id text NOT NULL, + source_invoice_id varchar(128) NOT NULL, + source_invoice_item_ref varchar(128) NOT NULL, + po_allocation_group varchar(128), + subscription_ref varchar(128), + revenue_stream varchar(64) NOT NULL, + currency varchar(16) NOT NULL, + total_deferred_minor bigint NOT NULL, + recognized_minor bigint NOT NULL DEFAULT 0, + policy_ref varchar(256) NOT NULL, + ssp_snapshot_ref varchar(256), + vc_estimate_ref varchar(256), + vc_method_ref varchar(256), + status varchar(16) NOT NULL, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, schedule_id), + CONSTRAINT chk_ledger_recognition_schedule_recognized_nonneg + CHECK (recognized_minor >= 0), + CONSTRAINT chk_ledger_recognition_schedule_deferred_nonneg + CHECK (total_deferred_minor >= 0), + CONSTRAINT chk_ledger_recognition_schedule_recognized_le_deferred + CHECK (recognized_minor <= total_deferred_minor), + CONSTRAINT chk_ledger_recognition_schedule_status + CHECK (status IN ('ACTIVE','COMPLETED','REPLACED','CANCELLED')) + )", + "CREATE UNIQUE INDEX ledger_recognition_schedule_live_idx + ON ledger_recognition_schedule + (tenant_id, source_invoice_id, source_invoice_item_ref, revenue_stream) + WHERE status = 'ACTIVE'", + "CREATE TABLE ledger_recognition_segment ( + tenant_id text NOT NULL, + schedule_id varchar(128) NOT NULL, + segment_no integer NOT NULL, + period_id varchar(64) NOT NULL, + amount_minor bigint NOT NULL, + status varchar(16) NOT NULL, + recognized_at text, + run_id text, + PRIMARY KEY (tenant_id, schedule_id, segment_no), + CONSTRAINT chk_ledger_recognition_segment_amount_nonneg + CHECK (amount_minor >= 0), + CONSTRAINT chk_ledger_recognition_segment_status + CHECK (status IN ('PENDING','QUEUED','DONE')) + )", + "CREATE UNIQUE INDEX ledger_recognition_segment_period_idx + ON ledger_recognition_segment (tenant_id, schedule_id, period_id)", + "CREATE TABLE ledger_recognition_run ( + tenant_id text NOT NULL, + run_id text NOT NULL, + period_id varchar(64) NOT NULL, + started_at_utc text NOT NULL, + status varchar(16) NOT NULL, + PRIMARY KEY (tenant_id, period_id, run_id), + CONSTRAINT chk_ledger_recognition_run_status + CHECK (status IN ('RUNNING','DONE','FAILED')) + )", + "CREATE INDEX ledger_recognition_run_period_idx + ON ledger_recognition_run (tenant_id, period_id)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS ledger_recognition_run", + "DROP TABLE IF EXISTS ledger_recognition_segment", + "DROP TABLE IF EXISTS ledger_recognition_schedule", +]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000012_create_dual_control_tables.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000012_create_dual_control_tables.rs new file mode 100644 index 000000000..8700b9190 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000012_create_dual_control_tables.rs @@ -0,0 +1,244 @@ +//! Create the dual-control governance tables in schema `bss` (VHP-1852): +//! `ledger_approval` (one row per governed mutation that crossed a policy +//! threshold — the `PENDING → APPROVED | REJECTED | NEEDS_REWORK | CANCELLED | +//! EXPIRED` state machine + the deterministic `intent`), `ledger_dual_control_policy` +//! (per-tenant append-only effective-dated D2/A6/TTL thresholds), and +//! `ledger_approval_comment` (the append-only preparer↔approver thread + decision +//! reasons). +//! +//! Lock order (§4.3): `ledger_approval` is NOT a balance cache, so it carries no +//! projector `table_rank`; when locked in the decision txn it sits in the +//! pre-balance sequence `idempotency_dedup → ledger_approval → fiscal_period → +//! balance grains`. The comment table is never locked in the posting txn. +//! +//! Migration number `000012` deliberately skips `000011`, which is reserved by +//! Slice 4 (recognition) on the parallel branch, to avoid a merge collision. +//! +//! All CHECKs are created in final form up-front (Foundation §7.2). A partial +//! UNIQUE index `(tenant_id, kind, business_key) WHERE state IN ('PENDING', +//! 'NEEDS_REWORK')` is the idempotency guard (DC13): a preparer retry returns the +//! existing active record rather than a duplicate. `SQLite` mirrors the shape with +//! the systematic transforms (`uuid`→`text`, `timestamptz`→`text`, `jsonb`→`text`). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_approval ( + approval_id uuid NOT NULL, + tenant_id uuid NOT NULL, + kind varchar(32) NOT NULL, + state varchar(16) NOT NULL, + revision integer NOT NULL DEFAULT 0, + business_key varchar(256) NOT NULL, + intent jsonb NOT NULL, + amount_usd_eq_minor bigint, + threshold_snapshot jsonb NOT NULL, + reason_code varchar(128) NOT NULL, + prepared_by uuid NOT NULL, + prepared_at timestamptz NOT NULL, + approved_by uuid, + decided_at timestamptz, + correlation_id uuid NOT NULL, + expires_at timestamptz NOT NULL, + PRIMARY KEY (approval_id), + CONSTRAINT chk_ledger_approval_kind CHECK (kind IN + ('REVERSE','MATERIAL_BACKDATING','CREDIT_GRANT','CHARGEBACK_LOSS','PAYER_CLOSURE','PERIOD_REOPEN','RECOGNITION_SCHEDULE_CHANGE','REFUND','MANUAL_ADJUSTMENT','CREDIT_NOTE','DEBIT_NOTE')), + CONSTRAINT chk_ledger_approval_state CHECK (state IN + ('PENDING','APPROVING','APPROVED','REJECTED','NEEDS_REWORK','CANCELLED','EXPIRED')), + CONSTRAINT chk_ledger_approval_revision_nonneg CHECK (revision >= 0), + CONSTRAINT chk_ledger_approval_approver_distinct + CHECK (approved_by IS NULL OR approved_by <> prepared_by) + )", + // The one-live idempotency guard (DC13) ALSO covers the transient `APPROVING` + // latch (H2): while an approve is executing the mutation, the slot stays held + // so no second active record for the same business key can be prepared. + "CREATE UNIQUE INDEX uq_ledger_approval_active + ON bss.ledger_approval (tenant_id, kind, business_key) + WHERE state IN ('PENDING','NEEDS_REWORK','APPROVING')", + "CREATE INDEX ix_ledger_approval_queue + ON bss.ledger_approval (tenant_id, state, kind)", + "CREATE INDEX ix_ledger_approval_expiry + ON bss.ledger_approval (tenant_id, expires_at) + WHERE state IN ('PENDING','NEEDS_REWORK')", + "CREATE TABLE bss.ledger_dual_control_policy ( + tenant_id uuid NOT NULL, + version bigint NOT NULL, + effective_from timestamptz NOT NULL, + d2_threshold_minor bigint NOT NULL, + a6_backdating_biz_days integer NOT NULL, + pending_ttl_seconds bigint NOT NULL, + created_at_utc timestamptz NOT NULL, + PRIMARY KEY (tenant_id, version), + CONSTRAINT chk_ledger_dcpolicy_version_nonneg CHECK (version >= 0), + CONSTRAINT chk_ledger_dcpolicy_d2_range + CHECK (d2_threshold_minor BETWEEN 10000 AND 100000000), + CONSTRAINT chk_ledger_dcpolicy_a6_range + CHECK (a6_backdating_biz_days BETWEEN 1 AND 30), + CONSTRAINT chk_ledger_dcpolicy_ttl_pos CHECK (pending_ttl_seconds > 0) + )", + "CREATE INDEX ix_ledger_dcpolicy_effective + ON bss.ledger_dual_control_policy (tenant_id, effective_from)", + "CREATE TABLE bss.ledger_approval_comment ( + comment_id uuid NOT NULL, + approval_id uuid NOT NULL, + tenant_id uuid NOT NULL, + revision integer NOT NULL, + author_actor uuid NOT NULL, + body text NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY (comment_id), + CONSTRAINT fk_ledger_approval_comment_approval + FOREIGN KEY (approval_id) REFERENCES bss.ledger_approval (approval_id) + )", + "CREATE INDEX ix_ledger_approval_comment_thread + ON bss.ledger_approval_comment (tenant_id, approval_id, created_at)", + // Append-only enforcement (Postgres-only; mirrors the journal tables' + // `bss.reject_mutation()` trigger in m20260619_000002). The comment thread is + // the sole tamper-evident store of the dual-control decision reasons + the + // preparer↔approver dialogue (the DC7 `secured_audit_record` stand-in until + // Slice 6), so UPDATE/DELETE must be refused at the DB level, not merely + // absent from the repo. The trigger function is created by the journal + // migration (which runs earlier in the same gear), so we reference it here. + "CREATE TRIGGER trg_ledger_approval_comment_append_only + BEFORE UPDATE OR DELETE ON bss.ledger_approval_comment + FOR EACH ROW EXECUTE FUNCTION bss.reject_mutation()", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS bss.ledger_approval_comment", + "DROP TABLE IF EXISTS bss.ledger_dual_control_policy", + "DROP TABLE IF EXISTS bss.ledger_approval", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`/`timestamptz`/ +// `jsonb`→`text`; CHECKs + PKs + indexes + partial-unique preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_approval ( + approval_id text NOT NULL, + tenant_id text NOT NULL, + kind varchar(32) NOT NULL, + state varchar(16) NOT NULL, + revision integer NOT NULL DEFAULT 0, + business_key varchar(256) NOT NULL, + intent text NOT NULL, + amount_usd_eq_minor bigint, + threshold_snapshot text NOT NULL, + reason_code varchar(128) NOT NULL, + prepared_by text NOT NULL, + prepared_at text NOT NULL, + approved_by text, + decided_at text, + correlation_id text NOT NULL, + expires_at text NOT NULL, + PRIMARY KEY (approval_id), + CONSTRAINT chk_ledger_approval_kind CHECK (kind IN + ('REVERSE','MATERIAL_BACKDATING','CREDIT_GRANT','CHARGEBACK_LOSS','PAYER_CLOSURE','PERIOD_REOPEN','RECOGNITION_SCHEDULE_CHANGE','REFUND','MANUAL_ADJUSTMENT','CREDIT_NOTE','DEBIT_NOTE')), + CONSTRAINT chk_ledger_approval_state CHECK (state IN + ('PENDING','APPROVING','APPROVED','REJECTED','NEEDS_REWORK','CANCELLED','EXPIRED')), + CONSTRAINT chk_ledger_approval_revision_nonneg CHECK (revision >= 0), + CONSTRAINT chk_ledger_approval_approver_distinct + CHECK (approved_by IS NULL OR approved_by <> prepared_by) + )", + "CREATE UNIQUE INDEX uq_ledger_approval_active + ON ledger_approval (tenant_id, kind, business_key) + WHERE state IN ('PENDING','NEEDS_REWORK','APPROVING')", + "CREATE INDEX ix_ledger_approval_queue + ON ledger_approval (tenant_id, state, kind)", + "CREATE INDEX ix_ledger_approval_expiry + ON ledger_approval (tenant_id, expires_at) + WHERE state IN ('PENDING','NEEDS_REWORK')", + "CREATE TABLE ledger_dual_control_policy ( + tenant_id text NOT NULL, + version bigint NOT NULL, + effective_from text NOT NULL, + d2_threshold_minor bigint NOT NULL, + a6_backdating_biz_days integer NOT NULL, + pending_ttl_seconds bigint NOT NULL, + created_at_utc text NOT NULL, + PRIMARY KEY (tenant_id, version), + CONSTRAINT chk_ledger_dcpolicy_version_nonneg CHECK (version >= 0), + CONSTRAINT chk_ledger_dcpolicy_d2_range + CHECK (d2_threshold_minor BETWEEN 10000 AND 100000000), + CONSTRAINT chk_ledger_dcpolicy_a6_range + CHECK (a6_backdating_biz_days BETWEEN 1 AND 30), + CONSTRAINT chk_ledger_dcpolicy_ttl_pos CHECK (pending_ttl_seconds > 0) + )", + "CREATE INDEX ix_ledger_dcpolicy_effective + ON ledger_dual_control_policy (tenant_id, effective_from)", + "CREATE TABLE ledger_approval_comment ( + comment_id text NOT NULL, + approval_id text NOT NULL, + tenant_id text NOT NULL, + revision integer NOT NULL, + author_actor text NOT NULL, + body text NOT NULL, + created_at text NOT NULL, + PRIMARY KEY (comment_id), + CONSTRAINT fk_ledger_approval_comment_approval + FOREIGN KEY (approval_id) REFERENCES ledger_approval (approval_id) + )", + "CREATE INDEX ix_ledger_approval_comment_thread + ON ledger_approval_comment (tenant_id, approval_id, created_at)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS ledger_approval_comment", + "DROP TABLE IF EXISTS ledger_dual_control_policy", + "DROP TABLE IF EXISTS ledger_approval", +]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000012_relax_journal_entry_trigger.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000012_relax_journal_entry_trigger.rs new file mode 100644 index 000000000..c52daf724 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000012_relax_journal_entry_trigger.rs @@ -0,0 +1,115 @@ +//! Relax the `journal_entry` append-only trigger from a blanket +//! reject-all-mutations guard (P1's `bss.reject_mutation`) into an +//! append-only *seal* guard: a single in-place `UPDATE` is permitted iff it +//! only sets the tamper-evidence chain columns (`row_hash` / `prev_hash` / +//! the prev pointers) on a not-yet-sealed row, and every business column is +//! left byte-for-byte unchanged. `DELETE` stays forbidden, and a second seal +//! of an already-sealed row is rejected. Postgres-only: `SQLite` (the +//! non-production test backend) carries no triggers — those invariants are +//! re-asserted in application code — so its up/down statement arrays are empty. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE OR REPLACE FUNCTION bss.ledger_journal_entry_append_guard() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'append-only: DELETE not permitted on journal_entry'; + END IF; + IF OLD.row_hash IS NOT NULL THEN + RAISE EXCEPTION 'append-only: chain already sealed (entry=%)', OLD.entry_id; + END IF; + IF NEW.row_hash IS NULL OR NEW.prev_hash IS NULL THEN + RAISE EXCEPTION 'append-only: seal must set row_hash and prev_hash'; + END IF; + IF ROW(NEW.entry_id,NEW.tenant_id,NEW.period_id,NEW.legal_entity_id,NEW.entry_currency, + NEW.source_doc_type,NEW.source_business_id,NEW.reverses_entry_id,NEW.reverses_period_id, + NEW.posted_at_utc,NEW.effective_at,NEW.origin,NEW.posted_by_actor_id,NEW.correlation_id, + NEW.rounding_evidence,NEW.created_seq) + IS DISTINCT FROM + ROW(OLD.entry_id,OLD.tenant_id,OLD.period_id,OLD.legal_entity_id,OLD.entry_currency, + OLD.source_doc_type,OLD.source_business_id,OLD.reverses_entry_id,OLD.reverses_period_id, + OLD.posted_at_utc,OLD.effective_at,OLD.origin,OLD.posted_by_actor_id,OLD.correlation_id, + OLD.rounding_evidence,OLD.created_seq) THEN + RAISE EXCEPTION 'append-only: only chain columns may be sealed (entry=%)', OLD.entry_id; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql", + "DROP TRIGGER trg_journal_entry_append_only ON bss.ledger_journal_entry", + "CREATE TRIGGER trg_journal_entry_append_guard + BEFORE UPDATE OR DELETE ON bss.ledger_journal_entry + FOR EACH ROW EXECUTE FUNCTION bss.ledger_journal_entry_append_guard()", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TRIGGER trg_journal_entry_append_guard ON bss.ledger_journal_entry", + "CREATE TRIGGER trg_journal_entry_append_only + BEFORE UPDATE OR DELETE ON bss.ledger_journal_entry + FOR EACH ROW EXECUTE FUNCTION bss.reject_mutation()", + "DROP FUNCTION IF EXISTS bss.ledger_journal_entry_append_guard()", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- +// +// SQLite omits all triggers and PL/pgSQL (see the P1 journal-tables +// migration): the append-only / seal invariants are re-asserted in +// application code, so there is nothing to relax here. Both arrays are empty. + +const SQLITE_UP_STATEMENTS: &[&str] = &[]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000013_create_scope_freeze.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000013_create_scope_freeze.rs new file mode 100644 index 000000000..5335f8e8a --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000013_create_scope_freeze.rs @@ -0,0 +1,104 @@ +//! Create the `bss.scope_freeze` table: a per-tenant tamper-freeze switch the +//! integrity verifier sets to STOP further posting into a scope whose chain +//! failed verification. A row is ACTIVE while `cleared_at IS NULL`; `period_id` +//! is `'ALL'` for a tenant-wide freeze or a concrete `varchar(6)` period to +//! freeze just that period. The composite PK `(tenant_id, scope, period_id)` +//! lets a tenant carry both an `'ALL'` freeze and per-period freezes at once. +//! `SQLite` (non-production test backend) mirrors the shape with the systematic +//! transforms (`uuid`→`text`, `timestamptz`→`text`, no `bss.` prefix); the PK +//! and the `period_id DEFAULT 'ALL'` are preserved on both backends. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &["CREATE TABLE bss.scope_freeze ( + tenant_id uuid NOT NULL, + scope text NOT NULL, + period_id varchar(6) NOT NULL DEFAULT 'ALL', + reason text NOT NULL, + frozen_at timestamptz NOT NULL DEFAULT now(), + set_by text NOT NULL, + cleared_by text, + cleared_at timestamptz, + PRIMARY KEY (tenant_id, scope, period_id) + )"]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.scope_freeze"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- +// +// Systematic transforms from the Postgres variant: +// * schema prefix `bss.` dropped (single namespace); +// * `uuid` → `text`; +// * `timestamptz NOT NULL DEFAULT now()` → `text NOT NULL DEFAULT (CURRENT_TIMESTAMP)` +// (mirroring how the P1 journal-tables migration maps `posted_at_utc`); +// * nullable `timestamptz` → nullable `text` (no default). +// The PK and the `period_id DEFAULT 'ALL'` are preserved. + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE scope_freeze ( + tenant_id text NOT NULL, + scope text NOT NULL, + period_id varchar(6) NOT NULL DEFAULT 'ALL', + reason text NOT NULL, + frozen_at text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + set_by text NOT NULL, + cleared_by text, + cleared_at text, + PRIMARY KEY (tenant_id, scope, period_id) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS scope_freeze"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000014_create_secured_audit.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000014_create_secured_audit.rs new file mode 100644 index 000000000..b71a48b57 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000014_create_secured_audit.rs @@ -0,0 +1,157 @@ +//! Create the secured audit store: the append-only `bss.secured_audit_record` +//! and its own per-tenant tamper-evidence chain tip `bss.audit_chain_state` +//! (Slice 6 Phase 2 Group 2A). Each audit row is born sealed (`row_hash` / +//! `prev_hash` non-NULL) and is never updated; Postgres carries the +//! append-only `bss.reject_mutation()` trigger (reused from migration 000002) +//! to forbid any later UPDATE/DELETE. `SQLite` (non-production test backend) +//! mirrors the shape with the systematic transforms (`uuid`→`text`, +//! `jsonb`→`text`, `bytea`→`blob`, `timestamptz`→`text`, `bigint`→`integer`, +//! no `bss.` prefix) and omits the trigger (`SQLite` has none — mirror 000002). +//! Every CHECK, index, PK, and both tables are preserved on both backends. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.secured_audit_record ( + audit_id uuid NOT NULL PRIMARY KEY, + tenant_id uuid NOT NULL, + event_type text NOT NULL, + actor_ref text, + reason_code text, + before_after jsonb NOT NULL DEFAULT '{}'::jsonb, + correlation_id uuid, + row_hash bytea NOT NULL, + prev_hash bytea NOT NULL, + at_utc timestamptz NOT NULL DEFAULT now(), + retain_until timestamptz, + CONSTRAINT chk_secured_audit_event_type CHECK (event_type IN ( + 'conflict-capture','metadata-change','cross-tenant-access','manual-adjustment', + 'erasure','re-identification','account-lifecycle-change','exception-resolution', + 'freeze-set-clear','config-change','restore-event','period-reopen')) + )", + "CREATE INDEX idx_secured_audit_correlation + ON bss.secured_audit_record (tenant_id, correlation_id)", + "CREATE INDEX idx_secured_audit_event + ON bss.secured_audit_record (tenant_id, event_type, at_utc)", + "CREATE TRIGGER trg_secured_audit_append_only + BEFORE UPDATE OR DELETE ON bss.secured_audit_record + FOR EACH ROW EXECUTE FUNCTION bss.reject_mutation()", + "CREATE TABLE bss.audit_chain_state ( + tenant_id uuid NOT NULL PRIMARY KEY, + last_row_hash bytea NOT NULL, + last_audit_id uuid NOT NULL, + last_seq bigint NOT NULL + )", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TRIGGER IF EXISTS trg_secured_audit_append_only ON bss.secured_audit_record", + "DROP TABLE IF EXISTS bss.audit_chain_state", + "DROP TABLE IF EXISTS bss.secured_audit_record", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- +// +// Systematic transforms from the Postgres variant: +// * schema prefix `bss.` dropped (single namespace); +// * `uuid` → `text`; `jsonb` → `text` (JSON default `'{}'::jsonb` → `'{}'`); +// * `bytea` → `blob`; `bigint` → `integer`; +// * `timestamptz NOT NULL DEFAULT now()` → `text NOT NULL DEFAULT (CURRENT_TIMESTAMP)`, +// nullable `timestamptz` → nullable `text`; +// * the append-only trigger and `bss.reject_mutation()` are DROPPED — SQLite +// has neither; that invariant is re-asserted in application code / tests. +// Every CHECK, index, PK, and both tables are preserved. + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE secured_audit_record ( + audit_id text NOT NULL PRIMARY KEY, + tenant_id text NOT NULL, + event_type text NOT NULL, + actor_ref text, + reason_code text, + before_after text NOT NULL DEFAULT '{}', + correlation_id text, + row_hash blob NOT NULL, + prev_hash blob NOT NULL, + at_utc text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + retain_until text, + CONSTRAINT chk_secured_audit_event_type CHECK (event_type IN ( + 'conflict-capture','metadata-change','cross-tenant-access','manual-adjustment', + 'erasure','re-identification','account-lifecycle-change','exception-resolution', + 'freeze-set-clear','config-change','restore-event','period-reopen')) + )", + "CREATE INDEX idx_secured_audit_correlation + ON secured_audit_record (tenant_id, correlation_id)", + "CREATE INDEX idx_secured_audit_event + ON secured_audit_record (tenant_id, event_type, at_utc)", + "CREATE TABLE audit_chain_state ( + tenant_id text NOT NULL PRIMARY KEY, + last_row_hash blob NOT NULL, + last_audit_id text NOT NULL, + last_seq integer NOT NULL + )", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS audit_chain_state", + "DROP TABLE IF EXISTS secured_audit_record", +]; + +#[cfg(test)] +#[path = "m20260624_000014_create_secured_audit_tests.rs"] +mod check_drift_tests; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000014_create_secured_audit_tests.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000014_create_secured_audit_tests.rs new file mode 100644 index 000000000..d766cbc37 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000014_create_secured_audit_tests.rs @@ -0,0 +1,45 @@ +//! Anti-drift: the `event_type` literal set is maintained independently in the +//! [`AuditEventType::ALL`] catalogue and in this migration's raw-SQL `CHECK` +//! constraint. A variant added to one side but not the other would let the DB +//! reject a "valid" enum token or accept an unlisted one. This test pins the +//! two sets equal by parsing the literals straight out of the migration SQL. + +#![allow(clippy::panic, clippy::expect_used)] + +use crate::infra::audit::event_type::AuditEventType; + +use super::PG_UP_STATEMENTS; + +/// Extract the single-quoted literals from the first `event_type IN ( … )` +/// clause across the Postgres up-statements (the `CHECK` constraint). +fn check_literals(col: &str) -> Vec { + let key = format!("{col} IN ("); + let stmt = PG_UP_STATEMENTS + .iter() + .find(|s| s.contains(&key)) + .unwrap_or_else(|| panic!("no PG up-statement defines a CHECK on `{col}`")); + let start = stmt.find(&key).expect("key present") + key.len(); + let rest = &stmt[start..]; + let end = rest.find(')').expect("CHECK IN list must be closed"); + let mut literals: Vec = rest[..end] + .split(',') + .map(|t| t.trim().trim_matches('\'').to_owned()) + .collect(); + literals.sort(); + literals +} + +fn sorted(all: &[&str]) -> Vec { + let mut v: Vec = all.iter().map(|s| (*s).to_owned()).collect(); + v.sort(); + v +} + +#[test] +fn event_type_enum_matches_migration_check() { + assert_eq!( + check_literals("event_type"), + sorted(AuditEventType::ALL), + "AuditEventType::ALL drifted from chk_secured_audit_event_type" + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000015_create_entry_annotation.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000015_create_entry_annotation.rs new file mode 100644 index 000000000..f400e6cb2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000015_create_entry_annotation.rs @@ -0,0 +1,88 @@ +//! Create the typed entry-annotation overlay: `bss.entry_annotation` (Slice 6 +//! Phase 2 Group 2B, Variant C remodel). Each row is the CURRENT controlled +//! non-financial annotation (`description`) on one journal entry / line. Unlike +//! the journal + secured-audit chains, this table is MUTABLE current-state: a +//! re-annotation UPSERTs the row in place. The append-only HISTORY of every +//! change lives in the secured-audit chain (`metadata-change` records), so this +//! table carries no append-only trigger and no before/after column. `SQLite` +//! (non-production test backend) mirrors the shape with the systematic +//! transforms (`uuid`→`text`, `timestamptz`→`text`, no `bss.` prefix). +//! PK `(tenant_id, target_id, target_kind)` — one current annotation per target. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.entry_annotation ( + tenant_id uuid NOT NULL, + target_id uuid NOT NULL, + target_kind text NOT NULL CHECK (target_kind IN ('ENTRY','LINE')), + target_period_id varchar(6) NOT NULL, + description text, + actor_ref text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, target_id, target_kind) + )", + // No separate (tenant_id, target_id) index: the PK btree + // (tenant_id, target_id, target_kind) already serves that prefix, and every + // read/upsert filters on the full PK. +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.entry_annotation"]; + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE entry_annotation ( + tenant_id text NOT NULL, + target_id text NOT NULL, + target_kind text NOT NULL CHECK (target_kind IN ('ENTRY','LINE')), + target_period_id varchar(6) NOT NULL, + description text, + actor_ref text NOT NULL, + updated_at text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (tenant_id, target_id, target_kind) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS entry_annotation"]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000016_create_payer_pii_map.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000016_create_payer_pii_map.rs new file mode 100644 index 000000000..c9fed5f3d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000016_create_payer_pii_map.rs @@ -0,0 +1,91 @@ +//! Create `bss.payer_pii_map` (the per-payer PII reference + erasure tombstone, +//! Slice 6 Phase 3 Group 3A, architecture §4.5 / AC #22). One row per +//! `(tenant_id, payer_tenant_id)` holds the opaque `pii_ref` (a pointer into the +//! external PII store — never the PII itself) and an `erased` tombstone the +//! erasure path flips. +//! +//! UNLIKE the audit / metadata-change tables this table is MUTABLE: the GDPR +//! right-to-erasure tombstone sets `erased = true` in place, so there is NO +//! `bss.reject_mutation()` append-only trigger. `SQLite` mirrors the same shape +//! with the systematic transforms (`uuid`→`text`). Tenant isolation runs through +//! the entity `#[secure(tenant_col = "tenant_id", …)]` `SecureORM` layer. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &["CREATE TABLE bss.payer_pii_map ( + tenant_id uuid NOT NULL, + payer_tenant_id uuid NOT NULL, + pii_ref text NOT NULL, + erased boolean NOT NULL DEFAULT false, + PRIMARY KEY (tenant_id, payer_tenant_id) + )"]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.payer_pii_map"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `boolean` kept; PK preserved). No append-only trigger (this table is mutable). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE payer_pii_map ( + tenant_id text NOT NULL, + payer_tenant_id text NOT NULL, + pii_ref text NOT NULL, + erased boolean NOT NULL DEFAULT false, + PRIMARY KEY (tenant_id, payer_tenant_id) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS payer_pii_map"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000017_create_chain_checkpoint.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000017_create_chain_checkpoint.rs new file mode 100644 index 000000000..7787df4fb --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000017_create_chain_checkpoint.rs @@ -0,0 +1,111 @@ +//! Create the `bss.chain_checkpoint` table: a per-tenant retention checkpoint +//! that records a contiguous range of the tamper-evidence hash chain (Slice 6 +//! design §4.8/E-5). One row pins a `from_row_hash` .. `to_row_hash` range plus +//! the number of journal entries it covers, so a future partition-rotation pass +//! can prove a detached partition is anchored by a signed checkpoint before it +//! retires the underlying rows. +//! +//! **Dormant seam (Variant 2).** Partitioning / rotation is Foundation +//! (Slice-1) debt: nothing in the MVP writes a checkpoint on a schedule yet. +//! This table ships as the interface Foundation's rotation will call. The +//! `signature` column is nullable on purpose — signing / `WORM` storage is +//! post-MVP (Bucket A); in the MVP a checkpoint just records a range, unsigned. +//! +//! `SQLite` (the non-production test backend) mirrors the shape with the +//! systematic transforms (drop the `bss.` prefix; `uuid` → `text`; +//! `bytea` → `blob`; `timestamptz NOT NULL DEFAULT now()` → +//! `text NOT NULL DEFAULT (CURRENT_TIMESTAMP)`). The `checkpoint_id` PK is +//! preserved on both backends. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &["CREATE TABLE bss.chain_checkpoint ( + checkpoint_id uuid NOT NULL, + tenant_id uuid NOT NULL, + from_row_hash bytea NOT NULL, + to_row_hash bytea NOT NULL, + covered_entry_count bigint NOT NULL, + signature bytea, + created_at_utc timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (checkpoint_id) + )"]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.chain_checkpoint"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- +// +// Systematic transforms from the Postgres variant: +// * schema prefix `bss.` dropped (single namespace); +// * `uuid` → `text`; +// * `bytea` → `blob` (both the NOT-NULL range hashes and the nullable signature); +// * `timestamptz NOT NULL DEFAULT now()` → `text NOT NULL DEFAULT (CURRENT_TIMESTAMP)` +// (mirroring how the P1 journal-tables migration maps `posted_at_utc`). +// The `checkpoint_id` PK is preserved. + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE chain_checkpoint ( + checkpoint_id text NOT NULL, + tenant_id text NOT NULL, + from_row_hash blob NOT NULL, + to_row_hash blob NOT NULL, + covered_entry_count bigint NOT NULL, + signature blob, + created_at_utc text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (checkpoint_id) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS chain_checkpoint"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000018_create_audit_pack_export.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000018_create_audit_pack_export.rs new file mode 100644 index 000000000..535551aa2 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260624_000018_create_audit_pack_export.rs @@ -0,0 +1,121 @@ +//! Create the `bss.audit_pack_export` table: one materialized audit-pack export +//! job (Slice 6 §5/§10). `POST …/audit/packs` inserts a row and returns +//! `202 Accepted` + a `Location` to `GET …/audit/packs/{exportId}`, which polls +//! the row for `status` and the materialized CSV. +//! +//! The row is owned by the requester's home tenant (`tenant_id`); +//! `target_tenant_id` records whose ledger was opened. The `status` CHECK pins +//! the four job states; MVP rows are born `succeeded` (synchronous build), the +//! `accepted` / `processing` states being reserved for a future worker path (no +//! migration needed to activate them). An index on `(tenant_id, created_at_utc)` +//! serves the requester's recent-exports listing. +//! +//! `SQLite` (the non-production test backend) mirrors the shape with the +//! systematic transforms (drop the `bss.` prefix; `uuid` → `text`; +//! `bytea` → `blob`; `timestamptz NOT NULL DEFAULT now()` → +//! `text NOT NULL DEFAULT (CURRENT_TIMESTAMP)`). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.audit_pack_export ( + export_id uuid NOT NULL, + tenant_id uuid NOT NULL, + target_tenant_id uuid NOT NULL, + status text NOT NULL, + reason_code text, + actor_ref text NOT NULL, + csv bytea, + row_count bigint NOT NULL DEFAULT 0, + error_detail text, + created_at_utc timestamptz NOT NULL DEFAULT now(), + completed_at_utc timestamptz, + PRIMARY KEY (export_id), + CONSTRAINT chk_audit_pack_export_status + CHECK (status IN ('accepted', 'processing', 'succeeded', 'failed')) + )", + "CREATE INDEX idx_audit_pack_export_tenant_created + ON bss.audit_pack_export (tenant_id, created_at_utc)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.audit_pack_export"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE audit_pack_export ( + export_id text NOT NULL, + tenant_id text NOT NULL, + target_tenant_id text NOT NULL, + status text NOT NULL, + reason_code text, + actor_ref text NOT NULL, + csv blob, + row_count bigint NOT NULL DEFAULT 0, + error_detail text, + created_at_utc text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + completed_at_utc text, + PRIMARY KEY (export_id), + CONSTRAINT chk_audit_pack_export_status + CHECK (status IN ('accepted', 'processing', 'succeeded', 'failed')) + )", + "CREATE INDEX idx_audit_pack_export_tenant_created + ON audit_pack_export (tenant_id, created_at_utc)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS audit_pack_export"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260625_000013_dual_control_approving_state.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260625_000013_dual_control_approving_state.rs new file mode 100644 index 000000000..8704c7fad --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260625_000013_dual_control_approving_state.rs @@ -0,0 +1,86 @@ +//! Forward-fix for the dual-control `APPROVING` latch (H2) + the comment +//! append-only trigger (Z9-1) on clusters where `m20260624_000012` was ALREADY +//! applied with its original shape. +//! +//! `m20260624_000012` was amended in place to (a) add `APPROVING` to the +//! `ledger_approval.state` CHECK + the active-uniqueness partial index, and (b) +//! add the `ledger_approval_comment` append-only trigger. In-place edits to an +//! already-run migration do NOT re-apply (migrations run once, by name), so a +//! cluster that ran the original `000012` keeps the OLD CHECK — and the new +//! `approve` flow's `PENDING → APPROVING` transition then trips that CHECK (a 500). +//! This migration brings such a cluster up to date; it is **idempotent** (DROP … +//! IF EXISTS + re-create), so on a FRESH cluster — where `000012`'s amended form +//! already created the final shape — it is a harmless no-op re-statement. +//! +//! **Postgres-only.** `SQLite` is non-production and always migrates fresh from +//! the amended `000012` (which already carries the `APPROVING` CHECK + index; it +//! has no append-only trigger, by the same convention as the journal tables), so +//! the `SQLite` branch here is intentionally empty. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const PG_UP_STATEMENTS: &[&str] = &[ + // (Z9-1) Append-only trigger on the decision-audit comment thread — the sole + // tamper-evident store of dual-control reasons until Slice 6 (mirrors the + // journal tables' `bss.reject_mutation()` trigger). DROP IF EXISTS first so a + // fresh cluster (where amended 000012 already created it) re-creates cleanly. + "DROP TRIGGER IF EXISTS trg_ledger_approval_comment_append_only ON bss.ledger_approval_comment", + "CREATE TRIGGER trg_ledger_approval_comment_append_only + BEFORE UPDATE OR DELETE ON bss.ledger_approval_comment + FOR EACH ROW EXECUTE FUNCTION bss.reject_mutation()", + // (H2) Add the transient `APPROVING` latch state to the CHECK. + "ALTER TABLE bss.ledger_approval DROP CONSTRAINT IF EXISTS chk_ledger_approval_state", + "ALTER TABLE bss.ledger_approval ADD CONSTRAINT chk_ledger_approval_state CHECK (state IN + ('PENDING','APPROVING','APPROVED','REJECTED','NEEDS_REWORK','CANCELLED','EXPIRED'))", + // (H2) The one-live idempotency guard must also hold the `APPROVING` row so an + // in-flight approve keeps the active-uniqueness slot. + "DROP INDEX IF EXISTS bss.uq_ledger_approval_active", + "CREATE UNIQUE INDEX uq_ledger_approval_active + ON bss.ledger_approval (tenant_id, kind, business_key) + WHERE state IN ('PENDING','NEEDS_REWORK','APPROVING')", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TRIGGER IF EXISTS trg_ledger_approval_comment_append_only ON bss.ledger_approval_comment", + "ALTER TABLE bss.ledger_approval DROP CONSTRAINT IF EXISTS chk_ledger_approval_state", + "ALTER TABLE bss.ledger_approval ADD CONSTRAINT chk_ledger_approval_state CHECK (state IN + ('PENDING','APPROVED','REJECTED','NEEDS_REWORK','CANCELLED','EXPIRED'))", + "DROP INDEX IF EXISTS bss.uq_ledger_approval_active", + "CREATE UNIQUE INDEX uq_ledger_approval_active + ON bss.ledger_approval (tenant_id, kind, business_key) + WHERE state IN ('PENDING','NEEDS_REWORK')", +]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + // Postgres-only: SQLite migrates fresh from the amended 000012. + if backend != sea_orm::DatabaseBackend::Postgres { + return Ok(()); + } + for sql in PG_UP_STATEMENTS { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + if backend != sea_orm::DatabaseBackend::Postgres { + return Ok(()); + } + for sql in PG_DOWN_STATEMENTS { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000019_create_invoice_exposure.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000019_create_invoice_exposure.rs new file mode 100644 index 000000000..63a5e7235 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000019_create_invoice_exposure.rs @@ -0,0 +1,134 @@ +//! Create the Slice 3 credit-note **headroom** counter table in schema `bss`: +//! `ledger_invoice_exposure` — the per-invoice guarded counter that bounds total +//! credit-note exposure (design §4.7 / §7), keyed by `(tenant_id, invoice_id)`. +//! +//! `original_total_minor` is **seeded at first touch** = the invoice's posted AR +//! (incl. tax), via `INSERT … ON CONFLICT DO UPDATE` (the Slice 1 first-touch +//! upsert pattern, so concurrent creators serialize with no duplicate-key); +//! `debit_note_total_minor` is raised by S4 debit notes; `credit_note_total_minor` +//! is raised by credit notes. The +//! `chk_ledger_invoice_exposure_headroom` CHECK — +//! `credit_note_total_minor <= original_total_minor + debit_note_total_minor` +//! (AC #24) — is the **authoritative** in-transaction headroom guard: the +//! `CreditNoteHandler` (Phase 1) bumps `credit_note_total_minor` by an in-place +//! delta under the lock order and the CHECK is evaluated post-delta; an over-cap +//! note aborts the txn (surfaced as `CREDIT_NOTE_EXCEEDS_HEADROOM`, 400 — the +//! platform `CanonicalError` ladder has no 422). The +//! nonneg CHECKs are the residual defense-in-depth. +//! +//! **Lock order.** `invoice_exposure` is acquired AFTER the shared balance caches +//! and the recognition tables, BEFORE `payment_allocation_refund`, per the single +//! global order in design §4.7: +//! `payment_settlement → account_balance → ar_invoice_balance → ar_payer_balance +//! → unallocated_balance → reusable_credit_subbalance → tax_subbalance → +//! recognition_schedule → recognition_segment → invoice_exposure → +//! payment_allocation_refund`. Like the recognition tables, this is a procedural +//! counter grain (a single-row in-place counter delta in the handler), NOT a +//! `BalanceProjector` balance grain, so it carries no `GrainTable` rank (the +//! projector ranks stay balance-only; see `grain_lock_order_ranks_are_pinned`). +//! +//! All CHECKs are created in final form up-front (Foundation §7.2). `SQLite` +//! mirrors the same shape with the systematic transforms (`uuid`→`text`); the +//! CHECKs + PK are preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &["CREATE TABLE bss.ledger_invoice_exposure ( + tenant_id uuid NOT NULL, + invoice_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + original_total_minor bigint NOT NULL, + debit_note_total_minor bigint NOT NULL DEFAULT 0, + credit_note_total_minor bigint NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, invoice_id), + CONSTRAINT chk_ledger_invoice_exposure_headroom + CHECK (credit_note_total_minor <= original_total_minor + debit_note_total_minor), + CONSTRAINT chk_ledger_invoice_exposure_original_nonneg + CHECK (original_total_minor >= 0), + CONSTRAINT chk_ledger_invoice_exposure_debit_nonneg + CHECK (debit_note_total_minor >= 0), + CONSTRAINT chk_ledger_invoice_exposure_credit_nonneg + CHECK (credit_note_total_minor >= 0) + )"]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_invoice_exposure"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`; all CHECKs +// + PK preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE ledger_invoice_exposure ( + tenant_id text NOT NULL, + invoice_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + original_total_minor bigint NOT NULL, + debit_note_total_minor bigint NOT NULL DEFAULT 0, + credit_note_total_minor bigint NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, invoice_id), + CONSTRAINT chk_ledger_invoice_exposure_headroom + CHECK (credit_note_total_minor <= original_total_minor + debit_note_total_minor), + CONSTRAINT chk_ledger_invoice_exposure_original_nonneg + CHECK (original_total_minor >= 0), + CONSTRAINT chk_ledger_invoice_exposure_debit_nonneg + CHECK (debit_note_total_minor >= 0), + CONSTRAINT chk_ledger_invoice_exposure_credit_nonneg + CHECK (credit_note_total_minor >= 0) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_invoice_exposure"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000020_create_credit_note.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000020_create_credit_note.rs new file mode 100644 index 000000000..895a53db9 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000020_create_credit_note.rs @@ -0,0 +1,129 @@ +//! Create the Slice 3 `ledger_credit_note` record table in schema `bss` — the +//! record linking a posted credit note to its originating posted invoice item, +//! its revenue stream, and the recognized/deferred split basis (design §7), +//! keyed by `(tenant_id, credit_note_id)`. +//! +//! `credit_note_id` is the **business** id (mirrors `recognition_schedule`'s +//! `schedule_id` — a `varchar(128)`, NOT a `uuid` column — so it lines up with +//! the `SecureORM` `resource_col`). The PK doubles as the design's +//! `UNIQUE (tenant_id, credit_note_id)` (§7). `amount_minor` is incl-tax; +//! `recognized_part_minor` + `deferred_part_minor` are the ex-tax split parts +//! recorded by the `RecognizedDeferredSplitter` (Phase 1, Group B) — there is +//! **deliberately no** `recognized + deferred == amount` CHECK, because the parts +//! are ex-tax while `amount_minor` is incl-tax (they do not sum to it). Only the +//! nonneg CHECKs are enforced here; the headroom cap lives on +//! `invoice_exposure` (see `m20260626_000019`), and the schedule-reduction guard +//! (`recognized_minor <= total_deferred_minor`) lives on `recognition_schedule` +//! (Slice 4) — both written by the credit-note handler under the lock order. +//! +//! All CHECKs are created in final form up-front (Foundation §7.2). `SQLite` +//! mirrors the same shape with the systematic transforms (`uuid`→`text`, +//! `timestamptz`→`text`); the CHECKs + PK are preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &["CREATE TABLE bss.ledger_credit_note ( + tenant_id uuid NOT NULL, + credit_note_id varchar(128) NOT NULL, + origin_invoice_id varchar(128) NOT NULL, + origin_invoice_item_ref varchar(128), + revenue_stream varchar(64) NOT NULL, + currency varchar(16) NOT NULL, + amount_minor bigint NOT NULL, + recognized_part_minor bigint NOT NULL DEFAULT 0, + deferred_part_minor bigint NOT NULL DEFAULT 0, + split_basis_ref varchar(256), + reason_code varchar(64) NOT NULL, + created_at_utc timestamptz NOT NULL, + PRIMARY KEY (tenant_id, credit_note_id), + CONSTRAINT chk_ledger_credit_note_amount_nonneg + CHECK (amount_minor >= 0), + CONSTRAINT chk_ledger_credit_note_recognized_nonneg + CHECK (recognized_part_minor >= 0), + CONSTRAINT chk_ledger_credit_note_deferred_nonneg + CHECK (deferred_part_minor >= 0) + )"]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_credit_note"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; all CHECKs + PK preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE ledger_credit_note ( + tenant_id text NOT NULL, + credit_note_id varchar(128) NOT NULL, + origin_invoice_id varchar(128) NOT NULL, + origin_invoice_item_ref varchar(128), + revenue_stream varchar(64) NOT NULL, + currency varchar(16) NOT NULL, + amount_minor bigint NOT NULL, + recognized_part_minor bigint NOT NULL DEFAULT 0, + deferred_part_minor bigint NOT NULL DEFAULT 0, + split_basis_ref varchar(256), + reason_code varchar(64) NOT NULL, + created_at_utc text NOT NULL, + PRIMARY KEY (tenant_id, credit_note_id), + CONSTRAINT chk_ledger_credit_note_amount_nonneg + CHECK (amount_minor >= 0), + CONSTRAINT chk_ledger_credit_note_recognized_nonneg + CHECK (recognized_part_minor >= 0), + CONSTRAINT chk_ledger_credit_note_deferred_nonneg + CHECK (deferred_part_minor >= 0) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_credit_note"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000021_create_debit_note.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000021_create_debit_note.rs new file mode 100644 index 000000000..1823c740c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000021_create_debit_note.rs @@ -0,0 +1,119 @@ +//! Create the Slice 3 `ledger_debit_note` record table in schema `bss` — the +//! record linking a posted debit note (an additional charge) to its originating +//! posted invoice and its recognized/deferred split (design §7), keyed by +//! `(tenant_id, debit_note_id)`. +//! +//! `debit_note_id` is the **business** id (mirrors `recognition_schedule`'s +//! `schedule_id` — a `varchar(128)`, NOT a `uuid` column — so it lines up with +//! the `SecureORM` `resource_col`). The PK doubles as the design's +//! `UNIQUE (tenant_id, debit_note_id)` (§7). `amount_minor` is incl-tax; +//! `recognized_part_minor` + `deferred_part_minor` are the ex-tax split parts — +//! as with `credit_note`, there is **deliberately no** `recognized + deferred == +//! amount` CHECK (parts are ex-tax, `amount_minor` is incl-tax). A debit note +//! **raises** the invoice's headroom (`invoice_exposure.debit_note_total_minor +//! += amount`, see `m20260626_000019`) under the lock order; only the nonneg +//! CHECKs are enforced on this record table. +//! +//! All CHECKs are created in final form up-front (Foundation §7.2). `SQLite` +//! mirrors the same shape with the systematic transforms (`uuid`→`text`, +//! `timestamptz`→`text`); the CHECKs + PK are preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &["CREATE TABLE bss.ledger_debit_note ( + tenant_id uuid NOT NULL, + debit_note_id varchar(128) NOT NULL, + origin_invoice_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + amount_minor bigint NOT NULL, + recognized_part_minor bigint NOT NULL DEFAULT 0, + deferred_part_minor bigint NOT NULL DEFAULT 0, + created_at_utc timestamptz NOT NULL, + PRIMARY KEY (tenant_id, debit_note_id), + CONSTRAINT chk_ledger_debit_note_amount_nonneg + CHECK (amount_minor >= 0), + CONSTRAINT chk_ledger_debit_note_recognized_nonneg + CHECK (recognized_part_minor >= 0), + CONSTRAINT chk_ledger_debit_note_deferred_nonneg + CHECK (deferred_part_minor >= 0) + )"]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_debit_note"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; all CHECKs + PK preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE ledger_debit_note ( + tenant_id text NOT NULL, + debit_note_id varchar(128) NOT NULL, + origin_invoice_id varchar(128) NOT NULL, + currency varchar(16) NOT NULL, + amount_minor bigint NOT NULL, + recognized_part_minor bigint NOT NULL DEFAULT 0, + deferred_part_minor bigint NOT NULL DEFAULT 0, + created_at_utc text NOT NULL, + PRIMARY KEY (tenant_id, debit_note_id), + CONSTRAINT chk_ledger_debit_note_amount_nonneg + CHECK (amount_minor >= 0), + CONSTRAINT chk_ledger_debit_note_recognized_nonneg + CHECK (recognized_part_minor >= 0), + CONSTRAINT chk_ledger_debit_note_deferred_nonneg + CHECK (deferred_part_minor >= 0) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_debit_note"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000022_create_refund.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000022_create_refund.rs new file mode 100644 index 000000000..d1e60547d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000022_create_refund.rs @@ -0,0 +1,152 @@ +//! Create the Slice 3 Phase-2 `ledger_refund` record table in schema `bss` — the +//! record of a PSP refund's two-stage lifecycle (design §4.1 ERD / §7), keyed by +//! the surrogate `(tenant_id, refund_id)`. +//! +//! **PK vs UNIQUE.** `refund_id` is the **business/surrogate** id and the +//! `SecureORM` `resource_col`; like its sibling note tables (`credit_note` / +//! `debit_note`) it is a `varchar(128)` (NOT a `uuid` column) so it lines up with +//! the `resource_col`. The PK is the surrogate `(tenant_id, refund_id)`. The +//! **idempotency grain** is the natural `(tenant_id, psp_refund_id, phase)` +//! (design §7) — that is a SEPARATE `UNIQUE` index, NOT the PK: a single PSP +//! refund advances through several `phase` rows (`initiated → confirmed`, or +//! `rejected`/`voided`), and we want one row per `(psp_refund_id, phase)` for +//! idempotent phase-transition recording while keeping a stable single-column +//! surrogate handle for REST (`GET /refunds/{refundId}`) and for the +//! `relates_to_refund_id` self-reference. Hence surrogate PK + natural UNIQUE, +//! mirroring the design's `refund_id PK` + `UNIQUE (tenant_id, psp_refund_id, +//! phase)`. +//! +//! Both refund **patterns** carry the origin `payment_id` NOT NULL (design §9 D7 +//! assumption / Rev2 B-1); `invoice_id` is required for Pattern B (`B_RESTORE_AR`) +//! and NULL for Pattern A (`A_UNALLOCATED`). `clearing_state` tracks the two-stage +//! `REFUND_CLEARING` drain (`PENDING → SETTLED`, or `REVERSED` on PSP +//! reject/void). `reverses_entry_id` is set ONLY on the stage-1 line-negation when +//! the PSP rejected/voided an initiated refund (it references the negated journal +//! entry); `relates_to_refund_id` is the refund-of-refund forward link +//! (claw-back / additional-outbound). +//! +//! All CHECKs are created in final form up-front (Foundation §7.2). `SQLite` +//! mirrors the same shape with the systematic transforms (`uuid`→`text`, +//! `timestamptz`→`text`); the CHECKs + PK + UNIQUE index are preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_refund ( + tenant_id uuid NOT NULL, + refund_id varchar(128) NOT NULL, + psp_refund_id varchar(128) NOT NULL, + phase varchar(16) NOT NULL, + pattern varchar(16) NOT NULL, + payment_id varchar(128) NOT NULL, + invoice_id varchar(128), + currency varchar(16) NOT NULL, + amount_minor bigint NOT NULL, + clearing_state varchar(16) NOT NULL, + relates_to_refund_id varchar(128), + reverses_entry_id uuid, + created_at_utc timestamptz NOT NULL, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, refund_id), + CONSTRAINT chk_ledger_refund_phase CHECK (phase IN + ('initiated','confirmed','rejected','voided','unknown_final')), + CONSTRAINT chk_ledger_refund_pattern CHECK (pattern IN + ('A_UNALLOCATED','B_RESTORE_AR')), + CONSTRAINT chk_ledger_refund_clearing_state CHECK (clearing_state IN + ('PENDING','SETTLED','REVERSED')), + CONSTRAINT chk_ledger_refund_amount_nonneg CHECK (amount_minor >= 0) + )", + "CREATE UNIQUE INDEX uq_ledger_refund_psp_phase + ON bss.ledger_refund (tenant_id, psp_refund_id, phase)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_refund"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; all CHECKs + PK + UNIQUE index preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_refund ( + tenant_id text NOT NULL, + refund_id varchar(128) NOT NULL, + psp_refund_id varchar(128) NOT NULL, + phase varchar(16) NOT NULL, + pattern varchar(16) NOT NULL, + payment_id varchar(128) NOT NULL, + invoice_id varchar(128), + currency varchar(16) NOT NULL, + amount_minor bigint NOT NULL, + clearing_state varchar(16) NOT NULL, + relates_to_refund_id varchar(128), + reverses_entry_id text, + created_at_utc text NOT NULL, + version bigint NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, refund_id), + CONSTRAINT chk_ledger_refund_phase CHECK (phase IN + ('initiated','confirmed','rejected','voided','unknown_final')), + CONSTRAINT chk_ledger_refund_pattern CHECK (pattern IN + ('A_UNALLOCATED','B_RESTORE_AR')), + CONSTRAINT chk_ledger_refund_clearing_state CHECK (clearing_state IN + ('PENDING','SETTLED','REVERSED')), + CONSTRAINT chk_ledger_refund_amount_nonneg CHECK (amount_minor >= 0) + )", + "CREATE UNIQUE INDEX uq_ledger_refund_psp_phase + ON ledger_refund (tenant_id, psp_refund_id, phase)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_refund"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000023_refund_approval_kind.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000023_refund_approval_kind.rs new file mode 100644 index 000000000..38741be27 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000023_refund_approval_kind.rs @@ -0,0 +1,69 @@ +//! Forward-fix: add `REFUND` to the dual-control `ledger_approval.kind` CHECK +//! (Slice 3 Group D, VHP-1856) on clusters where `m20260624_000012` was ALREADY +//! applied with its original kind set. +//! +//! `m20260624_000012` was amended in place to add `REFUND` to the +//! `chk_ledger_approval_kind` CHECK (so a high-value refund can be parked PENDING). +//! In-place edits to an already-run migration do NOT re-apply (migrations run once, +//! by name), so a cluster that ran the original `000012` keeps the OLD CHECK — and +//! the new refund gate's `INSERT … kind = 'REFUND'` then trips that CHECK (a 500). +//! This migration brings such a cluster up to date; it is **idempotent** (DROP +//! CONSTRAINT IF EXISTS + re-create), so on a FRESH cluster — where `000012`'s +//! amended form already created the final shape — it is a harmless no-op +//! re-statement. +//! +//! **Postgres-only.** `SQLite` is non-production and always migrates fresh from the +//! amended `000012` (which already carries `REFUND` in the CHECK), so the `SQLite` +//! branch here is intentionally empty. (Re-stating a table-level CHECK on `SQLite` +//! would require a full table rebuild; it is unnecessary for the fresh-only +//! `SQLite` path, mirroring `m20260625_000013`.) + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const PG_UP_STATEMENTS: &[&str] = &[ + // Add the `REFUND` kind (Group D). DROP IF EXISTS first so a fresh cluster + // (where amended 000012 already created the final CHECK) re-creates cleanly. + "ALTER TABLE bss.ledger_approval DROP CONSTRAINT IF EXISTS chk_ledger_approval_kind", + "ALTER TABLE bss.ledger_approval ADD CONSTRAINT chk_ledger_approval_kind CHECK (kind IN + ('REVERSE','MATERIAL_BACKDATING','CREDIT_GRANT','CHARGEBACK_LOSS','PAYER_CLOSURE','PERIOD_REOPEN','RECOGNITION_SCHEDULE_CHANGE','REFUND'))", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_approval DROP CONSTRAINT IF EXISTS chk_ledger_approval_kind", + "ALTER TABLE bss.ledger_approval ADD CONSTRAINT chk_ledger_approval_kind CHECK (kind IN + ('REVERSE','MATERIAL_BACKDATING','CREDIT_GRANT','CHARGEBACK_LOSS','PAYER_CLOSURE','PERIOD_REOPEN','RECOGNITION_SCHEDULE_CHANGE'))", +]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + // Postgres-only: SQLite migrates fresh from the amended 000012. + if backend != sea_orm::DatabaseBackend::Postgres { + return Ok(()); + } + for sql in PG_UP_STATEMENTS { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + if backend != sea_orm::DatabaseBackend::Postgres { + return Ok(()); + } + for sql in PG_DOWN_STATEMENTS { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000024_manual_adjustment_approval_kind.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000024_manual_adjustment_approval_kind.rs new file mode 100644 index 000000000..9eadc3b6f --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000024_manual_adjustment_approval_kind.rs @@ -0,0 +1,71 @@ +//! Forward-fix: add `MANUAL_ADJUSTMENT` to the dual-control `ledger_approval.kind` +//! CHECK (Slice 3 Group 5 / Phase 3 governance, VHP-1856) on clusters where +//! `m20260624_000012` was ALREADY applied with its prior kind set. +//! +//! `m20260624_000012` was amended in place to add `MANUAL_ADJUSTMENT` to the +//! `chk_ledger_approval_kind` CHECK (so an over-threshold governed manual +//! adjustment can be parked PENDING). In-place edits to an already-run migration do +//! NOT re-apply (migrations run once, by name), so a cluster that ran the prior +//! `000012` keeps the OLD CHECK — and the new manual-adjustment gate's +//! `INSERT … kind = 'MANUAL_ADJUSTMENT'` then trips that CHECK (a 500). This +//! migration brings such a cluster up to date; it is **idempotent** (DROP +//! CONSTRAINT IF EXISTS + re-create), so on a FRESH cluster — where `000012`'s +//! amended form already created the final shape — it is a harmless no-op +//! re-statement. +//! +//! **Postgres-only.** `SQLite` is non-production and always migrates fresh from the +//! amended `000012` (which already carries `MANUAL_ADJUSTMENT` in the CHECK), so the +//! `SQLite` branch here is intentionally empty. (Re-stating a table-level CHECK on +//! `SQLite` would require a full table rebuild; it is unnecessary for the fresh-only +//! `SQLite` path, mirroring `m20260626_000023`.) + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const PG_UP_STATEMENTS: &[&str] = &[ + // Add the `MANUAL_ADJUSTMENT` kind (Group 5 / Phase 3). DROP IF EXISTS first so a + // fresh cluster (where amended 000012 already created the final CHECK) re-creates + // cleanly. + "ALTER TABLE bss.ledger_approval DROP CONSTRAINT IF EXISTS chk_ledger_approval_kind", + "ALTER TABLE bss.ledger_approval ADD CONSTRAINT chk_ledger_approval_kind CHECK (kind IN + ('REVERSE','MATERIAL_BACKDATING','CREDIT_GRANT','CHARGEBACK_LOSS','PAYER_CLOSURE','PERIOD_REOPEN','RECOGNITION_SCHEDULE_CHANGE','REFUND','MANUAL_ADJUSTMENT'))", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_approval DROP CONSTRAINT IF EXISTS chk_ledger_approval_kind", + "ALTER TABLE bss.ledger_approval ADD CONSTRAINT chk_ledger_approval_kind CHECK (kind IN + ('REVERSE','MATERIAL_BACKDATING','CREDIT_GRANT','CHARGEBACK_LOSS','PAYER_CLOSURE','PERIOD_REOPEN','RECOGNITION_SCHEDULE_CHANGE','REFUND'))", +]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + // Postgres-only: SQLite migrates fresh from the amended 000012. + if backend != sea_orm::DatabaseBackend::Postgres { + return Ok(()); + } + for sql in PG_UP_STATEMENTS { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + if backend != sea_orm::DatabaseBackend::Postgres { + return Ok(()); + } + for sql in PG_DOWN_STATEMENTS { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000025_note_approval_kinds.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000025_note_approval_kinds.rs new file mode 100644 index 000000000..e17d9592d --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260626_000025_note_approval_kinds.rs @@ -0,0 +1,67 @@ +//! Forward-fix: add `CREDIT_NOTE` + `DEBIT_NOTE` to the dual-control +//! `ledger_approval.kind` CHECK (Slice 3 Phase-1 dual-control follow-up, VHP-1856) +//! on clusters where `m20260624_000012` was ALREADY applied with its prior kind set. +//! +//! `m20260624_000012` was amended in place to add `CREDIT_NOTE` + `DEBIT_NOTE` to the +//! `chk_ledger_approval_kind` CHECK (so an over-threshold credit/debit note can be +//! parked PENDING — the §5 D1–D2 governance gate the Phase-1 as-built deferred). In-place +//! edits to an already-run migration do NOT re-apply (migrations run once, by name), so a +//! cluster that ran the prior `000012` keeps the OLD CHECK — and the new note gate's +//! `INSERT … kind = 'CREDIT_NOTE'` then trips that CHECK (a 500). This migration brings +//! such a cluster up to date; it is **idempotent** (DROP CONSTRAINT IF EXISTS + re-create), +//! so on a FRESH cluster — where `000012`'s amended form already created the final shape — +//! it is a harmless no-op re-statement. +//! +//! **Postgres-only.** `SQLite` is non-production and always migrates fresh from the +//! amended `000012` (which already carries both note kinds in the CHECK), so the `SQLite` +//! branch here is intentionally empty (mirroring `m20260626_000023` / `_000024`). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const PG_UP_STATEMENTS: &[&str] = &[ + // Add the `CREDIT_NOTE` + `DEBIT_NOTE` kinds. DROP IF EXISTS first so a fresh + // cluster (where amended 000012 already created the final CHECK) re-creates cleanly. + "ALTER TABLE bss.ledger_approval DROP CONSTRAINT IF EXISTS chk_ledger_approval_kind", + "ALTER TABLE bss.ledger_approval ADD CONSTRAINT chk_ledger_approval_kind CHECK (kind IN + ('REVERSE','MATERIAL_BACKDATING','CREDIT_GRANT','CHARGEBACK_LOSS','PAYER_CLOSURE','PERIOD_REOPEN','RECOGNITION_SCHEDULE_CHANGE','REFUND','MANUAL_ADJUSTMENT','CREDIT_NOTE','DEBIT_NOTE'))", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_approval DROP CONSTRAINT IF EXISTS chk_ledger_approval_kind", + "ALTER TABLE bss.ledger_approval ADD CONSTRAINT chk_ledger_approval_kind CHECK (kind IN + ('REVERSE','MATERIAL_BACKDATING','CREDIT_GRANT','CHARGEBACK_LOSS','PAYER_CLOSURE','PERIOD_REOPEN','RECOGNITION_SCHEDULE_CHANGE','REFUND','MANUAL_ADJUSTMENT'))", +]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + // Postgres-only: SQLite migrates fresh from the amended 000012. + if backend != sea_orm::DatabaseBackend::Postgres { + return Ok(()); + } + for sql in PG_UP_STATEMENTS { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + if backend != sea_orm::DatabaseBackend::Postgres { + return Ok(()); + } + for sql in PG_DOWN_STATEMENTS { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000026_create_fx_rate_tables.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000026_create_fx_rate_tables.rs new file mode 100644 index 000000000..12f8c6df9 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000026_create_fx_rate_tables.rs @@ -0,0 +1,149 @@ +//! Create the FX rate tables (Slice 5): the immutable per-lock +//! `ledger_fx_rate_snapshot` (frozen on each journal line via the +//! `rate_snapshot_ref` FK) and the mutable `ledger_fx_rate` "latest known" +//! local store that the `RateSyncJob` upserts and `RateSource` reads at lock +//! time (no synchronous provider call on the posting path). The snapshot table +//! is append-only — it reuses the P1 generic `bss.reject_mutation` guard on +//! UPDATE/DELETE so a provider revision can only INSERT a new row. `SQLite` +//! (non-production test backend) carries no triggers; immutability is +//! re-asserted in application code. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_fx_rate_snapshot ( + tenant_id uuid NOT NULL, + rate_id uuid NOT NULL, + base_currency varchar(16) NOT NULL, + quote_currency varchar(16) NOT NULL, + rate_micro bigint NOT NULL, + as_of timestamptz NOT NULL, + provider varchar(128) NOT NULL, + stale boolean NOT NULL DEFAULT false, + fallback_order integer NOT NULL DEFAULT 0, + triangulated_via varchar(128), + PRIMARY KEY (tenant_id, rate_id), + CONSTRAINT uq_fx_rate_snapshot_lock UNIQUE + (tenant_id, base_currency, quote_currency, provider, as_of, fallback_order) + )", + "CREATE INDEX idx_fx_rate_snapshot_pair + ON bss.ledger_fx_rate_snapshot (tenant_id, base_currency, quote_currency, as_of)", + // Append-only: a provider revision INSERTs a new row; UPDATE/DELETE reject. + // Reuses the generic guard defined by the P1 journal-tables migration. + "CREATE TRIGGER trg_fx_rate_snapshot_append_only + BEFORE UPDATE OR DELETE ON bss.ledger_fx_rate_snapshot + FOR EACH ROW EXECUTE FUNCTION bss.reject_mutation()", + // Mutable "latest known" store: upserted by the RateSyncJob / ingest endpoint; + // RateSource reads it at lock time (the per-lock freeze lands in the snapshot). + "CREATE TABLE bss.ledger_fx_rate ( + tenant_id uuid NOT NULL, + base_currency varchar(16) NOT NULL, + quote_currency varchar(16) NOT NULL, + provider varchar(128) NOT NULL, + rate_micro bigint NOT NULL, + as_of timestamptz NOT NULL, + fallback_order integer NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, base_currency, quote_currency, provider) + )", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS bss.ledger_fx_rate", + "DROP TRIGGER IF EXISTS trg_fx_rate_snapshot_append_only ON bss.ledger_fx_rate_snapshot", + "DROP TABLE IF EXISTS bss.ledger_fx_rate_snapshot", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`, `boolean`→numeric; no triggers). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_fx_rate_snapshot ( + tenant_id text NOT NULL, + rate_id text NOT NULL, + base_currency varchar(16) NOT NULL, + quote_currency varchar(16) NOT NULL, + rate_micro bigint NOT NULL, + as_of text NOT NULL, + provider varchar(128) NOT NULL, + stale boolean NOT NULL DEFAULT 0, + fallback_order integer NOT NULL DEFAULT 0, + triangulated_via varchar(128), + PRIMARY KEY (tenant_id, rate_id), + CONSTRAINT uq_fx_rate_snapshot_lock UNIQUE + (tenant_id, base_currency, quote_currency, provider, as_of, fallback_order) + )", + "CREATE INDEX idx_fx_rate_snapshot_pair + ON ledger_fx_rate_snapshot (tenant_id, base_currency, quote_currency, as_of)", + "CREATE TABLE ledger_fx_rate ( + tenant_id text NOT NULL, + base_currency varchar(16) NOT NULL, + quote_currency varchar(16) NOT NULL, + provider varchar(128) NOT NULL, + rate_micro bigint NOT NULL, + as_of text NOT NULL, + fallback_order integer NOT NULL DEFAULT 0, + updated_at text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (tenant_id, base_currency, quote_currency, provider) + )", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "DROP TABLE IF EXISTS ledger_fx_rate", + "DROP TABLE IF EXISTS ledger_fx_rate_snapshot", +]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000027_journal_line_rate_ref.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000027_journal_line_rate_ref.rs new file mode 100644 index 000000000..f2af72241 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000027_journal_line_rate_ref.rs @@ -0,0 +1,107 @@ +//! Add `journal_line.rate_snapshot_ref` (FK → `ledger_fx_rate_snapshot`, the +//! locked FX rate frozen on the line) and tighten the relaxed amount CHECK so a +//! functional-only line (`amount_minor = 0`) must carry a POSITIVE +//! `functional_amount_minor` (the DR/CR side carries the sign; a zero/negative +//! functional-only line is a posting bug). `journal_line` is append-only; +//! `ADD COLUMN` / `ADD CONSTRAINT` are DDL, not row mutations, so the seal +//! trigger does not block them (see the P1 `ar_status` migration). +//! +//! `SQLite` (non-production test backend) cannot DROP a table-level CHECK +//! without a full table rebuild, so the tightening is Postgres-only there and +//! the FK is omitted; the column is added on both backends, and the app-level +//! balance guard re-asserts the `> 0` rule on `SQLite`. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_journal_line ADD COLUMN rate_snapshot_ref uuid", + // Composite FK (tenant-scoped) to the immutable snapshot. Nullable ref → + // MATCH SIMPLE skips the check when rate_snapshot_ref IS NULL (single-currency). + // `NOT VALID` on both adds: existing rows are known-valid (legacy lines carry a + // NULL `rate_snapshot_ref` — the FK is MATCH SIMPLE, skipped on NULL — and a + // positive `amount_minor`, satisfying the tightened CHECK), so Postgres skips + // the validating full-table scan (no ACCESS EXCLUSIVE rewrite on a large + // append-only journal). Both constraints still enforce every NEW write. + "ALTER TABLE bss.ledger_journal_line + ADD CONSTRAINT fk_journal_line_rate_snapshot + FOREIGN KEY (tenant_id, rate_snapshot_ref) + REFERENCES bss.ledger_fx_rate_snapshot (tenant_id, rate_id) NOT VALID", + "ALTER TABLE bss.ledger_journal_line DROP CONSTRAINT chk_journal_line_amount", + "ALTER TABLE bss.ledger_journal_line + ADD CONSTRAINT chk_journal_line_amount + CHECK (amount_minor > 0 OR (amount_minor = 0 AND functional_amount_minor > 0)) NOT VALID", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_journal_line DROP CONSTRAINT IF EXISTS chk_journal_line_amount", + // Restore the P1 relaxed form (IS NOT NULL). + "ALTER TABLE bss.ledger_journal_line + ADD CONSTRAINT chk_journal_line_amount + CHECK (amount_minor > 0 OR (amount_minor = 0 AND functional_amount_minor IS NOT NULL))", + "ALTER TABLE bss.ledger_journal_line DROP CONSTRAINT IF EXISTS fk_journal_line_rate_snapshot", + "ALTER TABLE bss.ledger_journal_line DROP COLUMN IF EXISTS rate_snapshot_ref", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (column add only; the table-level +// CHECK tighten + FK are Postgres-only — SQLite cannot ALTER them in place). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = + &["ALTER TABLE ledger_journal_line ADD COLUMN rate_snapshot_ref text"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = + &["ALTER TABLE ledger_journal_line DROP COLUMN rate_snapshot_ref"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000028_wide_cache_functional_cols.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000028_wide_cache_functional_cols.rs new file mode 100644 index 000000000..9aed8fc8c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000028_wide_cache_functional_cols.rs @@ -0,0 +1,102 @@ +//! Add the dual-currency functional columns (`functional_balance_minor`, +//! `functional_currency`) to the three wide balance caches that lacked them +//! (`account_balance`, `ar_invoice_balance`, `ar_payer_balance`). The two +//! narrow caches `unallocated_balance` / `reusable_credit_subbalance` already +//! carry them from P1. Populated by the `BalanceProjector` on cross-currency +//! posts (Slice 5 Phase 1, group B); left NULL on single-currency grains, where +//! the functional balance equals the transaction balance by identity. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_account_balance ADD COLUMN functional_balance_minor bigint", + "ALTER TABLE bss.ledger_account_balance ADD COLUMN functional_currency varchar(16)", + "ALTER TABLE bss.ledger_ar_invoice_balance ADD COLUMN functional_balance_minor bigint", + "ALTER TABLE bss.ledger_ar_invoice_balance ADD COLUMN functional_currency varchar(16)", + "ALTER TABLE bss.ledger_ar_payer_balance ADD COLUMN functional_balance_minor bigint", + "ALTER TABLE bss.ledger_ar_payer_balance ADD COLUMN functional_currency varchar(16)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_ar_payer_balance DROP COLUMN IF EXISTS functional_currency", + "ALTER TABLE bss.ledger_ar_payer_balance DROP COLUMN IF EXISTS functional_balance_minor", + "ALTER TABLE bss.ledger_ar_invoice_balance DROP COLUMN IF EXISTS functional_currency", + "ALTER TABLE bss.ledger_ar_invoice_balance DROP COLUMN IF EXISTS functional_balance_minor", + "ALTER TABLE bss.ledger_account_balance DROP COLUMN IF EXISTS functional_currency", + "ALTER TABLE bss.ledger_account_balance DROP COLUMN IF EXISTS functional_balance_minor", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `varchar`→text affinity). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "ALTER TABLE ledger_account_balance ADD COLUMN functional_balance_minor bigint", + "ALTER TABLE ledger_account_balance ADD COLUMN functional_currency varchar(16)", + "ALTER TABLE ledger_ar_invoice_balance ADD COLUMN functional_balance_minor bigint", + "ALTER TABLE ledger_ar_invoice_balance ADD COLUMN functional_currency varchar(16)", + "ALTER TABLE ledger_ar_payer_balance ADD COLUMN functional_balance_minor bigint", + "ALTER TABLE ledger_ar_payer_balance ADD COLUMN functional_currency varchar(16)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE ledger_ar_payer_balance DROP COLUMN functional_currency", + "ALTER TABLE ledger_ar_payer_balance DROP COLUMN functional_balance_minor", + "ALTER TABLE ledger_ar_invoice_balance DROP COLUMN functional_currency", + "ALTER TABLE ledger_ar_invoice_balance DROP COLUMN functional_balance_minor", + "ALTER TABLE ledger_account_balance DROP COLUMN functional_currency", + "ALTER TABLE ledger_account_balance DROP COLUMN functional_balance_minor", +]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000029_dual_column_commit_check.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000029_dual_column_commit_check.rs new file mode 100644 index 000000000..bc1c33d41 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000029_dual_column_commit_check.rs @@ -0,0 +1,195 @@ +//! Extend the `check_entry_balanced` constraint-trigger function with the FX +//! dual-column functional-balance assertion (Slice 5, spec §2). The existing +//! transaction-balance checks (empty / mixed-payer / currency-mismatch / +//! per-(currency,scale) zero-sum) are reproduced verbatim from the P1 +//! journal-tables migration; only the functional block is added. `CREATE OR +//! REPLACE FUNCTION` updates the body in place — the existing deferred +//! constraint trigger `trg_journal_entry_balanced` keeps pointing at it. +//! +//! NULL-aware so existing single-currency posts (every line's +//! `functional_amount_minor` NULL) stay byte-green: let +//! `f = count(functional NOT NULL)`. `f = 0` → skip (single-currency). +//! `f = line_count` → enforce `SUM(DR.functional) = SUM(CR.functional)`. +//! `0 < f < line_count` → RAISE (a partial-functional entry is a posting bug — +//! fail loud, never silently imbalance). Postgres-only: `SQLite` carries no +//! triggers, so the application-level `validate_balanced_entry` re-asserts the +//! same NULL-aware rule (Phase 1, group B). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — replace the function body (trigger unchanged). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE OR REPLACE FUNCTION bss.check_entry_balanced() RETURNS trigger AS $$ + DECLARE + line_count int; + payer_count int; + currency_mismatch int; + func_count int; + unbalanced int; + func_imbalance bigint; + BEGIN + SELECT count(*), + count(DISTINCT l.payer_tenant_id), + count(*) FILTER (WHERE l.currency <> NEW.entry_currency + AND NOT (l.amount_minor = 0 + AND l.functional_amount_minor IS NOT NULL)), + count(*) FILTER (WHERE l.functional_amount_minor IS NOT NULL) + INTO line_count, payer_count, currency_mismatch, func_count + FROM bss.ledger_journal_line l + WHERE (l.tenant_id, l.period_id, l.entry_id) + = (NEW.tenant_id, NEW.period_id, NEW.entry_id); + + IF line_count < 1 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_EMPTY entry=%', NEW.entry_id; + END IF; + IF payer_count > 1 THEN + RAISE EXCEPTION 'MIXED_PAYER_TENANT entry=%', NEW.entry_id; + END IF; + IF currency_mismatch > 0 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_CURRENCY_MISMATCH entry=%', NEW.entry_id; + END IF; + + -- zero-sum per (currency, currency_scale), exact (zero tolerance) + SELECT count(*) INTO unbalanced FROM ( + SELECT 1 + FROM bss.ledger_journal_line l + WHERE (l.tenant_id, l.period_id, l.entry_id) + = (NEW.tenant_id, NEW.period_id, NEW.entry_id) + GROUP BY l.currency, l.currency_scale + HAVING sum(CASE WHEN l.side = 'DR' THEN l.amount_minor + ELSE -l.amount_minor END) <> 0 + ) u; + IF unbalanced > 0 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_UNBALANCED entry=%', NEW.entry_id; + END IF; + + -- FX dual-column functional balance (NULL-aware; Slice 5 decision 8). + -- func_count = 0 -> single-currency entry: skip. + -- 0 < func_count < line_count -> partial-functional posting bug: reject. + -- func_count = line_count -> enforce SUM(DR.functional) = SUM(CR.functional). + IF func_count > 0 AND func_count < line_count THEN + RAISE EXCEPTION 'LEDGER_ENTRY_FUNCTIONAL_PARTIAL entry=%', NEW.entry_id; + END IF; + IF func_count = line_count THEN + SELECT coalesce(sum(CASE WHEN l.side = 'DR' THEN l.functional_amount_minor + ELSE -l.functional_amount_minor END), 0) + INTO func_imbalance + FROM bss.ledger_journal_line l + WHERE (l.tenant_id, l.period_id, l.entry_id) + = (NEW.tenant_id, NEW.period_id, NEW.entry_id); + IF func_imbalance <> 0 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_FUNCTIONAL_UNBALANCED entry=%', NEW.entry_id; + END IF; + END IF; + + RETURN NULL; + END; + $$ LANGUAGE plpgsql", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + // Restore the P1 transaction-only body (no functional block). + "CREATE OR REPLACE FUNCTION bss.check_entry_balanced() RETURNS trigger AS $$ + DECLARE + line_count int; + payer_count int; + currency_mismatch int; + unbalanced int; + BEGIN + SELECT count(*), + count(DISTINCT l.payer_tenant_id), + count(*) FILTER (WHERE l.currency <> NEW.entry_currency + AND NOT (l.amount_minor = 0 + AND l.functional_amount_minor IS NOT NULL)) + INTO line_count, payer_count, currency_mismatch + FROM bss.ledger_journal_line l + WHERE (l.tenant_id, l.period_id, l.entry_id) + = (NEW.tenant_id, NEW.period_id, NEW.entry_id); + + IF line_count < 1 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_EMPTY entry=%', NEW.entry_id; + END IF; + IF payer_count > 1 THEN + RAISE EXCEPTION 'MIXED_PAYER_TENANT entry=%', NEW.entry_id; + END IF; + IF currency_mismatch > 0 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_CURRENCY_MISMATCH entry=%', NEW.entry_id; + END IF; + + SELECT count(*) INTO unbalanced FROM ( + SELECT 1 + FROM bss.ledger_journal_line l + WHERE (l.tenant_id, l.period_id, l.entry_id) + = (NEW.tenant_id, NEW.period_id, NEW.entry_id) + GROUP BY l.currency, l.currency_scale + HAVING sum(CASE WHEN l.side = 'DR' THEN l.amount_minor + ELSE -l.amount_minor END) <> 0 + ) u; + IF unbalanced > 0 THEN + RAISE EXCEPTION 'LEDGER_ENTRY_UNBALANCED entry=%', NEW.entry_id; + END IF; + + RETURN NULL; + END; + $$ LANGUAGE plpgsql", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — no triggers (the app-level validate_balanced_entry covers it). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000030_fiscal_calendar_functional_ccy.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000030_fiscal_calendar_functional_ccy.rs new file mode 100644 index 000000000..37eb28ec1 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000030_fiscal_calendar_functional_ccy.rs @@ -0,0 +1,75 @@ +//! Add the legal-entity **functional currency** (`functional_currency`) to +//! `ledger_fiscal_calendar` (Slice 5 — the S5-F3 functional-currency source). +//! +//! The functional currency is per-legal-entity accounting config (F5: one per +//! LE), so it rides the existing per-`(tenant, legal_entity)` calendar row +//! alongside the timezone / granularity / FY-start. **Nullable**: a tenant with no +//! configured functional currency is treated as single-currency (the +//! `RateLocker` short-circuits, functional stays NULL, byte-green as today); FX +//! activates per-tenant once a functional currency is seeded and a transaction +//! currency differs from it. +//! +//! Design §S5-F3 places the source-of-truth in account-management, but AM models +//! no accounting currency and is upstream (gears-rust); the ledger therefore owns +//! it as provisioning reference data (the FX analogue of the §0.1 / Slice-4 +//! Variant-A "the gear completes the substrate the design assumed upstream"). +//! Additive nullable column → no table rebuild on either backend. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const PG_UP_STATEMENTS: &[&str] = + &["ALTER TABLE bss.ledger_fiscal_calendar ADD COLUMN functional_currency varchar(16)"]; + +const PG_DOWN_STATEMENTS: &[&str] = + &["ALTER TABLE bss.ledger_fiscal_calendar DROP COLUMN IF EXISTS functional_currency"]; + +const SQLITE_UP_STATEMENTS: &[&str] = + &["ALTER TABLE ledger_fiscal_calendar ADD COLUMN functional_currency varchar(16)"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = + &["ALTER TABLE ledger_fiscal_calendar DROP COLUMN functional_currency"]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000031_cache_functional_consistency.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000031_cache_functional_consistency.rs new file mode 100644 index 000000000..0b692a795 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000031_cache_functional_consistency.rs @@ -0,0 +1,119 @@ +//! Safety-net invariant (Slice 5 remediation): on every functional-bearing +//! balance cache, the two FX columns must be set together — +//! `functional_balance_minor IS NULL` **iff** `functional_currency IS NULL`. +//! +//! Rationale: a cross-currency grain carries BOTH columns; a single-currency +//! grain carries NEITHER (functional ≡ transaction by identity, decision 8). The +//! one way to violate that is the drift bug this remediation closes — a +//! transaction-only post (reversal / settlement-return / claw-back that dropped +//! the functional column) relieving a cross-currency grain: the projector's +//! conflict path nets `balance_minor + delta` while overwriting +//! `functional_currency` to NULL and leaving `functional_balance_minor` frozen, +//! producing a `(balance Some, currency NULL)` row. This CHECK rejects exactly +//! that row, turning a SILENT transaction-vs-functional drift into a loud, +//! transactional abort — the backstop behind the per-path carry-forward fixes. +//! +//! Added `NOT VALID`: every legacy / single-currency row already satisfies the +//! predicate (both columns NULL by construction — they predate or opt out of the +//! functional translation), so the validating full-table scan is skipped (no +//! `ACCESS EXCLUSIVE` table rewrite on a large cache); the constraint still +//! enforces every NEW write, which is all the drift guard needs. +//! +//! Postgres-only: `SQLite` (the non-production test backend) cannot +//! `ADD CONSTRAINT` to an existing table without a full rebuild (mirrors the +//! m027 / m029 split); the real backend the FX postgres tests run against is +//! Postgres. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_account_balance + ADD CONSTRAINT chk_account_balance_func_consistency + CHECK ((functional_balance_minor IS NULL) = (functional_currency IS NULL)) NOT VALID", + "ALTER TABLE bss.ledger_ar_invoice_balance + ADD CONSTRAINT chk_ar_invoice_balance_func_consistency + CHECK ((functional_balance_minor IS NULL) = (functional_currency IS NULL)) NOT VALID", + "ALTER TABLE bss.ledger_ar_payer_balance + ADD CONSTRAINT chk_ar_payer_balance_func_consistency + CHECK ((functional_balance_minor IS NULL) = (functional_currency IS NULL)) NOT VALID", + "ALTER TABLE bss.ledger_unallocated_balance + ADD CONSTRAINT chk_unallocated_balance_func_consistency + CHECK ((functional_balance_minor IS NULL) = (functional_currency IS NULL)) NOT VALID", + "ALTER TABLE bss.ledger_reusable_credit_subbalance + ADD CONSTRAINT chk_reusable_credit_func_consistency + CHECK ((functional_balance_minor IS NULL) = (functional_currency IS NULL)) NOT VALID", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_reusable_credit_subbalance + DROP CONSTRAINT IF EXISTS chk_reusable_credit_func_consistency", + "ALTER TABLE bss.ledger_unallocated_balance + DROP CONSTRAINT IF EXISTS chk_unallocated_balance_func_consistency", + "ALTER TABLE bss.ledger_ar_payer_balance + DROP CONSTRAINT IF EXISTS chk_ar_payer_balance_func_consistency", + "ALTER TABLE bss.ledger_ar_invoice_balance + DROP CONSTRAINT IF EXISTS chk_ar_invoice_balance_func_consistency", + "ALTER TABLE bss.ledger_account_balance + DROP CONSTRAINT IF EXISTS chk_account_balance_func_consistency", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema: no table-level CHECK add in place. +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000032_snapshot_identity_rate_micro.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000032_snapshot_identity_rate_micro.rs new file mode 100644 index 000000000..939543747 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000032_snapshot_identity_rate_micro.rs @@ -0,0 +1,102 @@ +//! Slice 5 remediation (codex#1): widen the immutable snapshot identity +//! `uq_fx_rate_snapshot_lock` to include `rate_micro`. +//! +//! The lock key was `(tenant, base, quote, provider, as_of, fallback_order)` — +//! it did NOT include the rate value. The `ledger_fx_rate` "latest known" store +//! is keyed `(tenant, base, quote, provider)` and `upsert_rate` OVERWRITES +//! `rate_micro` + `as_of` on conflict, and the ingest endpoint is idempotent on +//! `(tenant, base, quote, provider, as_of)` — so a provider/manual CORRECTION of +//! the rate at the SAME `as_of` is a supported operation. Under the old key the +//! snapshot freeze (read-first dedupe + this UNIQUE) would REUSE the prior +//! snapshot row for the corrected rate: the posting path translates lines at the +//! corrected `rate_micro` but stamps a `rate_snapshot_ref` pointing at the STALE +//! rate, so the audit snapshot no longer reproduces the posted functional amount. +//! +//! Adding `rate_micro` to the identity makes a corrected rate freeze a NEW, +//! distinct snapshot (a fresh `rate_id`) while an identical re-lock (same rate) +//! still dedupes — the audit snapshot always reproduces the rate that was posted. +//! The repo's read-first lookup is widened with `rate_micro` in lockstep. +//! +//! Postgres-only: the constraint is a named table constraint there, droppable + +//! re-addable in place; `SQLite` (non-production test backend) declares it inline +//! in `CREATE TABLE` and cannot ALTER it without a full table rebuild (mirrors the +//! m027 / m029 / m031 split). The repo lookup change applies on both backends. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — drop + re-add the named UNIQUE with `rate_micro` appended. +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_fx_rate_snapshot + DROP CONSTRAINT uq_fx_rate_snapshot_lock", + "ALTER TABLE bss.ledger_fx_rate_snapshot + ADD CONSTRAINT uq_fx_rate_snapshot_lock UNIQUE + (tenant_id, base_currency, quote_currency, provider, as_of, fallback_order, rate_micro)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "ALTER TABLE bss.ledger_fx_rate_snapshot + DROP CONSTRAINT uq_fx_rate_snapshot_lock", + "ALTER TABLE bss.ledger_fx_rate_snapshot + ADD CONSTRAINT uq_fx_rate_snapshot_lock UNIQUE + (tenant_id, base_currency, quote_currency, provider, as_of, fallback_order)", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — inline CREATE-TABLE constraint, not alterable in place. +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &[]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000033_create_period_close.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000033_create_period_close.rs new file mode 100644 index 000000000..0c887accd --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000033_create_period_close.rs @@ -0,0 +1,107 @@ +//! Create the period-close process table in schema `bss`: +//! `ledger_period_close` (one row per `(tenant_id, legal_entity_id, period_id)`) +//! — the owner of the close *process* lifecycle (OPEN → CLOSING → CLOSED → +//! REOPENED) that the Foundation `fiscal_period.status` posting-gate flips +//! alongside (Slice 7, design §4.1/§4.5/§7). During CLOSING the `fiscal_period` +//! row stays OPEN (concurrent posts still allowed under `FOR SHARE`); the flip +//! commits `period_close CLOSING→CLOSED` and `fiscal_period OPEN→CLOSED` in the +//! same txn. `blocked_reasons` records the last gate result; `recon_watermark` +//! is the max in-period `created_seq` the CLOSING recompute covered. +//! `SQLite` (non-production test backend) mirrors the shape with the systematic +//! transforms (`uuid`→`text`, `jsonb`→`text`, `bigint`→`integer`, +//! `timestamptz`→`text`, no `bss.` prefix). The CHECK, PK are preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &["CREATE TABLE bss.ledger_period_close ( + tenant_id uuid NOT NULL, + legal_entity_id uuid NOT NULL, + period_id varchar(64) NOT NULL, + status varchar(16) NOT NULL, + initiated_by varchar(256) NOT NULL, + blocked_reasons jsonb, + recon_watermark bigint, + reopen_approval_id uuid, + reopened_by varchar(256), + closed_at timestamptz, + PRIMARY KEY (tenant_id, legal_entity_id, period_id), + CONSTRAINT chk_ledger_period_close_status + CHECK (status IN ('OPEN','CLOSING','CLOSED','REOPENED')) + )"]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_period_close"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE ledger_period_close ( + tenant_id text NOT NULL, + legal_entity_id text NOT NULL, + period_id varchar(64) NOT NULL, + status varchar(16) NOT NULL, + initiated_by varchar(256) NOT NULL, + blocked_reasons text, + recon_watermark integer, + reopen_approval_id text, + reopened_by varchar(256), + closed_at text, + PRIMARY KEY (tenant_id, legal_entity_id, period_id), + CONSTRAINT chk_ledger_period_close_status + CHECK (status IN ('OPEN','CLOSING','CLOSED','REOPENED')) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_period_close"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000034_create_exception_queue.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000034_create_exception_queue.rs new file mode 100644 index 000000000..1ee7209ed --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000034_create_exception_queue.rs @@ -0,0 +1,128 @@ +//! Create the durable, close-blocking exception queue in schema `bss`: +//! `ledger_exception_queue` (one row per open exception, keyed by +//! `(tenant_id, exception_id)`) — Slice 7 (design §4.6/§7). The per-slice +//! exception *stubs* (stuck-refund-clearing, clawback-underflow, +//! credit-note-split-blocked, chargeback-on-refunded, …) and the reconciliation +//! framework open rows here; the period-close gate blocks while any OPEN +//! close-blocking row exists for the period. `GL_WRITEOFF_VARIANCE` is the one +//! type Finance acknowledges → `APPROVED_EXCEPTION` (then non-blocking). +//! `SQLite` mirrors the shape (`uuid`→`text`, `jsonb`→`text`, +//! `timestamptz`→`text`, no `bss.` prefix); CHECKs, PK, indexes (incl. the +//! partial OPEN index) preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_exception_queue ( + tenant_id uuid NOT NULL, + exception_id uuid NOT NULL, + exception_type varchar(48) NOT NULL, + business_ref varchar(256) NOT NULL, + status varchar(24) NOT NULL DEFAULT 'OPEN', + period_id varchar(64), + detail jsonb, + opened_at timestamptz NOT NULL DEFAULT now(), + resolved_at timestamptz, + resolved_by varchar(256), + PRIMARY KEY (tenant_id, exception_id), + CONSTRAINT chk_ledger_exception_queue_type CHECK (exception_type IN ( + 'SETTLED_NO_MATCH','MAPPING_GAP','RECON_MISMATCH','PSP_VARIANCE', + 'SPLIT_AMBIGUOUS','RECOGNITION_POLICY_CONFLICT','UNSCHEDULED_DEFERRAL', + 'STUCK_REFUND_CLEARING','SETTLEMENT_RETURN_OVER_ALLOCATED', + 'CHARGEBACK_ON_REFUNDED','GL_WRITEOFF_VARIANCE','MISSED_POSTING')), + CONSTRAINT chk_ledger_exception_queue_status CHECK (status IN ( + 'OPEN','ACK','RESOLVED','APPROVED_EXCEPTION')) + )", + "CREATE INDEX ledger_exception_queue_open_idx + ON bss.ledger_exception_queue (tenant_id, period_id) WHERE status = 'OPEN'", + "CREATE INDEX ledger_exception_queue_type_idx + ON bss.ledger_exception_queue (tenant_id, exception_type, status)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_exception_queue"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_exception_queue ( + tenant_id text NOT NULL, + exception_id text NOT NULL, + exception_type varchar(48) NOT NULL, + business_ref varchar(256) NOT NULL, + status varchar(24) NOT NULL DEFAULT 'OPEN', + period_id varchar(64), + detail text, + opened_at text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + resolved_at text, + resolved_by varchar(256), + PRIMARY KEY (tenant_id, exception_id), + CONSTRAINT chk_ledger_exception_queue_type CHECK (exception_type IN ( + 'SETTLED_NO_MATCH','MAPPING_GAP','RECON_MISMATCH','PSP_VARIANCE', + 'SPLIT_AMBIGUOUS','RECOGNITION_POLICY_CONFLICT','UNSCHEDULED_DEFERRAL', + 'STUCK_REFUND_CLEARING','SETTLEMENT_RETURN_OVER_ALLOCATED', + 'CHARGEBACK_ON_REFUNDED','GL_WRITEOFF_VARIANCE','MISSED_POSTING')), + CONSTRAINT chk_ledger_exception_queue_status CHECK (status IN ( + 'OPEN','ACK','RESOLVED','APPROVED_EXCEPTION')) + )", + "CREATE INDEX ledger_exception_queue_open_idx + ON ledger_exception_queue (tenant_id, period_id) WHERE status = 'OPEN'", + "CREATE INDEX ledger_exception_queue_type_idx + ON ledger_exception_queue (tenant_id, exception_type, status)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_exception_queue"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000035_create_reconciliation_run.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000035_create_reconciliation_run.rs new file mode 100644 index 000000000..bd88b82a8 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000035_create_reconciliation_run.rs @@ -0,0 +1,117 @@ +//! Create the reconciliation-run table in schema `bss`: +//! `ledger_reconciliation_run` (one row per check execution, keyed by +//! `(tenant_id, run_id)`) — Slice 7 (design §4.3/§7). Records the variance of an +//! AR↔derived / Payments↔PSP / invoice-completeness check for a period; an +//! out-of-tolerance run opens an `exception_queue` row and feeds the close gate. +//! `watermark` is the max in-period `created_seq` the run covered (the close +//! flip checks it). (Ledger↔ERP / GL check types are deferred with ERP export, +//! VHP-1948.) `SQLite` mirrors the shape (`uuid`→`text`, `jsonb`→`text`, +//! `boolean`→`boolean DEFAULT 1`, `timestamptz`→`text`, no `bss.` prefix); +//! CHECKs, PK, index preserved. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_reconciliation_run ( + tenant_id uuid NOT NULL, + run_id uuid NOT NULL, + period_id varchar(64) NOT NULL, + check_type varchar(32) NOT NULL, + variance_minor bigint NOT NULL DEFAULT 0, + within_tolerance boolean NOT NULL DEFAULT true, + status varchar(16) NOT NULL, + watermark bigint, + detail jsonb, + at_utc timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, run_id), + CONSTRAINT chk_ledger_reconciliation_run_check_type CHECK (check_type IN ( + 'AR_DERIVED','PAYMENTS_PSP','INVOICE_COMPLETENESS')), + CONSTRAINT chk_ledger_reconciliation_run_status CHECK (status IN ( + 'RUNNING','DONE','FAILED')) + )", + "CREATE INDEX ledger_reconciliation_run_period_idx + ON bss.ledger_reconciliation_run (tenant_id, period_id, check_type)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_reconciliation_run"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema for fast tests / dev. +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_reconciliation_run ( + tenant_id text NOT NULL, + run_id text NOT NULL, + period_id varchar(64) NOT NULL, + check_type varchar(32) NOT NULL, + variance_minor bigint NOT NULL DEFAULT 0, + within_tolerance boolean NOT NULL DEFAULT 1, + status varchar(16) NOT NULL, + watermark bigint, + detail text, + at_utc text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (tenant_id, run_id), + CONSTRAINT chk_ledger_reconciliation_run_check_type CHECK (check_type IN ( + 'AR_DERIVED','PAYMENTS_PSP','INVOICE_COMPLETENESS')), + CONSTRAINT chk_ledger_reconciliation_run_status CHECK (status IN ( + 'RUNNING','DONE','FAILED')) + )", + "CREATE INDEX ledger_reconciliation_run_period_idx + ON ledger_reconciliation_run (tenant_id, period_id, check_type)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_reconciliation_run"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000036_exception_queue_open_uniq.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000036_exception_queue_open_uniq.rs new file mode 100644 index 000000000..ebed97727 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260628_000036_exception_queue_open_uniq.rs @@ -0,0 +1,69 @@ +//! m036: partial UNIQUE index on `ledger_exception_queue` enforcing AT-MOST-ONE OPEN +//! row per `(tenant_id, exception_type, business_ref)`. Backs the fire-and-forget dedup +//! in `ExceptionRouter` (the `exists_open_for_ref` check + insert is racy under default +//! isolation: two concurrent routes could both insert). With the index, the loser's +//! insert fails on the unique constraint and the fire-and-forget route swallows it, so +//! exactly one OPEN row survives. +//! +//! Dual-backend (`PG` `bss`-qualified / `SQLite` bare). The table is new in m034 and carries +//! no production data yet, so the unique index cannot fail on existing duplicates at +//! creation. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE UNIQUE INDEX ledger_exception_queue_open_uniq + ON bss.ledger_exception_queue (tenant_id, exception_type, business_ref) WHERE status = 'OPEN'", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP INDEX IF EXISTS bss.ledger_exception_queue_open_uniq"]; + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE UNIQUE INDEX ledger_exception_queue_open_uniq + ON ledger_exception_queue (tenant_id, exception_type, business_ref) WHERE status = 'OPEN'"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP INDEX IF EXISTS ledger_exception_queue_open_uniq"]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000037_create_posting_policy.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000037_create_posting_policy.rs new file mode 100644 index 000000000..25f5e67b0 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000037_create_posting_policy.rs @@ -0,0 +1,109 @@ +//! Create the effective-dated posting-policy table in schema `bss`: +//! `tenant_posting_policy` (per-tenant, append-only `version`ed rows that pin the +//! two invoice-posting policies the design names tenant-overridable, VHP-1853): +//! the missing-mapping mode (`SUSPENSE` route-to-suspense default vs `HARD_BLOCK`) +//! and the AR-aging bucket thresholds (CSV upper-bounds, e.g. `30,60,90`). The +//! orchestrator / aging read resolves the row in effect at decision time (latest +//! `effective_from <= now`, highest `version` on a tie); absent a row the gear's +//! built-in defaults apply (`SUSPENSE` + `30,60,90`, the prior hardcoded +//! behaviour). Tenant scoping is via `SecureORM` at query time (no PG RLS policy +//! block — same as the other policy tables). `SQLite` mirrors the same shape with +//! the systematic transforms (`uuid`→`text`, `timestamptz`→`text`). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_tenant_posting_policy ( + tenant_id uuid NOT NULL, + version bigint NOT NULL, + effective_from timestamptz NOT NULL, + missing_mapping_mode varchar(16) NOT NULL, + ar_aging_thresholds varchar(128) NOT NULL, + created_at_utc timestamptz NOT NULL, + PRIMARY KEY (tenant_id, version), + CONSTRAINT chk_posting_policy_version_nonneg CHECK (version >= 0), + CONSTRAINT chk_posting_policy_missing_mapping_mode + CHECK (missing_mapping_mode IN ('SUSPENSE', 'HARD_BLOCK')) + )", + "CREATE INDEX ix_posting_policy_effective + ON bss.ledger_tenant_posting_policy (tenant_id, effective_from)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_tenant_posting_policy"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; the CHECKs + PK + index preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_tenant_posting_policy ( + tenant_id text NOT NULL, + version bigint NOT NULL, + effective_from text NOT NULL, + missing_mapping_mode varchar(16) NOT NULL, + ar_aging_thresholds varchar(128) NOT NULL, + created_at_utc text NOT NULL, + PRIMARY KEY (tenant_id, version), + CONSTRAINT chk_posting_policy_version_nonneg CHECK (version >= 0), + CONSTRAINT chk_posting_policy_missing_mapping_mode + CHECK (missing_mapping_mode IN ('SUSPENSE', 'HARD_BLOCK')) + )", + "CREATE INDEX ix_posting_policy_effective + ON ledger_tenant_posting_policy (tenant_id, effective_from)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_tenant_posting_policy"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000038_create_verified_balance.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000038_create_verified_balance.rs new file mode 100644 index 000000000..302015f44 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000038_create_verified_balance.rs @@ -0,0 +1,118 @@ +//! Create the cumulative tie-out baseline table in schema `bss`: +//! `ledger_verified_balance` (VHP-1843 incremental tie-out). One row per +//! `(tenant_id, grain, grain_key)` holding the cumulative VERIFIED balance +//! through the last closed period. Written atomically in the period-close +//! SERIALIZABLE txn right after the clean full tie-out passes (the closing +//! period's cache is the verified total); the daily job and the `AR_DERIVED` +//! recon check then verify `baseline + fold(open periods) == cache` instead of +//! folding all-time. `grain` is the cache discriminator (`account`, `ar_payer`, +//! `ar_invoice`, `ar_invoice_disputed`, `tax`, `unallocated`, `reusable_credit`) +//! and `grain_key` is the canonical per-instance key the tie-out fold produces, +//! so the verify compares like-for-like. Tenant scoping is via `SecureORM` at +//! query time (no PG RLS block — same as the other derived-cache tables). +//! `SQLite` mirrors the same shape with the systematic transforms +//! (`uuid`→`text`, `timestamptz`→`text`). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_verified_balance ( + tenant_id uuid NOT NULL, + grain varchar(32) NOT NULL, + grain_key varchar(512) NOT NULL, + verified_balance_minor bigint NOT NULL, + through_period varchar(64) NOT NULL, + watermark_seq bigint NOT NULL, + updated_at_utc timestamptz NOT NULL, + PRIMARY KEY (tenant_id, grain, grain_key), + CONSTRAINT chk_verified_balance_grain CHECK (grain IN ( + 'account', 'ar_payer', 'ar_invoice', 'ar_invoice_disputed', + 'tax', 'unallocated', 'reusable_credit' + )), + CONSTRAINT chk_verified_balance_watermark_nonneg CHECK (watermark_seq >= 0) + )", + "CREATE INDEX ix_verified_balance_through + ON bss.ledger_verified_balance (tenant_id, through_period)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_verified_balance"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; the CHECKs + PK + index preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_verified_balance ( + tenant_id text NOT NULL, + grain varchar(32) NOT NULL, + grain_key varchar(512) NOT NULL, + verified_balance_minor bigint NOT NULL, + through_period varchar(64) NOT NULL, + watermark_seq bigint NOT NULL, + updated_at_utc text NOT NULL, + PRIMARY KEY (tenant_id, grain, grain_key), + CONSTRAINT chk_verified_balance_grain CHECK (grain IN ( + 'account', 'ar_payer', 'ar_invoice', 'ar_invoice_disputed', + 'tax', 'unallocated', 'reusable_credit' + )), + CONSTRAINT chk_verified_balance_watermark_nonneg CHECK (watermark_seq >= 0) + )", + "CREATE INDEX ix_verified_balance_through + ON ledger_verified_balance (tenant_id, through_period)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_verified_balance"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000039_create_fx_revaluation_run.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000039_create_fx_revaluation_run.rs new file mode 100644 index 000000000..88e696c09 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260629_000039_create_fx_revaluation_run.rs @@ -0,0 +1,97 @@ +//! Create the Mode-B FX-revaluation completion-marker table in schema `bss`: +//! `ledger_fx_revaluation_run` (VHP-1859 review C3). One COMPLETE marker per +//! `(tenant_id, period_id)`, upserted by the revaluation job after a period-end +//! `run_period` finishes ALL scopes without error. The period-close gate, when +//! Mode-B is enabled (`fx.revaluation_enabled`), REQUIRES this marker for the +//! closing period inside the close SERIALIZABLE txn — without it (a failed/lagged +//! run) close BLOCKS and emits `FxRevaluationIncomplete`, rather than certifying a +//! period whose missing `FX_REVALUATION` entries the closed-period guard would +//! make unpostable forever. Entry-existence alone cannot distinguish "ran, nothing +//! to post" from "never ran", so a marker is required (`run_scope` legitimately +//! posts zero entries for a zero-net payer). `scope` is retained as a forward-compat +//! column (the whole-period run records `PERIOD`). Tenant scoping is via `SecureORM`. +//! `SQLite` mirrors the shape (`uuid`→`text`, `timestamptz`→`text`). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &["CREATE TABLE bss.ledger_fx_revaluation_run ( + tenant_id uuid NOT NULL, + period_id varchar(64) NOT NULL, + scope varchar(32) NOT NULL, + status varchar(16) NOT NULL, + completed_at_utc timestamptz NOT NULL, + PRIMARY KEY (tenant_id, period_id), + CONSTRAINT chk_fx_revaluation_run_status CHECK (status IN ('COMPLETE')) + )"]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_fx_revaluation_run"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; the CHECK + PK preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &["CREATE TABLE ledger_fx_revaluation_run ( + tenant_id text NOT NULL, + period_id varchar(64) NOT NULL, + scope varchar(32) NOT NULL, + status varchar(16) NOT NULL, + completed_at_utc text NOT NULL, + PRIMARY KEY (tenant_id, period_id), + CONSTRAINT chk_fx_revaluation_run_status CHECK (status IN ('COMPLETE')) + )"]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_fx_revaluation_run"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260630_000040_create_fx_revaluation_mode.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260630_000040_create_fx_revaluation_mode.rs new file mode 100644 index 000000000..1a8bea9d7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260630_000040_create_fx_revaluation_mode.rs @@ -0,0 +1,106 @@ +//! Create the effective-dated per-tenant FX **revaluation-mode** table in schema +//! `bss`: `ledger_tenant_fx_revaluation_mode` (per-tenant, append-only `version`ed +//! rows, VHP-1986) pinning whether BSS runs the period-end unrealized revaluation +//! for a tenant (`MODE_B` = BSS is the ledger of record) or defers to the tenant's +//! ERP (`MODE_A`, the fail-safe default — BSS must not double-count). The +//! revaluation job / period-close resolve the row in effect at decision time +//! (latest `effective_from <= now`, highest `version` on a tie); absent a row the +//! gear default (`MODE_A`) applies. Tenant scoping is via `SecureORM` at query +//! time (no PG RLS policy block — same as the other policy tables). `SQLite` +//! mirrors the same shape (`uuid`→`text`, `timestamptz`→`text`). + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE bss.ledger_tenant_fx_revaluation_mode ( + tenant_id uuid NOT NULL, + version bigint NOT NULL, + effective_from timestamptz NOT NULL, + revaluation_mode varchar(16) NOT NULL, + created_at_utc timestamptz NOT NULL, + PRIMARY KEY (tenant_id, version), + CONSTRAINT chk_fx_revaluation_mode_version_nonneg CHECK (version >= 0), + CONSTRAINT chk_fx_revaluation_mode_value + CHECK (revaluation_mode IN ('MODE_A', 'MODE_B')) + )", + "CREATE INDEX ix_fx_revaluation_mode_effective + ON bss.ledger_tenant_fx_revaluation_mode (tenant_id, effective_from)", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS bss.ledger_tenant_fx_revaluation_mode"]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema (unqualified; `uuid`→`text`, +// `timestamptz`→`text`; the CHECKs + PK + index preserved). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[ + "CREATE TABLE ledger_tenant_fx_revaluation_mode ( + tenant_id text NOT NULL, + version bigint NOT NULL, + effective_from text NOT NULL, + revaluation_mode varchar(16) NOT NULL, + created_at_utc text NOT NULL, + PRIMARY KEY (tenant_id, version), + CONSTRAINT chk_fx_revaluation_mode_version_nonneg CHECK (version >= 0), + CONSTRAINT chk_fx_revaluation_mode_value + CHECK (revaluation_mode IN ('MODE_A', 'MODE_B')) + )", + "CREATE INDEX ix_fx_revaluation_mode_effective + ON ledger_tenant_fx_revaluation_mode (tenant_id, effective_from)", +]; + +const SQLITE_DOWN_STATEMENTS: &[&str] = &["DROP TABLE IF EXISTS ledger_tenant_fx_revaluation_mode"]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260706_000041_currency_scale_immutable.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260706_000041_currency_scale_immutable.rs new file mode 100644 index 000000000..0ce2e2ad8 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260706_000041_currency_scale_immutable.rs @@ -0,0 +1,103 @@ +//! Enforce currency-scale immutability at the DB layer (design §3.7 / +//! MoneyModule): `minor_units` for a `(tenant, currency)` is **immutable once +//! any posting exists** (`CURRENCY_SCALE_LOCKED`). The application path already +//! refuses a scale change once a `journal_line` exists for the currency +//! (`ReferenceRepo::upsert_currency_scale` → `RepoError::CurrencyScaleLocked`), +//! but a direct SQL `UPDATE` bypassing the gear would silently re-denominate +//! history. This adds the defense-in-depth trigger the design calls for — the +//! sibling of the journal / fx-snapshot append-only guards, which likewise pair +//! an app-level assert with a DB trigger. +//! +//! The guard fires ONLY when `minor_units` actually changes AND a posting for +//! the `(tenant, currency)` exists — a same-value upsert (the gear's +//! `ON CONFLICT DO UPDATE` rewrites the column to its current value) and a +//! pre-posting scale correction both pass. `SQLite` (non-production test +//! backend) carries no triggers; immutability is re-asserted in application code. + +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// --------------------------------------------------------------------------- +// Postgres variant — canonical production schema (bss-qualified DDL). +// --------------------------------------------------------------------------- + +const PG_UP_STATEMENTS: &[&str] = &[ + "CREATE OR REPLACE FUNCTION bss.reject_currency_scale_change() RETURNS trigger AS $$ + BEGIN + IF NEW.minor_units IS DISTINCT FROM OLD.minor_units + AND EXISTS ( + SELECT 1 FROM bss.ledger_journal_line + WHERE tenant_id = OLD.tenant_id AND currency = OLD.currency + ) + THEN + RAISE EXCEPTION 'currency scale locked: minor_units for (%, %) is immutable once a posting exists', + OLD.tenant_id, OLD.currency; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql", + "CREATE TRIGGER trg_currency_scale_immutable + BEFORE UPDATE ON bss.ledger_currency_scale_registry + FOR EACH ROW EXECUTE FUNCTION bss.reject_currency_scale_change()", +]; + +const PG_DOWN_STATEMENTS: &[&str] = &[ + "DROP TRIGGER IF EXISTS trg_currency_scale_immutable ON bss.ledger_currency_scale_registry", + "DROP FUNCTION IF EXISTS bss.reject_currency_scale_change()", +]; + +// --------------------------------------------------------------------------- +// SQLite variant — non-production schema: no triggers (immutability re-asserted +// in application code, as with the journal / fx-snapshot append-only guards). +// --------------------------------------------------------------------------- + +const SQLITE_UP_STATEMENTS: &[&str] = &[]; +const SQLITE_DOWN_STATEMENTS: &[&str] = &[]; + +// --------------------------------------------------------------------------- +// Migration dispatch. +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_UP_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_UP_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + let statements: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => PG_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::Sqlite => SQLITE_DOWN_STATEMENTS, + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Migration( + "MySQL not supported for bss-ledger".to_owned(), + )); + } + }; + for sql in statements { + conn.execute(Statement::from_string(backend, (*sql).to_owned())) + .await?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/odata_mapping.rs b/gears/bss/ledger/ledger/src/infra/storage/odata_mapping.rs new file mode 100644 index 000000000..ce1f8f3be --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/odata_mapping.rs @@ -0,0 +1,487 @@ +//! `OData` field → `SeaORM` column mappers for the ledger's list endpoints. +//! +//! `paginate_odata::(...)` is generic over a filter-field +//! type `F` and a mapper `M: ODataFieldMapping`; this module supplies the +//! mappers `M` for the three row collections (`tenant_account`, `journal_line`, +//! `account_balance`). Each maps every [`crate::odata`] filter field to its +//! backing column and supplies the seekset cursor-value extractor. + +use toolkit_db::odata::sea_orm_filter::{FieldToColumn, ODataFieldMapping}; + +use crate::infra::storage::entity::account_balance::{ + Column as BalanceColumn, Entity as BalanceEntity, Model as BalanceModel, +}; +use crate::infra::storage::entity::credit_note::{ + Column as CreditNoteColumn, Entity as CreditNoteEntity, Model as CreditNoteModel, +}; +use crate::infra::storage::entity::debit_note::{ + Column as DebitNoteColumn, Entity as DebitNoteEntity, Model as DebitNoteModel, +}; +use crate::infra::storage::entity::dispute::{ + Column as DisputeColumn, Entity as DisputeEntity, Model as DisputeModel, +}; +use crate::infra::storage::entity::exception_queue::{ + Column as ExceptionColumn, Entity as ExceptionEntity, Model as ExceptionModel, +}; +use crate::infra::storage::entity::journal_entry::{ + Column as JournalEntryColumn, Entity as JournalEntryEntity, Model as JournalEntryModel, +}; +use crate::infra::storage::entity::journal_line::{ + Column as JournalLineColumn, Entity as JournalLineEntity, Model as JournalLineModel, +}; +use crate::infra::storage::entity::recognition_run::{ + Column as RecognitionRunColumn, Entity as RecognitionRunEntity, Model as RecognitionRunModel, +}; +use crate::infra::storage::entity::refund::{ + Column as RefundColumn, Entity as RefundEntity, Model as RefundModel, +}; +use crate::infra::storage::entity::tenant_account::{ + Column as TenantAccountColumn, Entity as TenantAccountEntity, Model as TenantAccountModel, +}; +use crate::odata::{ + AccountInfoFilterField, BalanceFilterField, CreditNoteFilterField, DebitNoteFilterField, + DisputeFilterField, ExceptionFilterField, JournalEntryFilterField, JournalLineFilterField, + RecognitionRunFilterField, RefundFilterField, +}; + +/// Maps [`AccountInfoFilterField`] variants to their backing +/// [`tenant_account`](crate::infra::storage::entity::tenant_account) columns and +/// supplies the seekset cursor-value extractor. +pub struct AccountInfoODataMapper; + +impl FieldToColumn for AccountInfoODataMapper { + type Column = TenantAccountColumn; + + fn map_field(field: AccountInfoFilterField) -> TenantAccountColumn { + match field { + AccountInfoFilterField::AccountId => TenantAccountColumn::AccountId, + AccountInfoFilterField::AccountClass => TenantAccountColumn::AccountClass, + AccountInfoFilterField::Currency => TenantAccountColumn::Currency, + AccountInfoFilterField::RevenueStream => TenantAccountColumn::RevenueStream, + AccountInfoFilterField::LifecycleState => TenantAccountColumn::LifecycleState, + } + } +} + +impl ODataFieldMapping for AccountInfoODataMapper { + type Entity = TenantAccountEntity; + + fn extract_cursor_value( + model: &TenantAccountModel, + field: AccountInfoFilterField, + ) -> sea_orm::Value { + match field { + AccountInfoFilterField::AccountId => { + sea_orm::Value::Uuid(Some(Box::new(model.account_id))) + } + AccountInfoFilterField::AccountClass => { + sea_orm::Value::String(Some(Box::new(model.account_class.clone()))) + } + AccountInfoFilterField::Currency => { + sea_orm::Value::String(Some(Box::new(model.currency.clone()))) + } + // `revenue_stream` is nullable; a `None` cursor value round-trips as + // a typed NULL (the keyset never seeks past a NULL on the default + // `account_id` order, so this is belt-and-braces). + AccountInfoFilterField::RevenueStream => match &model.revenue_stream { + Some(s) => sea_orm::Value::String(Some(Box::new(s.clone()))), + None => sea_orm::Value::String(None), + }, + AccountInfoFilterField::LifecycleState => { + sea_orm::Value::String(Some(Box::new(model.lifecycle_state.clone()))) + } + } + } +} + +/// Maps [`JournalLineFilterField`] variants to their backing +/// [`journal_line`](crate::infra::storage::entity::journal_line) columns and +/// supplies the seekset cursor-value extractor. +pub struct JournalLineODataMapper; + +impl FieldToColumn for JournalLineODataMapper { + type Column = JournalLineColumn; + + fn map_field(field: JournalLineFilterField) -> JournalLineColumn { + match field { + JournalLineFilterField::LineId => JournalLineColumn::LineId, + JournalLineFilterField::PayerTenantId => JournalLineColumn::PayerTenantId, + JournalLineFilterField::AccountClass => JournalLineColumn::AccountClass, + JournalLineFilterField::PeriodId => JournalLineColumn::PeriodId, + JournalLineFilterField::InvoiceId => JournalLineColumn::InvoiceId, + } + } +} + +impl ODataFieldMapping for JournalLineODataMapper { + type Entity = JournalLineEntity; + + fn extract_cursor_value( + model: &JournalLineModel, + field: JournalLineFilterField, + ) -> sea_orm::Value { + match field { + JournalLineFilterField::LineId => sea_orm::Value::Uuid(Some(Box::new(model.line_id))), + JournalLineFilterField::PayerTenantId => { + sea_orm::Value::Uuid(Some(Box::new(model.payer_tenant_id))) + } + JournalLineFilterField::AccountClass => { + sea_orm::Value::String(Some(Box::new(model.account_class.clone()))) + } + JournalLineFilterField::PeriodId => { + sea_orm::Value::String(Some(Box::new(model.period_id.clone()))) + } + JournalLineFilterField::InvoiceId => match &model.invoice_id { + Some(s) => sea_orm::Value::String(Some(Box::new(s.clone()))), + None => sea_orm::Value::String(None), + }, + } + } +} + +/// Maps [`JournalEntryFilterField`] variants to their backing +/// [`journal_entry`](crate::infra::storage::entity::journal_entry) HEADER columns +/// and supplies the seekset cursor-value extractor. `entry_id` is the surrogate +/// `Uuid` keyset column (mapped to `Value::Uuid`); `source_doc_type` / +/// `source_business_id` / `period_id` are non-nullable `varchar` dims (mapped to +/// `Value::String`), so no `None`-cursor arm is needed (unlike `RefundODataMapper`'s +/// `invoice_id`). +pub struct JournalEntryODataMapper; + +impl FieldToColumn for JournalEntryODataMapper { + type Column = JournalEntryColumn; + + fn map_field(field: JournalEntryFilterField) -> JournalEntryColumn { + match field { + JournalEntryFilterField::EntryId => JournalEntryColumn::EntryId, + JournalEntryFilterField::SourceDocType => JournalEntryColumn::SourceDocType, + JournalEntryFilterField::SourceBusinessId => JournalEntryColumn::SourceBusinessId, + JournalEntryFilterField::PeriodId => JournalEntryColumn::PeriodId, + } + } +} + +impl ODataFieldMapping for JournalEntryODataMapper { + type Entity = JournalEntryEntity; + + fn extract_cursor_value( + model: &JournalEntryModel, + field: JournalEntryFilterField, + ) -> sea_orm::Value { + match field { + JournalEntryFilterField::EntryId => { + sea_orm::Value::Uuid(Some(Box::new(model.entry_id))) + } + JournalEntryFilterField::SourceDocType => { + sea_orm::Value::String(Some(Box::new(model.source_doc_type.clone()))) + } + JournalEntryFilterField::SourceBusinessId => { + sea_orm::Value::String(Some(Box::new(model.source_business_id.clone()))) + } + JournalEntryFilterField::PeriodId => { + sea_orm::Value::String(Some(Box::new(model.period_id.clone()))) + } + } + } +} + +/// Maps [`BalanceFilterField`] variants to their backing +/// [`account_balance`](crate::infra::storage::entity::account_balance) columns +/// and supplies the seekset cursor-value extractor. +pub struct BalanceODataMapper; + +impl FieldToColumn for BalanceODataMapper { + type Column = BalanceColumn; + + fn map_field(field: BalanceFilterField) -> BalanceColumn { + match field { + BalanceFilterField::AccountId => BalanceColumn::AccountId, + BalanceFilterField::AccountClass => BalanceColumn::AccountClass, + BalanceFilterField::Currency => BalanceColumn::Currency, + } + } +} + +impl ODataFieldMapping for BalanceODataMapper { + type Entity = BalanceEntity; + + fn extract_cursor_value(model: &BalanceModel, field: BalanceFilterField) -> sea_orm::Value { + match field { + BalanceFilterField::AccountId => sea_orm::Value::Uuid(Some(Box::new(model.account_id))), + BalanceFilterField::AccountClass => { + sea_orm::Value::String(Some(Box::new(model.account_class.clone()))) + } + BalanceFilterField::Currency => { + sea_orm::Value::String(Some(Box::new(model.currency.clone()))) + } + } + } +} + +/// Maps [`RefundFilterField`] variants to their backing +/// [`refund`](crate::infra::storage::entity::refund) columns and supplies the +/// seekset cursor-value extractor. Every dim is a `varchar` (`refund_id` is the +/// default keyset column); `invoice_id` is nullable (Pattern A has none). +pub struct RefundODataMapper; + +impl FieldToColumn for RefundODataMapper { + type Column = RefundColumn; + + fn map_field(field: RefundFilterField) -> RefundColumn { + match field { + RefundFilterField::RefundId => RefundColumn::RefundId, + RefundFilterField::PaymentId => RefundColumn::PaymentId, + RefundFilterField::PspRefundId => RefundColumn::PspRefundId, + RefundFilterField::Phase => RefundColumn::Phase, + RefundFilterField::Pattern => RefundColumn::Pattern, + RefundFilterField::ClearingState => RefundColumn::ClearingState, + RefundFilterField::InvoiceId => RefundColumn::InvoiceId, + } + } +} + +impl ODataFieldMapping for RefundODataMapper { + type Entity = RefundEntity; + + fn extract_cursor_value(model: &RefundModel, field: RefundFilterField) -> sea_orm::Value { + match field { + RefundFilterField::RefundId => { + sea_orm::Value::String(Some(Box::new(model.refund_id.clone()))) + } + RefundFilterField::PaymentId => { + sea_orm::Value::String(Some(Box::new(model.payment_id.clone()))) + } + RefundFilterField::PspRefundId => { + sea_orm::Value::String(Some(Box::new(model.psp_refund_id.clone()))) + } + RefundFilterField::Phase => sea_orm::Value::String(Some(Box::new(model.phase.clone()))), + RefundFilterField::Pattern => { + sea_orm::Value::String(Some(Box::new(model.pattern.clone()))) + } + RefundFilterField::ClearingState => { + sea_orm::Value::String(Some(Box::new(model.clearing_state.clone()))) + } + // `invoice_id` is nullable (Pattern A has none); a `None` cursor value + // round-trips as a typed NULL (the keyset never seeks past a NULL on + // the default `refund_id` order, so this is belt-and-braces). + RefundFilterField::InvoiceId => match &model.invoice_id { + Some(s) => sea_orm::Value::String(Some(Box::new(s.clone()))), + None => sea_orm::Value::String(None), + }, + } + } +} + +/// Maps [`CreditNoteFilterField`] variants to their backing +/// [`credit_note`](crate::infra::storage::entity::credit_note) columns and +/// supplies the seekset cursor-value extractor. Every dim is a `varchar` +/// (`credit_note_id` is the default keyset column); all four filter columns are +/// non-nullable, so no `None`-cursor arm is needed (unlike `RefundODataMapper`'s +/// `invoice_id`). +pub struct CreditNoteODataMapper; + +impl FieldToColumn for CreditNoteODataMapper { + type Column = CreditNoteColumn; + + fn map_field(field: CreditNoteFilterField) -> CreditNoteColumn { + match field { + CreditNoteFilterField::CreditNoteId => CreditNoteColumn::CreditNoteId, + CreditNoteFilterField::OriginInvoiceId => CreditNoteColumn::OriginInvoiceId, + CreditNoteFilterField::RevenueStream => CreditNoteColumn::RevenueStream, + CreditNoteFilterField::ReasonCode => CreditNoteColumn::ReasonCode, + } + } +} + +impl ODataFieldMapping for CreditNoteODataMapper { + type Entity = CreditNoteEntity; + + fn extract_cursor_value( + model: &CreditNoteModel, + field: CreditNoteFilterField, + ) -> sea_orm::Value { + match field { + CreditNoteFilterField::CreditNoteId => { + sea_orm::Value::String(Some(Box::new(model.credit_note_id.clone()))) + } + CreditNoteFilterField::OriginInvoiceId => { + sea_orm::Value::String(Some(Box::new(model.origin_invoice_id.clone()))) + } + CreditNoteFilterField::RevenueStream => { + sea_orm::Value::String(Some(Box::new(model.revenue_stream.clone()))) + } + CreditNoteFilterField::ReasonCode => { + sea_orm::Value::String(Some(Box::new(model.reason_code.clone()))) + } + } + } +} + +/// Maps [`DebitNoteFilterField`] variants to their backing +/// [`debit_note`](crate::infra::storage::entity::debit_note) columns and supplies +/// the seekset cursor-value extractor. Both dims are non-nullable `varchar` +/// (`debit_note_id` is the default keyset column); the `debit_note` table is +/// leaner than `credit_note` (no `revenue_stream` / `reason_code`). +pub struct DebitNoteODataMapper; + +impl FieldToColumn for DebitNoteODataMapper { + type Column = DebitNoteColumn; + + fn map_field(field: DebitNoteFilterField) -> DebitNoteColumn { + match field { + DebitNoteFilterField::DebitNoteId => DebitNoteColumn::DebitNoteId, + DebitNoteFilterField::OriginInvoiceId => DebitNoteColumn::OriginInvoiceId, + } + } +} + +impl ODataFieldMapping for DebitNoteODataMapper { + type Entity = DebitNoteEntity; + + fn extract_cursor_value(model: &DebitNoteModel, field: DebitNoteFilterField) -> sea_orm::Value { + match field { + DebitNoteFilterField::DebitNoteId => { + sea_orm::Value::String(Some(Box::new(model.debit_note_id.clone()))) + } + DebitNoteFilterField::OriginInvoiceId => { + sea_orm::Value::String(Some(Box::new(model.origin_invoice_id.clone()))) + } + } + } +} + +/// Maps [`DisputeFilterField`] variants to their backing +/// [`dispute`](crate::infra::storage::entity::dispute) columns and supplies the +/// seekset cursor-value extractor. Every dim is a non-nullable `varchar` +/// (`dispute_id` is the default keyset column), so no `None`-cursor arm is needed +/// (unlike `RefundODataMapper`'s `invoice_id`). +pub struct DisputeODataMapper; + +impl FieldToColumn for DisputeODataMapper { + type Column = DisputeColumn; + + fn map_field(field: DisputeFilterField) -> DisputeColumn { + match field { + DisputeFilterField::DisputeId => DisputeColumn::DisputeId, + DisputeFilterField::PaymentId => DisputeColumn::PaymentId, + DisputeFilterField::LastPhase => DisputeColumn::LastPhase, + DisputeFilterField::Variant => DisputeColumn::Variant, + } + } +} + +impl ODataFieldMapping for DisputeODataMapper { + type Entity = DisputeEntity; + + fn extract_cursor_value(model: &DisputeModel, field: DisputeFilterField) -> sea_orm::Value { + match field { + DisputeFilterField::DisputeId => { + sea_orm::Value::String(Some(Box::new(model.dispute_id.clone()))) + } + DisputeFilterField::PaymentId => { + sea_orm::Value::String(Some(Box::new(model.payment_id.clone()))) + } + DisputeFilterField::LastPhase => { + sea_orm::Value::String(Some(Box::new(model.last_phase.clone()))) + } + DisputeFilterField::Variant => { + sea_orm::Value::String(Some(Box::new(model.variant.clone()))) + } + } + } +} + +/// Maps [`RecognitionRunFilterField`] variants to their backing +/// [`recognition_run`](crate::infra::storage::entity::recognition_run) columns and +/// supplies the seekset cursor-value extractor. `run_id` is the surrogate `Uuid` +/// keyset column (mapped to `Value::Uuid`); `period_id` / `status` are +/// non-nullable `varchar` dims (mapped to `Value::String`), so no `None`-cursor +/// arm is needed (unlike `RefundODataMapper`'s `invoice_id`). +pub struct RecognitionRunODataMapper; + +impl FieldToColumn for RecognitionRunODataMapper { + type Column = RecognitionRunColumn; + + fn map_field(field: RecognitionRunFilterField) -> RecognitionRunColumn { + match field { + RecognitionRunFilterField::RunId => RecognitionRunColumn::RunId, + RecognitionRunFilterField::PeriodId => RecognitionRunColumn::PeriodId, + RecognitionRunFilterField::Status => RecognitionRunColumn::Status, + } + } +} + +impl ODataFieldMapping for RecognitionRunODataMapper { + type Entity = RecognitionRunEntity; + + fn extract_cursor_value( + model: &RecognitionRunModel, + field: RecognitionRunFilterField, + ) -> sea_orm::Value { + match field { + RecognitionRunFilterField::RunId => sea_orm::Value::Uuid(Some(Box::new(model.run_id))), + RecognitionRunFilterField::PeriodId => { + sea_orm::Value::String(Some(Box::new(model.period_id.clone()))) + } + RecognitionRunFilterField::Status => { + sea_orm::Value::String(Some(Box::new(model.status.clone()))) + } + } + } +} + +/// Maps [`ExceptionFilterField`] variants to their backing +/// [`exception_queue`](crate::infra::storage::entity::exception_queue) columns and +/// supplies the seekset cursor-value extractor. `exception_id` is the surrogate +/// `Uuid` keyset column (mapped to `Value::Uuid`); the wire `type` field maps to +/// the `exception_type` column. `period_id` is nullable (a non-period exception +/// has none), so it carries a `None`-cursor arm like `RefundODataMapper`'s +/// `invoice_id`. +pub struct ExceptionODataMapper; + +impl FieldToColumn for ExceptionODataMapper { + type Column = ExceptionColumn; + + fn map_field(field: ExceptionFilterField) -> ExceptionColumn { + match field { + ExceptionFilterField::ExceptionId => ExceptionColumn::ExceptionId, + ExceptionFilterField::ExceptionType => ExceptionColumn::ExceptionType, + ExceptionFilterField::Status => ExceptionColumn::Status, + ExceptionFilterField::BusinessRef => ExceptionColumn::BusinessRef, + ExceptionFilterField::PeriodId => ExceptionColumn::PeriodId, + } + } +} + +impl ODataFieldMapping for ExceptionODataMapper { + type Entity = ExceptionEntity; + + fn extract_cursor_value(model: &ExceptionModel, field: ExceptionFilterField) -> sea_orm::Value { + match field { + ExceptionFilterField::ExceptionId => { + sea_orm::Value::Uuid(Some(Box::new(model.exception_id))) + } + ExceptionFilterField::ExceptionType => { + sea_orm::Value::String(Some(Box::new(model.exception_type.clone()))) + } + ExceptionFilterField::Status => { + sea_orm::Value::String(Some(Box::new(model.status.clone()))) + } + ExceptionFilterField::BusinessRef => { + sea_orm::Value::String(Some(Box::new(model.business_ref.clone()))) + } + // `period_id` is nullable (a non-period exception has none); a `None` + // cursor value round-trips as a typed NULL (the keyset never seeks past + // a NULL on the default `exception_id` order, so this is + // belt-and-braces). + ExceptionFilterField::PeriodId => match &model.period_id { + Some(s) => sea_orm::Value::String(Some(Box::new(s.clone()))), + None => sea_orm::Value::String(None), + }, + } + } +} + +#[cfg(test)] +#[path = "odata_mapping_tests.rs"] +mod tests; diff --git a/gears/bss/ledger/ledger/src/infra/storage/odata_mapping_tests.rs b/gears/bss/ledger/ledger/src/infra/storage/odata_mapping_tests.rs new file mode 100644 index 000000000..8e08ec4d7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/odata_mapping_tests.rs @@ -0,0 +1,371 @@ +//! Unit tests for the OData field→column mappers in `odata_mapping.rs`. +//! +//! `Column` enums emitted by `DeriveEntityModel` implement `IdenStatic` but +//! not `PartialEq`, so we compare via `IdenStatic::as_str()` (the DB column +//! name). This is a stronger check: it validates the actual SQL identifier, not +//! just the enum discriminant. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::doc_markdown, + clippy::similar_names, + clippy::inconsistent_struct_constructor +)] + +use sea_orm::IdenStatic; +use uuid::Uuid; + +use crate::infra::storage::entity::{account_balance, journal_line, tenant_account}; +use crate::odata::{AccountInfoFilterField, BalanceFilterField, JournalLineFilterField}; + +use super::{ + AccountInfoODataMapper, BalanceColumn, BalanceODataMapper, JournalLineColumn, + JournalLineODataMapper, TenantAccountColumn, +}; +use toolkit_db::odata::sea_orm_filter::{FieldToColumn, ODataFieldMapping}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn sample_tenant_account() -> tenant_account::Model { + tenant_account::Model { + account_id: Uuid::from_u128(0x0A), + tenant_id: Uuid::from_u128(0x1A), + legal_entity_id: Uuid::from_u128(0x2A), + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: Some("SAAS".to_owned()), + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "active".to_owned(), + } +} + +fn sample_journal_line() -> journal_line::Model { + journal_line::Model { + line_id: Uuid::from_u128(0x0B), + entry_id: Uuid::from_u128(0x1B), + tenant_id: Uuid::from_u128(0x2B), + period_id: "2025-01".to_owned(), + payer_tenant_id: Uuid::from_u128(0x3B), + seller_tenant_id: None, + resource_tenant_id: None, + account_id: Uuid::from_u128(0x4B), + account_class: "AR".to_owned(), + gl_code: None, + side: "DR".to_owned(), + amount_minor: 5000, + currency: "EUR".to_owned(), + currency_scale: 2, + invoice_id: Some("INV-001".to_owned()), + due_date: None, + revenue_stream: None, + mapping_status: "RESOLVED".to_owned(), + functional_amount_minor: None, + functional_currency: None, + rate_snapshot_ref: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn sample_account_balance() -> account_balance::Model { + account_balance::Model { + tenant_id: Uuid::from_u128(0xA1), + account_id: Uuid::from_u128(0xB1), + currency: "GBP".to_owned(), + account_class: "REVENUE".to_owned(), + normal_side: "CR".to_owned(), + balance_minor: 9000, + functional_balance_minor: None, + functional_currency: None, + last_entry_seq: Some(42), + version: 3, + } +} + +// --------------------------------------------------------------------------- +// AccountInfoODataMapper — map_field (column name lock) +// --------------------------------------------------------------------------- + +#[test] +fn account_info_map_account_id() { + assert_eq!( + AccountInfoODataMapper::map_field(AccountInfoFilterField::AccountId).as_str(), + TenantAccountColumn::AccountId.as_str(), + ); +} + +#[test] +fn account_info_map_account_class() { + assert_eq!( + AccountInfoODataMapper::map_field(AccountInfoFilterField::AccountClass).as_str(), + TenantAccountColumn::AccountClass.as_str(), + ); +} + +#[test] +fn account_info_map_currency() { + assert_eq!( + AccountInfoODataMapper::map_field(AccountInfoFilterField::Currency).as_str(), + TenantAccountColumn::Currency.as_str(), + ); +} + +#[test] +fn account_info_map_revenue_stream() { + assert_eq!( + AccountInfoODataMapper::map_field(AccountInfoFilterField::RevenueStream).as_str(), + TenantAccountColumn::RevenueStream.as_str(), + ); +} + +#[test] +fn account_info_map_lifecycle_state() { + assert_eq!( + AccountInfoODataMapper::map_field(AccountInfoFilterField::LifecycleState).as_str(), + TenantAccountColumn::LifecycleState.as_str(), + ); +} + +// --------------------------------------------------------------------------- +// AccountInfoODataMapper — extract_cursor_value +// --------------------------------------------------------------------------- + +#[test] +fn account_info_cursor_account_id_is_uuid() { + let model = sample_tenant_account(); + let val = + AccountInfoODataMapper::extract_cursor_value(&model, AccountInfoFilterField::AccountId); + assert_eq!(val, sea_orm::Value::Uuid(Some(Box::new(model.account_id)))); +} + +#[test] +fn account_info_cursor_account_class_is_string() { + let model = sample_tenant_account(); + let val = + AccountInfoODataMapper::extract_cursor_value(&model, AccountInfoFilterField::AccountClass); + assert_eq!(val, sea_orm::Value::String(Some(Box::new("AR".to_owned())))); +} + +#[test] +fn account_info_cursor_currency_is_string() { + let model = sample_tenant_account(); + let val = + AccountInfoODataMapper::extract_cursor_value(&model, AccountInfoFilterField::Currency); + assert_eq!( + val, + sea_orm::Value::String(Some(Box::new("USD".to_owned()))), + ); +} + +#[test] +fn account_info_cursor_revenue_stream_some() { + let model = sample_tenant_account(); + let val = + AccountInfoODataMapper::extract_cursor_value(&model, AccountInfoFilterField::RevenueStream); + assert_eq!( + val, + sea_orm::Value::String(Some(Box::new("SAAS".to_owned()))), + ); +} + +#[test] +fn account_info_cursor_revenue_stream_none_is_null_string() { + let mut model = sample_tenant_account(); + model.revenue_stream = None; + let val = + AccountInfoODataMapper::extract_cursor_value(&model, AccountInfoFilterField::RevenueStream); + assert_eq!(val, sea_orm::Value::String(None)); +} + +#[test] +fn account_info_cursor_lifecycle_state_is_string() { + let model = sample_tenant_account(); + let val = AccountInfoODataMapper::extract_cursor_value( + &model, + AccountInfoFilterField::LifecycleState, + ); + assert_eq!( + val, + sea_orm::Value::String(Some(Box::new("active".to_owned()))), + ); +} + +// --------------------------------------------------------------------------- +// JournalLineODataMapper — map_field (column name lock) +// --------------------------------------------------------------------------- + +#[test] +fn journal_line_map_line_id() { + assert_eq!( + JournalLineODataMapper::map_field(JournalLineFilterField::LineId).as_str(), + JournalLineColumn::LineId.as_str(), + ); +} + +#[test] +fn journal_line_map_payer_tenant_id() { + assert_eq!( + JournalLineODataMapper::map_field(JournalLineFilterField::PayerTenantId).as_str(), + JournalLineColumn::PayerTenantId.as_str(), + ); +} + +#[test] +fn journal_line_map_account_class() { + assert_eq!( + JournalLineODataMapper::map_field(JournalLineFilterField::AccountClass).as_str(), + JournalLineColumn::AccountClass.as_str(), + ); +} + +#[test] +fn journal_line_map_period_id() { + assert_eq!( + JournalLineODataMapper::map_field(JournalLineFilterField::PeriodId).as_str(), + JournalLineColumn::PeriodId.as_str(), + ); +} + +#[test] +fn journal_line_map_invoice_id() { + assert_eq!( + JournalLineODataMapper::map_field(JournalLineFilterField::InvoiceId).as_str(), + JournalLineColumn::InvoiceId.as_str(), + ); +} + +// --------------------------------------------------------------------------- +// JournalLineODataMapper — extract_cursor_value +// --------------------------------------------------------------------------- + +#[test] +fn journal_line_cursor_line_id_is_uuid() { + let model = sample_journal_line(); + let val = JournalLineODataMapper::extract_cursor_value(&model, JournalLineFilterField::LineId); + assert_eq!(val, sea_orm::Value::Uuid(Some(Box::new(model.line_id)))); +} + +#[test] +fn journal_line_cursor_payer_tenant_id_is_uuid() { + let model = sample_journal_line(); + let val = + JournalLineODataMapper::extract_cursor_value(&model, JournalLineFilterField::PayerTenantId); + assert_eq!( + val, + sea_orm::Value::Uuid(Some(Box::new(Uuid::from_u128(0x3B)))), + ); +} + +#[test] +fn journal_line_cursor_account_class_is_string() { + let model = sample_journal_line(); + let val = + JournalLineODataMapper::extract_cursor_value(&model, JournalLineFilterField::AccountClass); + assert_eq!(val, sea_orm::Value::String(Some(Box::new("AR".to_owned())))); +} + +#[test] +fn journal_line_cursor_period_id_is_string() { + let model = sample_journal_line(); + let val = + JournalLineODataMapper::extract_cursor_value(&model, JournalLineFilterField::PeriodId); + assert_eq!( + val, + sea_orm::Value::String(Some(Box::new("2025-01".to_owned()))), + ); +} + +#[test] +fn journal_line_cursor_invoice_id_some() { + let model = sample_journal_line(); + let val = + JournalLineODataMapper::extract_cursor_value(&model, JournalLineFilterField::InvoiceId); + assert_eq!( + val, + sea_orm::Value::String(Some(Box::new("INV-001".to_owned()))), + ); +} + +#[test] +fn journal_line_cursor_invoice_id_none_is_null_string() { + let mut model = sample_journal_line(); + model.invoice_id = None; + let val = + JournalLineODataMapper::extract_cursor_value(&model, JournalLineFilterField::InvoiceId); + assert_eq!(val, sea_orm::Value::String(None)); +} + +// --------------------------------------------------------------------------- +// BalanceODataMapper — map_field (column name lock) +// --------------------------------------------------------------------------- + +#[test] +fn balance_map_account_id() { + assert_eq!( + BalanceODataMapper::map_field(BalanceFilterField::AccountId).as_str(), + BalanceColumn::AccountId.as_str(), + ); +} + +#[test] +fn balance_map_account_class() { + assert_eq!( + BalanceODataMapper::map_field(BalanceFilterField::AccountClass).as_str(), + BalanceColumn::AccountClass.as_str(), + ); +} + +#[test] +fn balance_map_currency() { + assert_eq!( + BalanceODataMapper::map_field(BalanceFilterField::Currency).as_str(), + BalanceColumn::Currency.as_str(), + ); +} + +// --------------------------------------------------------------------------- +// BalanceODataMapper — extract_cursor_value +// --------------------------------------------------------------------------- + +#[test] +fn balance_cursor_account_id_is_uuid() { + let model = sample_account_balance(); + let val = BalanceODataMapper::extract_cursor_value(&model, BalanceFilterField::AccountId); + assert_eq!( + val, + sea_orm::Value::Uuid(Some(Box::new(Uuid::from_u128(0xB1)))), + ); +} + +#[test] +fn balance_cursor_account_class_is_string() { + let model = sample_account_balance(); + let val = BalanceODataMapper::extract_cursor_value(&model, BalanceFilterField::AccountClass); + assert_eq!( + val, + sea_orm::Value::String(Some(Box::new("REVENUE".to_owned()))), + ); +} + +#[test] +fn balance_cursor_currency_is_string() { + let model = sample_account_balance(); + let val = BalanceODataMapper::extract_cursor_value(&model, BalanceFilterField::Currency); + assert_eq!( + val, + sea_orm::Value::String(Some(Box::new("GBP".to_owned()))), + ); +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo.rs new file mode 100644 index 000000000..a12f7d4b5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo.rs @@ -0,0 +1,39 @@ +//! Typed insert/read repositories over the foundation entities. + +pub mod adjustment_repo; +pub mod approval_repo; +pub mod chain_state_repo; +pub mod dispute_repo; +pub mod exception_queue_repo; +pub mod fx_repo; +pub mod fx_revaluation_mode_repo; +pub mod fx_revaluation_run_repo; +pub mod journal_repo; +pub mod payer_state_repo; +pub mod payment_repo; +pub mod pending_queue_repo; +pub mod period_close_repo; +pub mod posting_policy_repo; +pub mod recognition_repo; +pub mod reconciliation_run_repo; +pub mod reference_repo; +pub mod verified_balance_repo; + +pub use adjustment_repo::{AdjustmentRepo, NewCreditNote, NewDebitNote, NewRefund}; +pub use approval_repo::{ApprovalRepo, NewPendingApproval, NewPolicyVersion}; +pub use chain_state_repo::{ChainStateRepo, TipRow}; +pub use dispute_repo::DisputeRepo; +pub use exception_queue_repo::ExceptionQueueRepo; +pub use fx_repo::{FxRepo, NewFxRate, NewRateSnapshot}; +pub use fx_revaluation_mode_repo::FxRevaluationModeRepo; +pub use fx_revaluation_run_repo::FxRevaluationRunRepo; +pub use journal_repo::JournalRepo; +pub use payer_state_repo::PayerStateRepo; +pub use payment_repo::{PaymentRepo, RevaluationGrain}; +pub use pending_queue_repo::{NewQueueRow, PendingQueueRepo}; +pub use period_close_repo::PeriodCloseRepo; +pub use posting_policy_repo::PostingPolicyRepo; +pub use recognition_repo::{RecognitionRepo, RecognizedStreamEntry}; +pub use reconciliation_run_repo::ReconciliationRunRepo; +pub use reference_repo::ReferenceRepo; +pub use verified_balance_repo::{BaselineRow, VerifiedBalanceRepo}; diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/adjustment_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/adjustment_repo.rs new file mode 100644 index 000000000..656a1c2d7 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/adjustment_repo.rs @@ -0,0 +1,964 @@ +//! `AdjustmentRepo` — the Slice-3 adjustment counter/record tables +//! (`invoice_exposure`, `credit_note`, `debit_note`), plus the two ledger reads a +//! credit note needs: the invoice's **posted AR incl. tax** (the headroom seed +//! basis) and its **current open AR** (the AR-vs-wallet credit-leg cap). +//! +//! A debit note (Group D) **raises** the headroom: `add_debit_note_total` bumps +//! `debit_note_total_minor` (the RHS of the headroom CHECK), so it can never trip +//! the cap — it only widens the room available to later credit notes; the +//! `insert_debit_note` record write mirrors `insert_credit_note`. +//! +//! The counter **writes** (`seed_exposure_first_touch`, `add_credit_note_total`) +//! and the record write (`insert_credit_note`) run inside the passed-in posting +//! transaction (the in-txn `CreditNoteHandler` sidecar), mirroring +//! [`PaymentRepo`](super::PaymentRepo) / [`RecognitionRepo`](super::RecognitionRepo): +//! `seed_exposure_first_touch` is the Slice-1 first-touch `INSERT … ON CONFLICT DO +//! UPDATE` (so concurrent creators serialize, no duplicate-key), and +//! `add_credit_note_total` is the counter-delta-under-lock bumping +//! `credit_note_total_minor`, with the `chk_ledger_invoice_exposure_headroom` CHECK +//! (`credit_note_total_minor <= original_total_minor + debit_note_total_minor`, +//! AC #24) as the authoritative over-cap guard — a violation surfaces as +//! [`RepoError::MoneyOutCapExceeded`], which the handler refines to the +//! `CREDIT_NOTE_EXCEEDS_HEADROOM` wire code (mirroring how `PaymentRepo::add_*` +//! maps its per-payment cap CHECKs). +//! +//! The **reads** (`read_posted_ar_incl_tax_out_of_txn`, +//! `read_open_ar_for_invoice_out_of_txn`) take the PDP-compiled `AccessScope` and +//! run out-of-txn on a fresh scoped connection — the handler's PRE-txn cap basis, +//! exactly as [`CreditApplicationService`](crate::infra::payment::credit) reads +//! its open-AR candidates out-of-txn; the authoritative in-txn backstops (the +//! headroom CHECK, the AR no-negative CHECK) cover a concurrent race the lockless +//! reads cannot see. Both are `.secure().scope_with` (SQL-level BOLA — a foreign +//! tenant yields no rows). + +use bss_ledger_sdk::{AccountClass, Side, SourceDocType}; +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, DbErr, EntityTrait}; +use toolkit_db::secure::{ + AccessScope, DbTx, ScopeError, SecureEntityExt, SecureInsertExt, SecureOnConflict, + SecureUpdateExt, +}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use toolkit_db::odata::sea_orm_filter::{LimitCfg, paginate_odata}; +use toolkit_odata::{ODataQuery, Page, SortDir}; + +use crate::domain::model::RepoError; +use crate::infra::storage::entity::{ + credit_note, debit_note, invoice_exposure, journal_entry, journal_line, refund, +}; +use crate::infra::storage::odata_mapping::{ + CreditNoteODataMapper, DebitNoteODataMapper, RefundODataMapper, +}; +use crate::infra::storage::repo::journal_repo::{ + OdataPageError, map_odata_err, query_with_default_order, +}; +use crate::odata::{CreditNoteFilterField, DebitNoteFilterField, RefundFilterField}; + +/// A `credit_note` row to persist (the record linking a posted credit note to its +/// originating invoice item + the recognized/deferred split basis). `amount_minor` +/// is incl-tax; the split parts are ex-tax and do NOT sum to it (no CHECK). +pub struct NewCreditNote { + pub tenant_id: Uuid, + pub credit_note_id: String, + pub origin_invoice_id: String, + pub origin_invoice_item_ref: Option, + pub revenue_stream: String, + pub currency: String, + pub amount_minor: i64, + pub recognized_part_minor: i64, + pub deferred_part_minor: i64, + pub split_basis_ref: Option, + pub reason_code: String, + pub created_at_utc: DateTime, +} + +/// A `debit_note` row to persist (the record linking a posted debit note — an +/// additional charge — to its originating invoice + its recognized/deferred split, +/// design §4.3). `amount_minor` is incl-tax; the split parts are ex-tax and do NOT +/// sum to it (no CHECK, mirroring `credit_note`). +pub struct NewDebitNote { + pub tenant_id: Uuid, + pub debit_note_id: String, + pub origin_invoice_id: String, + pub currency: String, + pub amount_minor: i64, + pub recognized_part_minor: i64, + pub deferred_part_minor: i64, + pub created_at_utc: DateTime, +} + +/// A `refund` row to persist (the record of a PSP refund's two-stage lifecycle, +/// design §4.4). The surrogate PK is `(tenant_id, refund_id)`; the idempotency +/// grain is the natural `(tenant_id, psp_refund_id, phase)` (a separate UNIQUE +/// index — one PSP refund advances through several `phase` rows). `amount_minor` +/// is the cash returned. `invoice_id` is `None` for Pattern A (`A_UNALLOCATED`) +/// and `Some` for Pattern B (`B_RESTORE_AR`). `clearing_state` tracks the +/// two-stage `REFUND_CLEARING` drain (`PENDING → SETTLED`, or `REVERSED` on a PSP +/// reject/void in Group E). `reverses_entry_id` / `relates_to_refund_id` stay +/// `None` in Group B (the stage-1 line-negation is Group E, the refund-of-refund +/// link is Group E too); they are carried for the forward-compatible row shape. +pub struct NewRefund { + pub tenant_id: Uuid, + pub refund_id: String, + pub psp_refund_id: String, + /// The phase wire literal (`RefundPhase::as_str`) — the `chk_ledger_refund_phase` + /// CHECK set + the natural-UNIQUE grain. + pub phase: String, + /// The pattern wire literal (`RefundPattern::as_str`) — + /// `chk_ledger_refund_pattern`. + pub pattern: String, + pub payment_id: String, + pub invoice_id: Option, + pub currency: String, + pub amount_minor: i64, + /// The clearing-state wire literal (`PENDING` / `SETTLED` / `REVERSED`) — + /// `chk_ledger_refund_clearing_state`. + pub clearing_state: String, + /// The refund-of-refund forward link (Group E); `None` in Group B. + pub relates_to_refund_id: Option, + /// The negated stage-1 entry id on a PSP reject/void (Group E); `None` in + /// Group B. + pub reverses_entry_id: Option, + pub created_at_utc: DateTime, +} + +/// SeaORM-backed Slice-3 adjustment counter + record + read repository. +#[derive(Clone)] +pub struct AdjustmentRepo { + db: DBProvider, +} + +impl AdjustmentRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + // --- Out-of-txn reads (the handler's pre-txn cap basis; PDP In-scoped, + // SQL-level BOLA). The headroom CHECK + the AR no-negative CHECK are the + // authoritative in-txn backstops, exactly as `CreditApplicationService` + // reads open-AR candidates out-of-txn and relies on the AR CHECK. --- + + /// Sum the invoice's **posted AR incl. tax** — the headroom seed basis + /// (`invoice_exposure.original_total_minor`, design §4.7): the net of the + /// `AR`-class `journal_line`s of the invoice's `INVOICE_POST` entry (DR adds, + /// any CR nets down), for `(tenant, origin_invoice_id)`. This is the ORIGINAL + /// posted receivable (incl. tax), independent of later payments — exactly the + /// cap basis the design seeds at first touch, read from the journal (NOT the + /// payment-reduced `ar_invoice_balance` cache). Scoped (SQL-level BOLA). + /// + /// Implemented as a scoped two-step read (entry ids for the invoice's + /// `INVOICE_POST` business id, then their AR lines), the gear's single-entity + /// idiom; the per-invoice line set is tiny. Returns `0` when the invoice has no + /// posted AR line (a fully-deferred or non-AR invoice — the headroom then + /// floors at the debit-note total). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_posted_ar_incl_tax_out_of_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + origin_invoice_id: &str, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + // The original posted AR lives on the invoice's INVOICE_POST entry — keyed + // by `source_business_id = invoice_id` on the header (the line carries no + // business id). Resolve those entry ids first (scoped). + let entry_ids: Vec = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant)) + .add( + journal_entry::Column::SourceDocType + .eq(SourceDocType::InvoicePost.as_str()), + ) + .add(journal_entry::Column::SourceBusinessId.eq(origin_invoice_id)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("read invoice-post entry ids: {e}")))? + .into_iter() + .map(|e| e.entry_id) + .collect(); + if entry_ids.is_empty() { + return Ok(0); + } + + // Net the AR lines of those entries (DR +, CR −). `journal_line` also + // carries `invoice_id`, so filter on it defensively (a single INVOICE_POST + // entry is one invoice, but the AND keeps the read robust). + let lines = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_line::Column::TenantId.eq(tenant)) + .add(journal_line::Column::AccountClass.eq(AccountClass::Ar.as_str())) + .add(journal_line::Column::InvoiceId.eq(origin_invoice_id)) + .add(journal_line::Column::EntryId.is_in(entry_ids)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("read posted AR lines: {e}")))?; + let mut total: i64 = 0; + for line in lines { + let signed = if line.side == Side::Debit.as_str() { + line.amount_minor + } else { + -line.amount_minor + }; + total = total.saturating_add(signed); + } + Ok(total.max(0)) + } + + /// `true` iff a **posted invoice** exists for `(tenant, origin_invoice_id)` — + /// i.e. there is at least one `INVOICE_POST` `journal_entry` whose + /// `source_business_id` is the invoice id (design §4.2 / §5: a credit/debit + /// note MUST link an originating posted invoice, else `NOTE_INVOICE_NOT_FOUND` + /// / 404). Scoped (SQL-level BOLA — a foreign tenant yields `false`, the same + /// 404 as absent, no existence leak). Out-of-txn on a fresh scoped connection + /// (the handler's pre-txn link gate); mirrors the entry-id resolution + /// [`Self::read_posted_ar_incl_tax_out_of_txn`] does, but only needs existence, + /// so it `LIMIT 1`s rather than netting the AR lines. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn posted_invoice_exists_out_of_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + origin_invoice_id: &str, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let found = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant)) + .add( + journal_entry::Column::SourceDocType + .eq(SourceDocType::InvoicePost.as_str()), + ) + .add(journal_entry::Column::SourceBusinessId.eq(origin_invoice_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read invoice-post entry existence: {e}")))?; + Ok(found.is_some()) + } + + /// Sum the invoice's **current open AR** (incl. tax) — the AR-vs-wallet + /// credit-leg cap (design §4.2 / K-2): the `balance_minor` of the + /// `ar_invoice_balance` cache rows for `(tenant, origin_invoice_id)` (summed + /// across the payer/account grain, though v1 is one row per invoice). This is + /// the payment-reduced open receivable the `CR AR` leg is capped at; the + /// remainder credits `REUSABLE_CREDIT`. Scoped (SQL-level BOLA). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_open_ar_for_invoice_out_of_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + origin_invoice_id: &str, + ) -> Result { + use crate::infra::storage::entity::ar_invoice_balance; + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = ar_invoice_balance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(ar_invoice_balance::Column::TenantId.eq(tenant)) + .add(ar_invoice_balance::Column::InvoiceId.eq(origin_invoice_id)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("read open AR for invoice: {e}")))?; + let mut total: i64 = 0; + for r in rows { + total = total.saturating_add(r.balance_minor.max(0)); + } + Ok(total) + } + + /// Read the `invoice_exposure` headroom row for `(tenant, invoice_id)` — the + /// `GET /invoices/{invoice_id}/exposure` source (Group E). Returns the row (so + /// the handler can compute remaining headroom = `original_total_minor + + /// debit_note_total_minor − credit_note_total_minor`), or `None` when no note + /// has ever touched this invoice (the row is seeded at the first credit/debit + /// note's first touch — an invoice with neither yet has no exposure row). Scoped + /// (SQL-level BOLA — a foreign tenant yields `None`, the same 404 as absent, no + /// existence leak). Out-of-txn on a fresh scoped connection (a pure read). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_exposure_out_of_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + invoice_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + invoice_exposure::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(invoice_exposure::Column::TenantId.eq(tenant)) + .add(invoice_exposure::Column::InvoiceId.eq(invoice_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read invoice_exposure: {e}"))) + } + + // --- In-txn counter / record writes (called by the CreditNoteHandler sidecar) --- + + /// **First-touch** seed of the `invoice_exposure` row for `(tenant, + /// invoice_id)` with `original_total_minor = original_total`, via an + /// `INSERT … ON CONFLICT DO UPDATE` that is a no-op on conflict (it re-stamps + /// `original_total_minor` to its own existing value, so a concurrent second + /// creator serializes at the row without changing it — the Slice-1 first-touch + /// upsert, design §4.7). `debit_note_total_minor` / `credit_note_total_minor` + /// default to 0 on the fresh insert and are LEFT UNTOUCHED on conflict (the + /// running counters are owned by `add_credit_note_total` / the debit-note + /// handler). Idempotent + concurrency-safe: many credit notes on one invoice + /// all call this, only the first seeds, the rest are no-ops. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn seed_exposure_first_touch( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + invoice_id: &str, + currency: &str, + original_total: i64, + ) -> Result<(), RepoError> { + let am = invoice_exposure::ActiveModel { + tenant_id: Set(tenant), + invoice_id: Set(invoice_id.to_owned()), + currency: Set(currency.to_owned()), + original_total_minor: Set(original_total), + debit_note_total_minor: Set(0), + credit_note_total_minor: Set(0), + version: Set(0), + }; + // DO UPDATE that re-stamps `original_total_minor` to ITS OWN existing value + // (a self-assignment): a fresh insert seeds the real total; a conflict + // serializes at the row but changes nothing — the running counters are + // never reset. (`do_nothing` would also work, but an explicit self-update + // forces the row lock the headroom serialization relies on, mirroring the + // projector's first-write-wins upserts that always net a column.) + let on_conflict = SecureOnConflict::::columns([ + invoice_exposure::Column::TenantId, + invoice_exposure::Column::InvoiceId, + ]) + .value( + invoice_exposure::Column::OriginalTotalMinor, + Expr::col(( + invoice_exposure::Entity, + invoice_exposure::Column::OriginalTotalMinor, + )) + .into(), + ) + .map_err(|e| RepoError::Db(format!("invoice_exposure on_conflict: {e}")))?; + + invoice_exposure::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("invoice_exposure scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| RepoError::Db(format!("seed invoice_exposure: {e}")))?; + Ok(()) + } + + /// Increment `invoice_exposure.credit_note_total_minor` by `delta` (the new + /// credit note's incl-tax amount) for `(tenant, invoice_id)`, bumping + /// `version`. The `chk_ledger_invoice_exposure_headroom` CHECK + /// (`credit_note_total_minor <= original_total_minor + debit_note_total_minor`, + /// AC #24) is the authoritative headroom guard, evaluated against the resulting + /// row: an over-cap bump surfaces as [`RepoError::MoneyOutCapExceeded`] (the + /// handler refines it to `CreditNoteExceedsHeadroom` → `CREDIT_NOTE_EXCEEDS_HEADROOM`). + /// A scoped UPDATE, not an upsert: the row is always seeded first by + /// [`Self::seed_exposure_first_touch`], and an `INSERT … ON CONFLICT` would + /// trip the headroom CHECK on the INSERT VALUES tuple during arbitration (the + /// `PaymentRepo::add_allocated` rationale). SSI + retry serialize concurrent + /// credit notes on the same invoice; `rows_affected == 0` ⇒ no exposure row + /// (the seed must precede this — an invariant breach). + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when the headroom CHECK rejects the bump; + /// [`RepoError::Db`] when no row matched or on any other scope / storage + /// failure. + pub async fn add_credit_note_total( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + invoice_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + let result = invoice_exposure::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + invoice_exposure::Column::CreditNoteTotalMinor, + Expr::col(( + invoice_exposure::Entity, + invoice_exposure::Column::CreditNoteTotalMinor, + )) + .add(delta), + ) + .col_expr( + invoice_exposure::Column::Version, + Expr::col((invoice_exposure::Entity, invoice_exposure::Column::Version)).add(1), + ) + .filter( + Condition::all() + .add(invoice_exposure::Column::TenantId.eq(tenant)) + .add(invoice_exposure::Column::InvoiceId.eq(invoice_id)), + ) + .exec(txn) + .await + .map_err(|e| map_headroom_violation("bump credit_note_total_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "invoice_exposure row absent for ({tenant}, {invoice_id}) — not seeded" + ))); + } + Ok(()) + } + + /// Increment `invoice_exposure.debit_note_total_minor` by `delta` (the new + /// debit note's incl-tax amount) for `(tenant, invoice_id)`, bumping `version`. + /// A debit note **raises** the invoice's headroom (design §4.3 / AC #24): the + /// headroom CHECK is `credit_note_total_minor <= original_total_minor + + /// debit_note_total_minor`, so `debit_note_total_minor` is on the RHS — raising + /// it can NEVER violate the headroom CHECK (it only widens the cap available to + /// later credit notes). The only guard on this column is the nonneg CHECK + /// (`debit_note_total_minor >= 0`), which a positive `delta` never trips; the + /// caller never passes a negative `delta` (a debit note only adds charge). A + /// scoped UPDATE, not an upsert: the row is always seeded first by + /// [`Self::seed_exposure_first_touch`] (the same first-touch the debit-note + /// handler runs against the invoice's posted AR); `rows_affected == 0` ⇒ no + /// exposure row (the seed must precede this — an invariant breach). SSI + retry + /// serialize concurrent notes on the same invoice. + /// + /// # Errors + /// [`RepoError::Db`] when no row matched or on any scope / storage failure + /// (incl. the unexpected nonneg-CHECK trip a negative `delta` would cause). + pub async fn add_debit_note_total( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + invoice_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + let result = invoice_exposure::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + invoice_exposure::Column::DebitNoteTotalMinor, + Expr::col(( + invoice_exposure::Entity, + invoice_exposure::Column::DebitNoteTotalMinor, + )) + .add(delta), + ) + .col_expr( + invoice_exposure::Column::Version, + Expr::col((invoice_exposure::Entity, invoice_exposure::Column::Version)).add(1), + ) + .filter( + Condition::all() + .add(invoice_exposure::Column::TenantId.eq(tenant)) + .add(invoice_exposure::Column::InvoiceId.eq(invoice_id)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("bump debit_note_total_minor: {e}")))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "invoice_exposure row absent for ({tenant}, {invoice_id}) — not seeded" + ))); + } + Ok(()) + } + + /// Insert the `credit_note` record row (`(tenant, credit_note_id)` PK). Runs in + /// the post txn after the entry is posted; a duplicate `credit_note_id` is + /// short-circuited by the `(tenant, CREDIT_NOTE, credit_note_id)` idempotency + /// claim BEFORE the sidecar (a replay returns before this), so an unexpected + /// PK collision surfaces as [`RepoError::Db`] and rolls the post back. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn insert_credit_note( + txn: &DbTx<'_>, + scope: &AccessScope, + note: &NewCreditNote, + ) -> Result<(), RepoError> { + let am = credit_note::ActiveModel { + tenant_id: Set(note.tenant_id), + credit_note_id: Set(note.credit_note_id.clone()), + origin_invoice_id: Set(note.origin_invoice_id.clone()), + origin_invoice_item_ref: Set(note.origin_invoice_item_ref.clone()), + revenue_stream: Set(note.revenue_stream.clone()), + currency: Set(note.currency.clone()), + amount_minor: Set(note.amount_minor), + recognized_part_minor: Set(note.recognized_part_minor), + deferred_part_minor: Set(note.deferred_part_minor), + split_basis_ref: Set(note.split_basis_ref.clone()), + reason_code: Set(note.reason_code.clone()), + created_at_utc: Set(note.created_at_utc), + }; + credit_note::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("credit_note scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert credit_note: {e}")))?; + Ok(()) + } + + /// Insert the `debit_note` record row (`(tenant, debit_note_id)` PK). Runs in + /// the post txn after the entry is posted; a duplicate `debit_note_id` is + /// short-circuited by the `(tenant, DEBIT_NOTE, debit_note_id)` idempotency + /// claim BEFORE the sidecar (a replay returns before this), so an unexpected PK + /// collision surfaces as [`RepoError::Db`] and rolls the post back. Mirrors + /// [`Self::insert_credit_note`]. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn insert_debit_note( + txn: &DbTx<'_>, + scope: &AccessScope, + note: &NewDebitNote, + ) -> Result<(), RepoError> { + let am = debit_note::ActiveModel { + tenant_id: Set(note.tenant_id), + debit_note_id: Set(note.debit_note_id.clone()), + origin_invoice_id: Set(note.origin_invoice_id.clone()), + currency: Set(note.currency.clone()), + amount_minor: Set(note.amount_minor), + recognized_part_minor: Set(note.recognized_part_minor), + deferred_part_minor: Set(note.deferred_part_minor), + created_at_utc: Set(note.created_at_utc), + }; + debit_note::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("debit_note scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert debit_note: {e}")))?; + Ok(()) + } + + /// Insert the `refund` record row (surrogate `(tenant, refund_id)` PK; design + /// §4.4). Runs in the post txn after the refund's stage entry is posted. The + /// engine's `(tenant, REFUND, psp_refund_id:phase)` idempotency claim + /// short-circuits a replay BEFORE the sidecar (a replay returns before this), + /// so an unexpected collision — on either the surrogate PK or the natural + /// `UNIQUE (tenant, psp_refund_id, phase)` index — surfaces as + /// [`RepoError::Db`] and rolls the post back. Mirrors + /// [`Self::insert_credit_note`]; the `version` column defaults to 0. + /// + /// # Errors + /// [`RepoError::Db`] on a scope, UNIQUE/PK collision, or storage failure. + pub async fn insert_refund( + txn: &DbTx<'_>, + scope: &AccessScope, + rf: &NewRefund, + ) -> Result<(), RepoError> { + let am = refund::ActiveModel { + tenant_id: Set(rf.tenant_id), + refund_id: Set(rf.refund_id.clone()), + psp_refund_id: Set(rf.psp_refund_id.clone()), + phase: Set(rf.phase.clone()), + pattern: Set(rf.pattern.clone()), + payment_id: Set(rf.payment_id.clone()), + invoice_id: Set(rf.invoice_id.clone()), + currency: Set(rf.currency.clone()), + amount_minor: Set(rf.amount_minor), + clearing_state: Set(rf.clearing_state.clone()), + relates_to_refund_id: Set(rf.relates_to_refund_id.clone()), + reverses_entry_id: Set(rf.reverses_entry_id), + created_at_utc: Set(rf.created_at_utc), + version: Set(0), + }; + refund::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("refund scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert refund: {e}")))?; + Ok(()) + } + + /// Read the `refund` record row for `(tenant, refund_id)` — the + /// `GET /refunds/{refundId}` source (Group G). The surrogate PK is + /// `(tenant_id, refund_id)`, so this yields exactly one row (the latest phase + /// stamped on that surrogate id — the handler writes one `refund` row per + /// `refund_id`, advancing its `phase` / `clearing_state` as the PSP lifecycle + /// progresses). Returns `None` when no refund with that id exists for the + /// tenant. Scoped (SQL-level BOLA — a foreign tenant yields `None`, the same + /// 404 as absent, no existence leak). Out-of-txn on a fresh scoped connection + /// (a pure read). Mirrors [`Self::read_exposure_out_of_txn`]. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_refund_out_of_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + refund_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + refund::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(refund::Column::TenantId.eq(tenant)) + .add(refund::Column::RefundId.eq(refund_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read refund: {e}"))) + } + + /// List the `refund` record rows for `tenant` under `scope`, cursor-paginated + /// via the canonical `query` (`$filter` over `payment_id` / `psp_refund_id` / + /// `phase` / `pattern` / `clearing_state` / `invoice_id`, `$orderby` / `limit` / + /// `cursor`). The tenant predicate is pre-applied to the secured select; the + /// user `$filter` is additive over it (SQL-level BOLA — a foreign value still + /// ANDs the scope, so a cross-tenant refund never leaks). A bare list defaults + /// to `refund_id ASC`. The `GET /refunds` read-surface source; out-of-txn on a + /// fresh scoped connection. Mirrors `JournalRepo::list_lines`. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor + /// (the caller projects it to a canonical 400). + pub async fn list_refunds( + &self, + scope: &AccessScope, + tenant: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + // Pre-apply the tenant predicate to the secured select; the user `$filter` + // is applied additively by `paginate_odata` (it never replaces this scope — + // BOLA preserved). + let base_select = refund::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(refund::Column::TenantId.eq(tenant))); + let query = query_with_default_order(query, "refund_id"); + paginate_odata::( + base_select, + &conn, + &query, + ("refund_id", SortDir::Asc), + LimitCfg { + default: 25, + max: 200, + }, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// Read the `credit_note` record row for `(tenant, credit_note_id)` — the + /// `GET /credit-notes/{creditNoteId}` source (read surface R2). The PK is + /// `(tenant_id, credit_note_id)`, so this yields at most one row. Returns + /// `None` when no credit note with that id exists for the tenant. Scoped + /// (SQL-level BOLA — a foreign tenant yields `None`, the same 404 as absent, no + /// existence leak). Out-of-txn on a fresh scoped connection (a pure read). + /// Mirrors [`Self::read_refund_out_of_txn`]. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_credit_note_out_of_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + credit_note_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + credit_note::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(credit_note::Column::TenantId.eq(tenant)) + .add(credit_note::Column::CreditNoteId.eq(credit_note_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read credit_note: {e}"))) + } + + /// List the `credit_note` record rows for `tenant` under `scope`, + /// cursor-paginated via the canonical `query` (`$filter` over + /// `origin_invoice_id` / `revenue_stream` / `reason_code`, `$orderby` / `limit` + /// / `cursor`). The tenant predicate is pre-applied to the secured select; the + /// user `$filter` is additive over it (SQL-level BOLA — a foreign value still + /// ANDs the scope, so a cross-tenant credit note never leaks). A bare list + /// defaults to `credit_note_id ASC`. The `GET /credit-notes` read-surface + /// source; out-of-txn on a fresh scoped connection. Mirrors + /// [`Self::list_refunds`]. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor + /// (the caller projects it to a canonical 400). + pub async fn list_credit_notes( + &self, + scope: &AccessScope, + tenant: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + // Pre-apply the tenant predicate to the secured select; the user `$filter` + // is applied additively by `paginate_odata` (it never replaces this scope — + // BOLA preserved). + let base_select = credit_note::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(credit_note::Column::TenantId.eq(tenant))); + let query = query_with_default_order(query, "credit_note_id"); + paginate_odata::< + CreditNoteFilterField, + CreditNoteODataMapper, + credit_note::Entity, + credit_note::Model, + _, + _, + >( + base_select, + &conn, + &query, + ("credit_note_id", SortDir::Asc), + LimitCfg { + default: 25, + max: 200, + }, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// Read the `debit_note` record row for `(tenant, debit_note_id)` — the + /// `GET /debit-notes/{debitNoteId}` source (read surface R2). The PK is + /// `(tenant_id, debit_note_id)`, so this yields at most one row. Returns `None` + /// when no debit note with that id exists for the tenant. Scoped (SQL-level + /// BOLA — a foreign tenant yields `None`, the same 404 as absent, no existence + /// leak). Out-of-txn on a fresh scoped connection (a pure read). Mirrors + /// [`Self::read_credit_note_out_of_txn`]. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_debit_note_out_of_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + debit_note_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + debit_note::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(debit_note::Column::TenantId.eq(tenant)) + .add(debit_note::Column::DebitNoteId.eq(debit_note_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read debit_note: {e}"))) + } + + /// List the `debit_note` record rows for `tenant` under `scope`, + /// cursor-paginated via the canonical `query` (`$filter` over + /// `origin_invoice_id`, `$orderby` / `limit` / `cursor`). The tenant predicate + /// is pre-applied to the secured select; the user `$filter` is additive over it + /// (SQL-level BOLA — a foreign value still ANDs the scope, so a cross-tenant + /// debit note never leaks). A bare list defaults to `debit_note_id ASC`. The + /// `GET /debit-notes` read-surface source; out-of-txn on a fresh scoped + /// connection. Mirrors [`Self::list_credit_notes`]. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor + /// (the caller projects it to a canonical 400). + pub async fn list_debit_notes( + &self, + scope: &AccessScope, + tenant: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + // Pre-apply the tenant predicate to the secured select; the user `$filter` + // is applied additively by `paginate_odata` (it never replaces this scope — + // BOLA preserved). + let base_select = debit_note::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(debit_note::Column::TenantId.eq(tenant))); + let query = query_with_default_order(query, "debit_note_id"); + paginate_odata::< + DebitNoteFilterField, + DebitNoteODataMapper, + debit_note::Entity, + debit_note::Model, + _, + _, + >( + base_select, + &conn, + &query, + ("debit_note_id", SortDir::Asc), + LimitCfg { + default: 25, + max: 200, + }, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// Read the `refund` row on the natural `(tenant, psp_refund_id, phase)` grain — + /// the live state of ONE PSP-refund phase (Z5-4). A single PSP refund advances + /// through several phase rows (`initiated`, `confirmed`, `rejected`/`voided`), + /// each its own row under the natural `UNIQUE (tenant, psp_refund_id, phase)` + /// index, so this yields at most one row. The `unknown_final` disposition uses it + /// to read the STAGE-1 (`initiated`) row's REAL `clearing_state` + `amount_minor` + /// (the open clearing it writes off) rather than assuming a hardcoded + /// `PENDING` / request amount: if the stage-1 already `SETTLED` (stage-2 drained) + /// or `REVERSED` (a reject/void landed) the clearing is NOT open, so the + /// disposition must be a no-op rather than an over-DR. Returns `None` when no row + /// exists for that `(psp_refund_id, phase)`. Scoped (SQL-level BOLA). Out-of-txn + /// on a fresh scoped connection. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_refund_by_psp_phase( + &self, + scope: &AccessScope, + tenant: Uuid, + psp_refund_id: &str, + phase: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + refund::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(refund::Column::TenantId.eq(tenant)) + .add(refund::Column::PspRefundId.eq(psp_refund_id)) + .add(refund::Column::Phase.eq(phase)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read refund by psp_refund_id/phase: {e}"))) + } +} + +/// Map a counter-write [`ScopeError`] to [`RepoError`]: a CHECK-constraint +/// violation (the `invoice_exposure` headroom cap) becomes +/// [`RepoError::MoneyOutCapExceeded`] (the handler refines it to +/// `CreditNoteExceedsHeadroom`); anything else stays [`RepoError::Db`]. Mirrors +/// `PaymentRepo::map_cap_violation` / `RecognitionRepo::map_cap_violation` with the +/// `invoice_exposure` constraint-name prefix. +fn map_headroom_violation(context: &str, err: &ScopeError) -> RepoError { + if let ScopeError::Db(db_err) = err + && is_headroom_check_violation(db_err) + { + return RepoError::MoneyOutCapExceeded(format!("{context}: {err}")); + } + RepoError::Db(format!("{context}: {err}")) +} + +/// `true` iff `err` is a CHECK-constraint violation, matched first by the +/// `invoice_exposure` constraint-name prefix and then by the SQLSTATE-anchored +/// fallbacks (Postgres `23514`, `SQLite` extended code `275`). Mirrors +/// `RecognitionRepo::is_check_violation` (a structured `sql_err()` is never a +/// CHECK, so an unstructured error is required). +fn is_headroom_check_violation(err: &DbErr) -> bool { + if err.sql_err().is_some() { + return false; + } + let msg = err.to_string().to_lowercase(); + if msg.contains("chk_ledger_invoice_exposure_") { + return true; + } + msg.contains("check constraint") + || msg.contains("check_violation") + || msg.contains("sqlite_constraint_check") + || msg.contains("sqlstate 23514") + || msg.contains("sqlstate: 23514") + || msg.contains("sqlstate=23514") + || msg.contains("code 23514") + || msg.contains("code: 23514") + || msg.contains("(23514)") + || msg.contains("(23514:") + || msg.starts_with("23514:") + || msg.contains(" 23514:") + || (msg.contains("sqlite") + && (msg.contains("code 275") + || msg.contains("code: 275") + || msg.contains("(275)") + || msg.contains("(275:"))) +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/approval_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/approval_repo.rs new file mode 100644 index 000000000..af79d956c --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/approval_repo.rs @@ -0,0 +1,595 @@ +//! `ApprovalRepo` — dual-control approval state (`bss.ledger_approval`) plus the +//! append-only comment thread (`bss.ledger_approval_comment`). +//! +//! The pending-create, the decision transitions (approve / reject / +//! request-changes / cancel / expire), and the resubmit run **inside the caller's +//! transaction** (the approval row locks in the pre-balance slot just before +//! `fiscal_period`, §4.3). State transitions use an **optimistic guard on the +//! expected current state** (mirroring [`DisputeRepo::dispute_advance`]): a +//! transition matched against the wrong state touches 0 rows, so a concurrent +//! decision (e.g. two approvers) leaves exactly one winner — the loser maps the +//! `0` to an invalid transition. Reads (queue + single + thread) run out-of-txn +//! through the PDP-compiled `AccessScope` (SQL-level BOLA — a foreign tenant +//! yields no row). Idempotency (DC13) is the partial-unique index on +//! `(tenant, kind, business_key) WHERE state IN ('PENDING','NEEDS_REWORK')`; the +//! service reads the active record before inserting. + +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait, Order}; +use serde_json::Value as JsonValue; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt, SecureInsertExt, SecureUpdateExt}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::approval::ApprovalState; +use crate::domain::approval::policy::{DualControlPolicy, PolicyVersion}; +use crate::domain::error::DomainError; +use crate::domain::model::RepoError; +use crate::infra::storage::entity::{ + dual_control_approval as approval, dual_control_comment as comment, + dual_control_policy as policy, +}; + +/// Owned seed for a fresh `PENDING` approval row (preparer step). +#[derive(Clone)] +pub struct NewPendingApproval { + pub approval_id: Uuid, + pub tenant: Uuid, + pub kind: String, + pub business_key: String, + pub intent: JsonValue, + pub amount_usd_eq_minor: Option, + pub threshold_snapshot: JsonValue, + pub reason_code: String, + pub prepared_by: Uuid, + pub prepared_at: DateTime, + pub correlation_id: Uuid, + pub expires_at: DateTime, +} + +/// Owned seed for a fresh effective-dated dual-control policy version (DC8). The +/// `version` is computed by the caller as `max(version) + 1` inside the same +/// serializable txn as the insert. +#[derive(Clone)] +pub struct NewPolicyVersion { + pub tenant: Uuid, + pub version: i64, + pub effective_from: DateTime, + pub d2_threshold_minor: i64, + pub a6_backdating_biz_days: i32, + pub pending_ttl_seconds: i64, + pub created_at_utc: DateTime, +} + +/// SeaORM-backed dual-control approval repository. +#[derive(Clone)] +pub struct ApprovalRepo { + db: DBProvider, +} + +impl ApprovalRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + // --- In-txn writes (called by the ApprovalService) --- + + /// Insert a fresh `PENDING` row (`revision = 0`). The caller has already + /// confirmed no active record exists for `(tenant, kind, business_key)` + /// (DC13); a racing duplicate hits the partial-unique index and errors. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure (incl. the active-row + /// uniqueness violation). + pub async fn insert_pending( + txn: &DbTx<'_>, + scope: &AccessScope, + row: NewPendingApproval, + ) -> Result<(), RepoError> { + let am = approval::ActiveModel { + approval_id: Set(row.approval_id), + tenant_id: Set(row.tenant), + kind: Set(row.kind), + state: Set(ApprovalState::Pending.as_str().to_owned()), + revision: Set(0), + business_key: Set(row.business_key), + intent: Set(row.intent), + amount_usd_eq_minor: Set(row.amount_usd_eq_minor), + threshold_snapshot: Set(row.threshold_snapshot), + reason_code: Set(row.reason_code), + prepared_by: Set(row.prepared_by), + prepared_at: Set(row.prepared_at), + approved_by: Set(None), + decided_at: Set(None), + correlation_id: Set(row.correlation_id), + expires_at: Set(row.expires_at), + }; + approval::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_approval scope: {e}")))? + .exec_with_returning(txn) + .await + .map_err(|e| RepoError::Db(format!("insert ledger_approval: {e}")))?; + Ok(()) + } + + /// Read the approval row inside the decision transaction (the service checks + /// `state` + `prepared_by` before executing the stored `intent`). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_in_txn( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + approval_id: Uuid, + ) -> Result, RepoError> { + approval::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(approval::Column::TenantId.eq(tenant)) + .add(approval::Column::ApprovalId.eq(approval_id)), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("read ledger_approval: {e}"))) + } + + /// Transition the row from `expected_state` to `new_state`, stamping the + /// decider + decision time. Matched on `(tenant, approval_id, state = + /// expected_state)` — the in-txn optimistic backstop. Returns the rows + /// affected: `0` means the row was not in `expected_state` (a concurrent + /// decision won, or a stale request), which the caller maps to an invalid + /// transition. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + #[allow(clippy::too_many_arguments)] // a decision write is intrinsically wide + pub async fn transition( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + approval_id: Uuid, + expected_state: &str, + new_state: &str, + decider: Option, + decided_at: Option>, + ) -> Result { + let result = approval::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr(approval::Column::State, Expr::value(new_state)) + .col_expr(approval::Column::ApprovedBy, Expr::value(decider)) + .col_expr(approval::Column::DecidedAt, Expr::value(decided_at)) + .filter( + Condition::all() + .add(approval::Column::TenantId.eq(tenant)) + .add(approval::Column::ApprovalId.eq(approval_id)) + .add(approval::Column::State.eq(expected_state)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("transition ledger_approval: {e}")))?; + Ok(result.rows_affected) + } + + /// Resubmit a `NEEDS_REWORK` row back to `PENDING` with the preparer's edited + /// intent + re-snapshot threshold, bumping `revision`. Matched on `(tenant, + /// approval_id, state = NEEDS_REWORK)`; `0` rows = not awaiting rework. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + #[allow(clippy::too_many_arguments)] // a resubmit re-snapshots several columns + pub async fn resubmit( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + approval_id: Uuid, + new_intent: JsonValue, + new_threshold_snapshot: JsonValue, + new_amount_usd_eq_minor: Option, + new_revision: i32, + ) -> Result { + let result = approval::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + approval::Column::State, + Expr::value(ApprovalState::Pending.as_str()), + ) + .col_expr(approval::Column::Intent, Expr::value(new_intent)) + .col_expr( + approval::Column::ThresholdSnapshot, + Expr::value(new_threshold_snapshot), + ) + .col_expr( + approval::Column::AmountUsdEqMinor, + Expr::value(new_amount_usd_eq_minor), + ) + .col_expr(approval::Column::Revision, Expr::value(new_revision)) + .filter( + Condition::all() + .add(approval::Column::TenantId.eq(tenant)) + .add(approval::Column::ApprovalId.eq(approval_id)) + .add(approval::Column::State.eq(ApprovalState::NeedsRework.as_str())), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("resubmit ledger_approval: {e}")))?; + Ok(result.rows_affected) + } + + /// Batch-expire active rows past `expires_at` (sweep job; also runnable as a + /// lazy pass). Returns the number expired. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn expire_due( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + now: DateTime, + ) -> Result { + let result = approval::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + approval::Column::State, + Expr::value(ApprovalState::Expired.as_str()), + ) + .filter( + Condition::all() + .add(approval::Column::TenantId.eq(tenant)) + .add( + approval::Column::State + .is_in(ApprovalState::ACTIVE.map(ApprovalState::as_str)), + ) + // `<= now` (not `< now`): a row whose `expires_at` lands exactly + // on `now` is reclaimable in this same pass, closing the + // one-instant dead zone vs `read_active`'s `ExpiresAt > now`. + .add(approval::Column::ExpiresAt.lte(now)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("expire ledger_approval: {e}")))?; + Ok(result.rows_affected) + } + + /// Append a comment to the thread (a free comment/question, or the mandatory + /// reason on a `reject` / `request-changes` decision). Append-only — there is + /// no update/delete path. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + #[allow(clippy::too_many_arguments)] // a flat append row; a struct adds churn + pub async fn append_comment( + txn: &DbTx<'_>, + scope: &AccessScope, + comment_id: Uuid, + approval_id: Uuid, + tenant: Uuid, + revision: i32, + author_actor: Uuid, + body: String, + created_at: DateTime, + ) -> Result<(), RepoError> { + let am = comment::ActiveModel { + comment_id: Set(comment_id), + approval_id: Set(approval_id), + tenant_id: Set(tenant), + revision: Set(revision), + author_actor: Set(author_actor), + body: Set(body), + created_at: Set(created_at), + }; + comment::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_approval_comment scope: {e}")))? + .exec_with_returning(txn) + .await + .map_err(|e| RepoError::Db(format!("insert ledger_approval_comment: {e}")))?; + Ok(()) + } + + /// Cross-tenant TTL sweep (DC12): flip every active (`PENDING`/`NEEDS_REWORK`) + /// approval whose `expires_at` has passed to `EXPIRED`, across all tenants, in + /// one statement — the system-context reaper pattern + /// ([`AccessScope::allow_all`], like the tie-out / aged-alarm sweeps; expiry is + /// platform maintenance, not a tenant-scoped action). `APPROVING` is excluded + /// (an in-flight approve is not expirable). Complements the lazy per-tenant + /// [`Self::expire_due`] pass in `create_pending`; returns the number expired. + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn expire_due_all(&self, now: DateTime) -> Result { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let result = approval::Entity::update_many() + .secure() + .scope_with(&AccessScope::allow_all()) + .col_expr( + approval::Column::State, + Expr::value(ApprovalState::Expired.as_str()), + ) + .filter( + Condition::all() + .add( + approval::Column::State + .is_in(ApprovalState::ACTIVE.map(ApprovalState::as_str)), + ) + .add(approval::Column::ExpiresAt.lte(now)), + ) + .exec(&conn) + .await + .map_err(|e| DomainError::Internal(format!("expire_due_all ledger_approval: {e}")))?; + Ok(result.rows_affected) + } + + /// Count `ledger_approval` rows currently in the transient `APPROVING` latch, + /// across all tenants (Z8-1). A healthy approve clears the latch within one txn + /// (latch → execute → mark), so a non-zero result observed by the maintenance + /// sweep is a crash-stranded approve — excluded from the TTL sweep + /// ([`Self::expire_due_all`]) and still holding the active-uniqueness slot — that + /// needs a manual re-approve. System-context reaper read + /// ([`AccessScope::allow_all`], like the TTL sweep). + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn count_approving_all(&self) -> Result { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + approval::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + // Tie the query to the domain enum's wire token (the single source of + // truth, mirrored by the migration CHECK) rather than a bare literal. + .filter( + Condition::all().add(approval::Column::State.eq(ApprovalState::Approving.as_str())), + ) + .count(&conn) + .await + .map_err(|e| DomainError::Internal(format!("count_approving_all ledger_approval: {e}"))) + } + + /// The greatest existing policy `version` for `tenant`, or `None` when the + /// tenant has no policy row yet — the seed for the next version number + /// (`max + 1`), read inside the same serializable txn as the insert so two + /// concurrent writers cannot mint the same version (the PK `(tenant, version)` + /// is the backstop). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn max_policy_version( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + ) -> Result, RepoError> { + let row = policy::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(policy::Column::TenantId.eq(tenant))) + .order_by(policy::Column::Version, Order::Desc) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("max dual_control_policy version: {e}")))?; + Ok(row.map(|r| r.version)) + } + + /// Insert one effective-dated dual-control policy version (DC8). Append-only — + /// a new threshold set is a new `(tenant, version)` row, never an update; the + /// resolver picks the latest `effective_from` (highest `version` on a tie). The + /// D2/A6/TTL CHECK ranges are the DB backstop; the caller validates first + /// (`validate_config` → `DualControlPolicyOutOfRange`). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure (incl. a `(tenant, version)` + /// PK collision from a concurrent writer). + pub async fn insert_policy_row( + txn: &DbTx<'_>, + scope: &AccessScope, + row: NewPolicyVersion, + ) -> Result<(), RepoError> { + let am = policy::ActiveModel { + tenant_id: Set(row.tenant), + version: Set(row.version), + effective_from: Set(row.effective_from), + d2_threshold_minor: Set(row.d2_threshold_minor), + a6_backdating_biz_days: Set(row.a6_backdating_biz_days), + pending_ttl_seconds: Set(row.pending_ttl_seconds), + created_at_utc: Set(row.created_at_utc), + }; + policy::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_dual_control_policy scope: {e}")))? + .exec_with_returning(txn) + .await + .map_err(|e| RepoError::Db(format!("insert ledger_dual_control_policy: {e}")))?; + Ok(()) + } + + // --- Out-of-txn reads (PDP In-scoped; SQL-level BOLA) --- + + /// Read a single approval for `(tenant, approval_id)`, or `None`. A foreign + /// tenant yields no row. + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn read( + &self, + scope: &AccessScope, + tenant: Uuid, + approval_id: Uuid, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + approval::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(approval::Column::TenantId.eq(tenant)) + .add(approval::Column::ApprovalId.eq(approval_id)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read ledger_approval: {e}"))) + } + + /// Read all effective-dated dual-control policy versions for a tenant (the + /// threshold resolver picks the one in effect). Empty ⇒ ratified defaults. + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn read_policy_versions( + &self, + scope: &AccessScope, + tenant: Uuid, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let rows = policy::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(policy::Column::TenantId.eq(tenant))) + .all(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read dual_control_policy: {e}")))?; + Ok(rows + .into_iter() + .map(|r| PolicyVersion { + effective_from: r.effective_from, + version: r.version, + policy: DualControlPolicy { + d2_threshold_minor: r.d2_threshold_minor, + a6_backdating_biz_days: r.a6_backdating_biz_days, + pending_ttl_seconds: r.pending_ttl_seconds, + }, + }) + .collect()) + } + + /// Read the single active (`PENDING`/`NEEDS_REWORK`) approval for + /// `(tenant, kind, business_key)`, if any — the idempotency lookup (DC13) the + /// service does before creating a fresh pending record. The partial-unique + /// index guarantees at most one. + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn read_active( + &self, + scope: &AccessScope, + tenant: Uuid, + kind: &str, + business_key: &str, + now: DateTime, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + approval::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(approval::Column::TenantId.eq(tenant)) + .add(approval::Column::Kind.eq(kind)) + .add(approval::Column::BusinessKey.eq(business_key)) + // `APPROVING` (the H2 execute latch) is also active — an + // idempotent re-prepare while an approve is mid-flight returns + // the in-flight record rather than colliding on the partial + // unique with no row to surface. + .add(approval::Column::State.is_in([ + ApprovalState::Pending.as_str(), + ApprovalState::NeedsRework.as_str(), + ApprovalState::Approving.as_str(), + ])) + // A lapsed approval (past its TTL) is no longer active: it must + // not win the idempotent short-circuit, and a fresh prepare must + // be able to replace it (the lazy `expire_due` pass in + // `create_pending` flips it to EXPIRED inside the insert txn). + .add(approval::Column::ExpiresAt.gt(now)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read_active ledger_approval: {e}"))) + } + + /// List the approval queue for a tenant, optionally filtered by `state` and + /// `kind`. Newest-first by `prepared_at` (sorted in memory). + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn list( + &self, + scope: &AccessScope, + tenant: Uuid, + state: Option<&str>, + kind: Option<&str>, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let mut predicate = Condition::all().add(approval::Column::TenantId.eq(tenant)); + if let Some(s) = state { + predicate = predicate.add(approval::Column::State.eq(s)); + } + if let Some(k) = kind { + predicate = predicate.add(approval::Column::Kind.eq(k)); + } + let mut rows = approval::Entity::find() + .secure() + .scope_with(scope) + .filter(predicate) + .all(&conn) + .await + .map_err(|e| DomainError::Internal(format!("list ledger_approval: {e}")))?; + rows.sort_by_key(|r| std::cmp::Reverse(r.prepared_at)); + Ok(rows) + } + + /// Read the full comment thread for an approval, oldest-first. + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn read_thread( + &self, + scope: &AccessScope, + tenant: Uuid, + approval_id: Uuid, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let mut rows = comment::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(comment::Column::TenantId.eq(tenant)) + .add(comment::Column::ApprovalId.eq(approval_id)), + ) + .all(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read thread: {e}")))?; + rows.sort_by_key(|r| r.created_at); + Ok(rows) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/chain_state_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/chain_state_repo.rs new file mode 100644 index 000000000..601212912 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/chain_state_repo.rs @@ -0,0 +1,164 @@ +//! `ChainStateRepo` — read and advance the per-tenant tamper-evidence chain +//! tip (`bss.chain_state`). `read_tip` returns the last sealed +//! `(row_hash, entry_id, period_id, seq)` for a tenant, or `None` at genesis +//! (no row yet). `advance` upserts the tip to the new sealed values +//! (`INSERT … ON CONFLICT (tenant_id) DO UPDATE`). Both run inside the +//! caller's posting transaction; tenant isolation runs through the `SecureORM` +//! layer (`.secure().scope_with(scope)` for reads, `.scope_with_model(scope, +//! &am)` for the tip upsert — the validating variant rejects a mismatched +//! `(scope, tenant)` rather than writing cross-tenant). +//! +//! ## Concurrency: no row-level `FOR UPDATE` — SERIALIZABLE is the invariant +//! +//! The tip read is a plain scoped `.one(txn)` — it takes no row lock. The +//! `SecureORM` query builder exposes no `FOR UPDATE`/`lock_exclusive`, and a +//! gear cannot issue a raw locking `SELECT` against the in-flight transaction +//! (the `DBRunner` runner type is sealed and hands out no raw +//! `DatabaseTransaction`). This mirrors the rest of the gear: the +//! [`crate::infra::posting::period::FiscalPeriodGuard`] and the +//! [`crate::infra::posting::projector::BalanceProjector`] both read lockless +//! and rely on the posting's `SERIALIZABLE` transaction for serialization. +//! Two concurrent seals onto the same tenant tip overlap on the `chain_state` +//! row they both read-then-write, so Postgres SSI detects the conflict and +//! aborts the loser (which retries) — and the `tenant_id` primary key makes +//! the `ON CONFLICT` upsert itself atomic regardless. +//! +//! **CONTRACT:** because the read takes no lock, +//! [`ChainStateRepo::read_tip`] + [`ChainStateRepo::advance`] are correct ONLY +//! inside a **SERIALIZABLE** transaction. The `ON CONFLICT` upsert keeps the +//! row internally consistent under any isolation, but a weaker level would let +//! two concurrent seals read the same tip and **fork the chain** (both compute +//! `prev_hash` from the same predecessor). Callers MUST run under +//! `TxConfig::serializable()`. The literal `FOR UPDATE` lock the design names +//! (§4.2/§7) is deferred until `SecureORM` exposes a locking read; swap it in +//! here when it lands and this isolation dependency goes away. + +use sea_orm::sea_query::OnConflict; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::DbError; +use toolkit_db::secure::{AccessScope, DbTx, ScopeError, SecureEntityExt, SecureInsertExt}; +use uuid::Uuid; + +use crate::infra::storage::entity::chain_state; + +/// Map a [`ScopeError`] to [`DbError`] **preserving the inner `sea_orm::DbErr` +/// variant** (mirrors `infra::audit::store::scope_to_db` / +/// `period_close::scope_to_db`). This is load-bearing for retry: a +/// statement-time serialization failure (SSI 40001) on the lockless tip +/// read or the tip-advance UPDATE surfaces as `ScopeError::Db(DbErr::Exec | +/// DbErr::Query)`; keeping that variant lets the `transaction_with_retry` +/// contention classifier recognise it and retry the post from a fresh tip. +/// Stringifying it (the old `RepoError::Db(format!(…))`) buried it in a +/// `DbErr::Custom`, which the classifier treats as the NON-retryable business +/// sentinel — so two concurrent posts on one tenant's chain tip surfaced a +/// statement-time abort as a 500 instead of retrying. +fn scope_to_db(e: ScopeError) -> DbError { + match e { + ScopeError::Db(db_err) => DbError::Sea(db_err), + other => DbError::Other(anyhow::anyhow!("chain-state scope: {other}")), + } +} + +/// The per-tenant chain tip: the last sealed row's hash, entry id, period, and +/// sequence. Mirrors the `chain_state` columns minus `tenant_id` (the key). +#[derive(Clone, Debug, PartialEq, Eq)] +#[allow( + clippy::struct_field_names, + reason = "fields mirror the chain_state tip columns (last_*)" +)] +pub struct TipRow { + pub last_row_hash: Vec, + pub last_entry_id: Uuid, + pub last_period_id: String, + pub last_seq: i64, +} + +/// Chain-state repository. Stateless — every method runs inside the caller's +/// posting transaction (`txn`), so it holds no `DBProvider` (mirrors +/// [`crate::infra::posting::idempotency::IdempotencyGate`]). +#[derive(Clone, Default)] +pub struct ChainStateRepo; + +impl ChainStateRepo { + #[must_use] + pub fn new() -> Self { + Self + } + + /// Read the chain tip for `tenant` inside `txn` under `scope`. Returns + /// `None` when no tip row exists yet (genesis — the next seal is the first + /// link). The read takes no row lock (see the module docs); the posting's + /// `SERIALIZABLE` transaction is the concurrency backstop. + /// + /// # Errors + /// [`DbError`] on a storage / scope failure, with the inner `sea_orm::DbErr` + /// variant preserved (see [`scope_to_db`]) so a serialization abort stays + /// retryable. + pub async fn read_tip( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + ) -> Result, DbError> { + let row = chain_state::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(chain_state::Column::TenantId.eq(tenant))) + .one(txn) + .await + .map_err(scope_to_db)?; + + Ok(row.map(|r| TipRow { + last_row_hash: r.last_row_hash, + last_entry_id: r.last_entry_id, + last_period_id: r.last_period_id, + last_seq: r.last_seq, + })) + } + + /// Advance (upsert) the chain tip for `tenant` to `tip` inside `txn`: + /// `INSERT … ON CONFLICT (tenant_id) DO UPDATE SET` the four tip columns. + /// A fresh tenant inserts the first tip; a subsequent seal overwrites it. + /// + /// **MUST run inside a `SERIALIZABLE` txn** — the matching [`Self::read_tip`] + /// is lockless, so SSI is the only thing preventing a forked chain under + /// concurrent seals (see the module docs). + /// + /// # Errors + /// [`DbError`] on a storage / scope failure, with the inner `sea_orm::DbErr` + /// variant preserved (see [`scope_to_db`]) so a serialization abort on the + /// tip-advance UPDATE stays retryable. + pub async fn advance( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + tip: &TipRow, + ) -> Result<(), DbError> { + let am = chain_state::ActiveModel { + tenant_id: Set(tenant), + last_row_hash: Set(tip.last_row_hash.clone()), + last_entry_id: Set(tip.last_entry_id), + last_period_id: Set(tip.last_period_id.clone()), + last_seq: Set(tip.last_seq), + }; + let on_conflict = OnConflict::column(chain_state::Column::TenantId) + .update_columns([ + chain_state::Column::LastRowHash, + chain_state::Column::LastEntryId, + chain_state::Column::LastPeriodId, + chain_state::Column::LastSeq, + ]) + .to_owned(); + + chain_state::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(scope_to_db)? + .on_conflict_raw(on_conflict) + .exec(txn) + .await + .map_err(scope_to_db)?; + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/dispute_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/dispute_repo.rs new file mode 100644 index 000000000..fdd29717f --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/dispute_repo.rs @@ -0,0 +1,330 @@ +//! `DisputeRepo` — the chargeback dispute current-state table +//! (`bss.ledger_dispute`), keyed by `(tenant_id, dispute_id)`. +//! +//! The **write** (`dispute_upsert` — seed on `opened`; `dispute_advance` — +//! advance `last_phase`/`cycle` on a `won`/`lost`/re-open, Group C) runs inside +//! the passed-in posting transaction (the in-txn sidecar, decision M), mirroring +//! [`PaymentRepo`](super::PaymentRepo)'s `seed_settlement` / `add_allocated` +//! shape: a scoped insert via `.secure().scope_with_model`, a scoped +//! `update_many` via `.secure().scope_with`. The dispute row is **lock rank 0** +//! — taken BEFORE the rank-1 `ledger_payment_settlement` write, keeping the lock +//! order acyclic. +//! +//! The **read** (`read_dispute`) takes the PDP-compiled `AccessScope` and runs +//! out-of-txn through `.secure().scope_with(scope)` (SQL-level BOLA — a foreign +//! tenant yields no row); the service uses it for the pre-read that selects the +//! variant + validates the transition before opening the post transaction. + +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::odata::sea_orm_filter::{LimitCfg, paginate_odata}; +use toolkit_db::secure::{ + AccessScope, DbTx, SecureEntityExt, SecureInsertExt, SecureOnConflict, SecureUpdateExt, +}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_odata::{ODataQuery, Page, SortDir}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::RepoError; +use crate::domain::payment::chargeback::{DisputePhase, DisputeVariant}; +use crate::infra::storage::entity::dispute; +use crate::infra::storage::odata_mapping::DisputeODataMapper; +use crate::infra::storage::repo::journal_repo::{ + OdataPageError, map_odata_err, query_with_default_order, +}; +use crate::odata::DisputeFilterField; + +/// SeaORM-backed dispute current-state repository. +#[derive(Clone)] +pub struct DisputeRepo { + db: DBProvider, +} + +impl DisputeRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + // --- In-txn writes (called by the chargeback post sidecar) --- + + /// Upsert the `ledger_dispute` row for an `opened` dispute + /// (`last_phase = OPENED`, the chosen `variant`, `cycle`, + /// `disputed_amount_minor`, and the `cash_hold_minor` held at open). On a + /// **fresh** dispute this seeds the row; on a + /// **re-open** (a new cycle after a prior `won`/`lost`, allowed by the + /// service's transition guard) the `(tenant, dispute_id)` PK already exists, + /// so `ON CONFLICT DO UPDATE` advances it to the new cycle's OPENED state + /// (variant / cycle / disputed re-set, `version + 1`). A same-`(dispute, + /// cycle, phase)` replay never reaches here — the engine's idempotency gate + /// short-circuits before the sidecar. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + #[allow(clippy::too_many_arguments)] // a wide row seed; grouping into a struct adds churn + pub async fn dispute_upsert( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + dispute_id: &str, + payment_id: &str, + currency: &str, + variant: DisputeVariant, + cycle: i32, + disputed_amount_minor: i64, + cash_hold_minor: i64, + ) -> Result<(), RepoError> { + let am = dispute::ActiveModel { + tenant_id: Set(tenant), + dispute_id: Set(dispute_id.to_owned()), + payment_id: Set(payment_id.to_owned()), + currency: Set(currency.to_owned()), + variant: Set(variant.as_str().to_owned()), + last_phase: Set(DisputePhase::Opened.as_str().to_owned()), + cycle: Set(cycle), + disputed_amount_minor: Set(disputed_amount_minor), + cash_hold_minor: Set(cash_hold_minor), + version: Set(0), + }; + // Re-open (won/lost → opened, new cycle) lands on the existing PK: net the + // row forward to the new cycle's OPENED state rather than colliding. + let on_conflict = SecureOnConflict::::columns([ + dispute::Column::TenantId, + dispute::Column::DisputeId, + ]) + .value(dispute::Column::Variant, Expr::value(variant.as_str())) + .and_then(|oc| { + oc.value( + dispute::Column::LastPhase, + Expr::value(DisputePhase::Opened.as_str()), + ) + }) + .and_then(|oc| oc.value(dispute::Column::Cycle, Expr::value(cycle))) + .and_then(|oc| { + oc.value( + dispute::Column::DisputedAmountMinor, + Expr::value(disputed_amount_minor), + ) + }) + .and_then(|oc| oc.value(dispute::Column::CashHoldMinor, Expr::value(cash_hold_minor))) + .and_then(|oc| { + oc.value( + dispute::Column::Version, + Expr::col((dispute::Entity, dispute::Column::Version)).add(1), + ) + }) + .map_err(|e| RepoError::Db(format!("ledger_dispute on_conflict: {e}")))?; + dispute::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_dispute scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| RepoError::Db(format!("upsert ledger_dispute: {e}")))?; + Ok(()) + } + + /// Advance an `OPENED` dispute to its `won`/`lost` outcome (`last_phase`, + /// re-stating `cycle` + `disputed_amount_minor`), bumping `version`. A scoped + /// UPDATE matched on `(tenant, dispute_id, last_phase = OPENED, cycle)`. + /// + /// The `(last_phase = OPENED, cycle)` predicate is the **in-txn race/stale + /// backstop**, not redundant with the caller's out-of-txn transition guard: + /// two different outcomes for the same dispute (a `won` and a `lost`, distinct + /// dedup keys) can both clear that guard, and a `(tenant, dispute_id)`-only + /// UPDATE would let the second writer overwrite the already-resolved row and + /// commit a second journal entry. Matching on the OPENED phase + the exact + /// cycle makes the loser — and any stale-cycle outcome — touch 0 rows and be + /// rejected as an invalid transition. SSI + retry serialize the writers; this + /// predicate decides the loser's fate cleanly. + /// + /// Re-open (`won`/`lost` → `opened`, a new cycle) does NOT come here — it + /// lands on [`Self::dispute_upsert`]'s `ON CONFLICT` path. Only the outcome + /// advance calls this, so the same-cycle match never blocks a legitimate + /// re-open. + /// + /// # Errors + /// [`RepoError::DisputeNotOpen`] when no `OPENED` row matched the requested + /// cycle (a concurrent resolve or a stale outcome); [`RepoError::Db`] on any + /// other scope / storage failure. + pub async fn dispute_advance( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + dispute_id: &str, + last_phase: DisputePhase, + cycle: i32, + disputed_amount_minor: i64, + ) -> Result<(), RepoError> { + let result = dispute::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr(dispute::Column::LastPhase, Expr::value(last_phase.as_str())) + .col_expr(dispute::Column::Cycle, Expr::value(cycle)) + .col_expr( + dispute::Column::DisputedAmountMinor, + Expr::value(disputed_amount_minor), + ) + .col_expr( + dispute::Column::Version, + Expr::col((dispute::Entity, dispute::Column::Version)).add(1), + ) + // Only advance the row STILL `OPENED` at THIS cycle: the in-txn + // backstop for a concurrent outcome race (a `won` + a `lost` both + // clearing the out-of-txn guard) and for a stale-cycle outcome — the + // loser / stale request matches 0 rows instead of overwriting an + // already-resolved dispute with a second committed entry. + .filter( + Condition::all() + .add(dispute::Column::TenantId.eq(tenant)) + .add(dispute::Column::DisputeId.eq(dispute_id)) + .add(dispute::Column::LastPhase.eq(DisputePhase::Opened.as_str())) + .add(dispute::Column::Cycle.eq(cycle)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("advance ledger_dispute: {e}")))?; + if result.rows_affected == 0 { + // No `OPENED` row at this cycle: the dispute was concurrently resolved + // (a `won`/`lost` race) or the outcome targets a stale cycle. A + // non-retryable invalid transition, not an infra fault. + return Err(RepoError::DisputeNotOpen(format!( + "ledger_dispute ({tenant}, {dispute_id}) is not OPENED at cycle {cycle} \ + — already resolved or stale outcome" + ))); + } + Ok(()) + } + + // --- Out-of-txn read (PDP In-scoped; SQL-level BOLA) --- + + /// Read the `ledger_dispute` row for `(tenant, dispute_id)` (the current + /// variant + cycle + last phase), or `None` when the dispute was never + /// opened. SQL-level BOLA: a foreign tenant yields no row. + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn read_dispute( + &self, + scope: &AccessScope, + tenant: Uuid, + dispute_id: &str, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let row = dispute::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(dispute::Column::TenantId.eq(tenant)) + .add(dispute::Column::DisputeId.eq(dispute_id)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read ledger_dispute: {e}")))?; + Ok(row) + } + + /// List the `ledger_dispute` current-state rows for `tenant` under `scope`, + /// cursor-paginated via the canonical `query` (`$filter` over `payment_id` / + /// `last_phase` / `variant`, `$orderby` / `limit` / `cursor`). The tenant + /// predicate is pre-applied to the secured select; the user `$filter` is + /// additive over it (SQL-level BOLA — a foreign value still ANDs the scope, so a + /// cross-tenant dispute never leaks). A bare list defaults to `dispute_id ASC`. + /// The `GET /disputes` read-surface source; out-of-txn on a fresh scoped + /// connection. Mirrors `AdjustmentRepo::list_refunds`. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor + /// (the caller projects it to a canonical 400). + pub async fn list_disputes( + &self, + scope: &AccessScope, + tenant: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + // Pre-apply the tenant predicate to the secured select; the user `$filter` + // is applied additively by `paginate_odata` (it never replaces this scope — + // BOLA preserved). + let base_select = dispute::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(dispute::Column::TenantId.eq(tenant))); + let query = query_with_default_order(query, "dispute_id"); + paginate_odata::< + DisputeFilterField, + DisputeODataMapper, + dispute::Entity, + dispute::Model, + _, + _, + >( + base_select, + &conn, + &query, + ("dispute_id", SortDir::Asc), + LimitCfg { + default: 25, + max: 200, + }, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// Read the OPEN (non-terminal) dispute on a PAYMENT, if any — the refund + /// dispute-hold pre-read (Z5-2, design §5). A dispute is OPEN exactly while its + /// `last_phase == OPENED`; the `won`/`lost` outcomes are terminal (the row stays + /// at the latest cycle's outcome until a re-open seeds a new `OPENED` cycle). A + /// refund MUST NOT move cash on a payment with an OPEN dispute (the disputed + /// funds are sub judice — held in `DISPUTE_HOLD` for `CASH_HOLD`, or reclassed + /// `DISPUTED` for `AR_RECLASS`), so the handler holds the cash leg until the + /// dispute resolves. + /// + /// Keyed on `(tenant, payment_id, last_phase = OPENED)` (NOT `dispute_id` — + /// the refund knows only the payment it unwinds). At most one OPEN dispute per + /// `(tenant, payment_id)` exists at a time: a cycle must resolve (`won`/`lost`) + /// before the same payment's dispute re-opens (the `opened` transition guard + /// rejects an `opened` on a still-`OPENED` row), so an `OPENED` row is unique + /// per payment. Returns the row (its `dispute_id` / `cycle` drive the held + /// payload + the hold drain's re-read), or `None` when the payment has no open + /// dispute. SQL-level BOLA: a foreign tenant yields no row. + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn read_open_dispute_for_payment( + &self, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let row = dispute::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(dispute::Column::TenantId.eq(tenant)) + .add(dispute::Column::PaymentId.eq(payment_id)) + .add(dispute::Column::LastPhase.eq(DisputePhase::Opened.as_str())), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read open dispute for payment: {e}")))?; + Ok(row) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/exception_queue_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/exception_queue_repo.rs new file mode 100644 index 000000000..a5d98d604 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/exception_queue_repo.rs @@ -0,0 +1,289 @@ +//! `ExceptionQueueRepo` — the durable close-blocking exception queue +//! (`bss.ledger_exception_queue`), keyed by `(tenant_id, exception_id)`. The +//! per-slice exception stubs + the reconciliation framework `open` rows here; +//! the close gate reads OPEN rows for a period (Slice 7, design §4.6). + +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use serde_json::Value as JsonValue; +use toolkit_db::odata::sea_orm_filter::{LimitCfg, paginate_odata}; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt, SecureInsertExt, SecureUpdateExt}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_odata::{ODataQuery, Page, SortDir}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::RepoError; +use crate::infra::storage::entity::exception_queue; +use crate::infra::storage::odata_mapping::ExceptionODataMapper; +use crate::infra::storage::repo::journal_repo::{ + OdataPageError, map_odata_err, query_with_default_order, +}; +use crate::odata::ExceptionFilterField; + +/// SeaORM-backed exception-queue repository. +#[derive(Clone)] +pub struct ExceptionQueueRepo { + db: DBProvider, +} + +impl ExceptionQueueRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// Open a new exception row (caller mints the synthetic `exception_id`). + #[allow( + clippy::too_many_arguments, + reason = "an exception row carries its full identity at open time" + )] + pub async fn open( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + exception_id: Uuid, + exception_type: &str, + business_ref: &str, + period_id: Option<&str>, + detail: Option, + ) -> Result<(), RepoError> { + let am = exception_queue::ActiveModel { + tenant_id: Set(tenant), + exception_id: Set(exception_id), + exception_type: Set(exception_type.to_owned()), + business_ref: Set(business_ref.to_owned()), + status: Set("OPEN".to_owned()), + period_id: Set(period_id.map(ToOwned::to_owned)), + detail: Set(detail), + opened_at: Set(Utc::now()), + resolved_at: Set(None), + resolved_by: Set(None), + }; + exception_queue::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_exception_queue scope: {e}")))? + .exec_with_returning(txn) + .await + .map_err(|e| RepoError::Db(format!("insert ledger_exception_queue: {e}")))?; + Ok(()) + } + + /// List OPEN close-blocking exceptions for a period (in-txn — the close gate + /// input). `APPROVED_EXCEPTION` rows are not OPEN, so they are excluded. + pub async fn list_open_in_txn( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + ) -> Result, RepoError> { + let rows = exception_queue::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(exception_queue::Column::TenantId.eq(tenant)) + .add(exception_queue::Column::PeriodId.eq(period_id)) + .add(exception_queue::Column::Status.eq("OPEN")), + ) + .all(txn) + .await + .map_err(|e| RepoError::Db(format!("list open ledger_exception_queue: {e}")))?; + Ok(rows) + } + + /// Whether an `OPEN` row already exists for `(tenant, type, business_ref)` — the + /// routing dedup. A periodically-ticking stub (e.g. the aged-refund-clearing job) + /// re-detects the same condition each scan, and an inline re-try repeats the same + /// business key; without this they would pile up duplicate OPEN rows. In-txn so + /// the check and the subsequent open share one snapshot. + pub async fn exists_open_for_ref( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + exception_type: &str, + business_ref: &str, + ) -> Result { + let row = exception_queue::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(exception_queue::Column::TenantId.eq(tenant)) + .add(exception_queue::Column::ExceptionType.eq(exception_type)) + .add(exception_queue::Column::BusinessRef.eq(business_ref)) + .add(exception_queue::Column::Status.eq("OPEN")), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("exists open exception_queue: {e}")))?; + Ok(row.is_some()) + } + + /// List exceptions for the dashboard (out-of-txn), tenant-scoped, optionally + /// filtered by status. + pub async fn list( + &self, + scope: &AccessScope, + tenant: Uuid, + status: Option<&str>, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let mut predicate = Condition::all().add(exception_queue::Column::TenantId.eq(tenant)); + if let Some(s) = status { + predicate = predicate.add(exception_queue::Column::Status.eq(s)); + } + let rows = exception_queue::Entity::find() + .secure() + .scope_with(scope) + .filter(predicate) + .order_by(exception_queue::Column::OpenedAt, sea_orm::Order::Desc) + .all(&conn) + .await + .map_err(|e| DomainError::Internal(format!("list ledger_exception_queue: {e}")))?; + Ok(rows) + } + + /// List the exception-queue rows for `tenant` under `scope`, cursor-paginated + /// via the canonical `query` (`$filter` over `type` / `status` / `business_ref` + /// / `period_id`, `$orderby` / `limit` / `cursor`). The tenant predicate is + /// pre-applied to the secured select; the user `$filter` is additive over it + /// (SQL-level BOLA — a foreign value still ANDs the scope, so a cross-tenant + /// exception never leaks). A bare list defaults to `exception_id ASC`. The + /// `GET /exceptions` dashboard source; out-of-txn on a fresh scoped connection. + /// Mirrors `AdjustmentRepo::list_refunds` / `DisputeRepo::list_disputes`. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor + /// (the caller projects it to a canonical 400). + pub async fn list_page( + &self, + scope: &AccessScope, + tenant: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + // Pre-apply the tenant predicate to the secured select; the user `$filter` + // is applied additively by `paginate_odata` (it never replaces this scope — + // BOLA preserved). + let base_select = exception_queue::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(exception_queue::Column::TenantId.eq(tenant))); + let query = query_with_default_order(query, "exception_id"); + paginate_odata::< + ExceptionFilterField, + ExceptionODataMapper, + exception_queue::Entity, + exception_queue::Model, + _, + _, + >( + base_select, + &conn, + &query, + ("exception_id", SortDir::Asc), + LimitCfg { + default: 25, + max: 200, + }, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// Transition an exception to a terminal / ack status (in-txn). + pub async fn resolve( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + exception_id: Uuid, + status: &str, + resolved_by: &str, + ) -> Result<(), RepoError> { + exception_queue::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr(exception_queue::Column::Status, Expr::value(status)) + .col_expr( + exception_queue::Column::ResolvedBy, + Expr::value(resolved_by), + ) + .col_expr(exception_queue::Column::ResolvedAt, Expr::value(Utc::now())) + .filter( + Condition::all() + .add(exception_queue::Column::TenantId.eq(tenant)) + .add(exception_queue::Column::ExceptionId.eq(exception_id)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("resolve ledger_exception_queue: {e}")))?; + Ok(()) + } + + /// Read one exception by id (out-of-txn, tenant-scoped) — the resolution + /// endpoint's pre-read (validate the transition against the row's type/status). + /// A foreign-owned id resolves to `None` (SQL-level BOLA, no existence leak). + pub async fn read( + &self, + scope: &AccessScope, + tenant: Uuid, + exception_id: Uuid, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let row = exception_queue::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(exception_queue::Column::TenantId.eq(tenant)) + .add(exception_queue::Column::ExceptionId.eq(exception_id)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read ledger_exception_queue: {e}")))?; + Ok(row) + } + + /// Apply a resolution transition in its own transaction (the REST resolution + /// endpoint's apply step). Thin wrapper over [`Self::resolve`] so the handler + /// need not own a transaction. + pub async fn resolve_one( + &self, + scope: &AccessScope, + tenant: Uuid, + exception_id: Uuid, + status: &str, + resolved_by: &str, + ) -> Result<(), DomainError> { + let scope = scope.clone(); + let status = status.to_owned(); + let resolved_by = resolved_by.to_owned(); + self.db + .transaction(move |txn| { + let scope = scope.clone(); + let status = status.clone(); + let resolved_by = resolved_by.clone(); + Box::pin(async move { + Self::resolve(txn, &scope, tenant, exception_id, &status, &resolved_by) + .await + .map_err(|e| DbError::Other(anyhow::anyhow!("resolve exception: {e}"))) + }) + }) + .await + .map_err(|e| DomainError::Internal(format!("resolve exception txn: {e}"))) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/fx_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/fx_repo.rs new file mode 100644 index 000000000..0d91f2bc9 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/fx_repo.rs @@ -0,0 +1,275 @@ +//! `FxRepo` — the DB plumbing for FX & multi-currency (Slice 5): the mutable +//! `ledger_fx_rate` "latest known" store (upserted by the `RateSyncJob` / ingest +//! endpoint, read by [`RateSource`](crate::infra::fx::rate_source::RateSource) at +//! lock time) and the immutable per-lock `ledger_fx_rate_snapshot` (frozen on a +//! journal line via `journal_line.rate_snapshot_ref`). +//! +//! **Scoping.** `ledger_fx_rate` is reference data — exchange rates are not +//! BOLA-sensitive object data, so it carries no resource axis. It is, however, +//! still read/written through the `SecureORM` runner with a tenant-only +//! `Scopable` (`no_resource`/`no_owner`/`no_type`, like `chain_state`): the +//! toolkit's `DBRunner`/`DBRunnerInternal` executor is sealed, so a gear cannot +//! get a raw `&DatabaseConnection` to run an un-secured query — the only +//! mechanically available path for the `tenant_id` predicate is +//! `.secure().scope_with(&AccessScope::for_tenant(tenant))`. The snapshot table +//! IS object-scoped (`rate_id` resource axis) and so takes the caller's compiled +//! [`AccessScope`] for SQL-level BOLA, exactly like the other secured reads/writes +//! in this gear. +//! +//! `ledger_fx_rate` is upserted out-of-txn (reference-data sync, no posting +//! transaction); the snapshot insert is likewise a single scoped insert. NEITHER +//! is wired into the live posting paths in this slice — see +//! [`RateLocker`](crate::infra::fx::rate_locker::RateLocker). + +use chrono::{DateTime, Utc}; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{AccessScope, SecureEntityExt, SecureInsertExt, SecureOnConflict}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::model::RepoError; +use crate::infra::storage::entity::{fx_rate, fx_rate_snapshot}; + +/// One immutable `ledger_fx_rate_snapshot` row to freeze for a single lock — the +/// rate [`RateSource`](crate::infra::fx::rate_source::RateSource) resolved, +/// captured verbatim so a re-read reproduces the exact translation regardless of +/// later provider revisions. `rate_id` is generated by the repo +/// ([`Uuid::now_v7`]) and returned to the caller, who stamps it onto each +/// translated line's `rate_snapshot_ref`. The snapshot is append-only (a +/// `BEFORE UPDATE OR DELETE` trigger rejects mutation), so a provider revision +/// only ever INSERTs a new row. +pub struct NewRateSnapshot { + pub tenant_id: Uuid, + pub base_currency: String, + pub quote_currency: String, + pub rate_micro: i64, + pub as_of: DateTime, + pub provider: String, + pub stale: bool, + pub fallback_order: i32, + pub triangulated_via: Option, +} + +/// One `ledger_fx_rate` row to upsert — the mutable "latest known" rate the +/// [`RateSyncJob`](crate::infra::jobs::rate_sync::RateSyncJob) and the +/// `POST /fx/rates` ingest endpoint refresh. Upsert-keyed on `(tenant_id, +/// base_currency, quote_currency, provider)`; on a PK conflict only the quote +/// moves (`rate_micro` / `as_of` / `fallback_order` / `updated_at`). A parameter +/// object (not a long arg list) — the fields are exactly the row's columns. +pub struct NewFxRate { + pub tenant_id: Uuid, + pub base_currency: String, + pub quote_currency: String, + pub provider: String, + pub rate_micro: i64, + pub as_of: DateTime, + pub fallback_order: i32, +} + +/// SeaORM-backed FX rate-store + per-lock snapshot repository. +#[derive(Clone)] +pub struct FxRepo { + db: DBProvider, +} + +impl FxRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// UPSERT one `ledger_fx_rate` row for `(tenant, base, quote, provider)` — the + /// "latest known" rate the `RateSyncJob` / ingest endpoint refreshes. On a PK + /// conflict the row's `rate_micro`, `as_of`, `fallback_order` and `updated_at` + /// are overwritten (the tuple identity — tenant/pair/provider — is immutable; + /// only the quote moves). `updated_at` is stamped from `Utc::now()` (passed in + /// the active model and mirrored in the conflict update) rather than a SQL + /// `now()`, so the stored ingest-time matches the value `SeaORM` round-trips on + /// both backends. + /// + /// Reference data, not BOLA-object data: scoped by the tenant axis only via + /// `AccessScope::for_tenant(tenant)` (the entity is `no_resource`). The + /// `SecureOnConflict` builder additionally refuses to put `tenant_id` in the + /// update set, so an upsert can never re-tenant a rate row. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn upsert_rate(&self, rate: &NewFxRate) -> Result<(), RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let scope = AccessScope::for_tenant(rate.tenant_id); + let now = Utc::now(); + let am = fx_rate::ActiveModel { + tenant_id: Set(rate.tenant_id), + base_currency: Set(rate.base_currency.clone()), + quote_currency: Set(rate.quote_currency.clone()), + provider: Set(rate.provider.clone()), + rate_micro: Set(rate.rate_micro), + as_of: Set(rate.as_of), + fallback_order: Set(rate.fallback_order), + updated_at: Set(now), + }; + let on_conflict = SecureOnConflict::::columns([ + fx_rate::Column::TenantId, + fx_rate::Column::BaseCurrency, + fx_rate::Column::QuoteCurrency, + fx_rate::Column::Provider, + ]) + .update_columns([ + fx_rate::Column::RateMicro, + fx_rate::Column::AsOf, + fx_rate::Column::FallbackOrder, + fx_rate::Column::UpdatedAt, + ]) + .map_err(|e| RepoError::Db(format!("ledger_fx_rate on_conflict: {e}")))?; + fx_rate::Entity::insert(am.clone()) + .secure() + .scope_with_model(&scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_fx_rate scope: {e}")))? + .on_conflict(on_conflict) + .exec(&conn) + .await + .map_err(|e| RepoError::Db(format!("upsert ledger_fx_rate: {e}")))?; + Ok(()) + } + + /// All provider rows in the `ledger_fx_rate` store for one `(tenant, base, + /// quote)` pair — the candidate set + /// [`RateSource`](crate::infra::fx::rate_source::RateSource) orders by the + /// configured provider precedence and screens for staleness. Returns the raw + /// rows (one per provider); ordering/selection is the resolver's job. Reference + /// data, tenant-axis scope only (`AccessScope::for_tenant`). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn latest_rates( + &self, + tenant: Uuid, + base: &str, + quote: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let scope = AccessScope::for_tenant(tenant); + let rows = fx_rate::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(fx_rate::Column::TenantId.eq(tenant)) + .add(fx_rate::Column::BaseCurrency.eq(base)) + .add(fx_rate::Column::QuoteCurrency.eq(quote)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list ledger_fx_rate: {e}")))?; + Ok(rows) + } + + /// Insert ONE immutable `ledger_fx_rate_snapshot` row from a resolved rate, + /// generating `rate_id = Uuid::now_v7()` and returning it (the value the + /// caller stamps onto each line's `rate_snapshot_ref`). The snapshot IS + /// object-scoped (`rate_id` resource axis), so it takes the caller's compiled + /// [`AccessScope`] and inserts via `.secure().scope_with_model` — SQL-level + /// BOLA, exactly like the other secured writes. Append-only: the table's + /// `BEFORE UPDATE OR DELETE` trigger rejects any later mutation. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn insert_snapshot( + &self, + scope: &AccessScope, + snap: &NewRateSnapshot, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + // Idempotent freeze (spec §2): a snapshot is keyed by the immutable rate + // identity `(tenant, base, quote, provider, as_of, fallback_order, + // rate_micro)` (`uq_fx_rate_snapshot_lock`), so a re-lock of the SAME rate + // — an idempotent post replay, or a second cross-currency entry at the same + // published rate — REUSES the existing snapshot rather than inserting a + // duplicate (which the constraint would reject). `rate_micro` is part of the + // identity (codex#1 / m032): a provider/manual CORRECTION at the SAME + // `as_of` carries a DIFFERENT rate, so it must freeze a NEW snapshot — never + // dedupe onto the stale one (which would stamp a `rate_snapshot_ref` that no + // longer reproduces the posted functional amount). The read-first dedups the + // (sequential) replay; a rare concurrent first-freeze of the same rate may + // still lose the insert race and surface as a retryable error. + if let Some(existing) = fx_rate_snapshot::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(fx_rate_snapshot::Column::TenantId.eq(snap.tenant_id)) + .add(fx_rate_snapshot::Column::BaseCurrency.eq(snap.base_currency.clone())) + .add(fx_rate_snapshot::Column::QuoteCurrency.eq(snap.quote_currency.clone())) + .add(fx_rate_snapshot::Column::Provider.eq(snap.provider.clone())) + .add(fx_rate_snapshot::Column::AsOf.eq(snap.as_of)) + .add(fx_rate_snapshot::Column::FallbackOrder.eq(snap.fallback_order)) + .add(fx_rate_snapshot::Column::RateMicro.eq(snap.rate_micro)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("find ledger_fx_rate_snapshot: {e}")))? + { + return Ok(existing.rate_id); + } + let rate_id = Uuid::now_v7(); + let am = fx_rate_snapshot::ActiveModel { + tenant_id: Set(snap.tenant_id), + rate_id: Set(rate_id), + base_currency: Set(snap.base_currency.clone()), + quote_currency: Set(snap.quote_currency.clone()), + rate_micro: Set(snap.rate_micro), + as_of: Set(snap.as_of), + provider: Set(snap.provider.clone()), + stale: Set(snap.stale), + fallback_order: Set(snap.fallback_order), + triangulated_via: Set(snap.triangulated_via.clone()), + }; + fx_rate_snapshot::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_fx_rate_snapshot scope: {e}")))? + .exec(&conn) + .await + .map_err(|e| RepoError::Db(format!("insert ledger_fx_rate_snapshot: {e}")))?; + Ok(rate_id) + } + + /// Scoped read-back of the frozen `ledger_fx_rate_snapshot` for `(tenant, + /// rate_id)`, or `None` when absent — the audit surface that reproduces a + /// line's exact lock. SQL-level BOLA: a foreign tenant yields no row. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_snapshot( + &self, + scope: &AccessScope, + tenant: Uuid, + rate_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = fx_rate_snapshot::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(fx_rate_snapshot::Column::TenantId.eq(tenant)) + .add(fx_rate_snapshot::Column::RateId.eq(rate_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read ledger_fx_rate_snapshot: {e}")))?; + Ok(row) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/fx_revaluation_mode_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/fx_revaluation_mode_repo.rs new file mode 100644 index 000000000..27e1b8468 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/fx_revaluation_mode_repo.rs @@ -0,0 +1,163 @@ +//! Repository for the per-tenant FX revaluation mode +//! (`ledger_tenant_fx_revaluation_mode`, VHP-1986): resolve the version in effect +//! (latest `effective_from <= at`, highest `version` on a tie) and append a new +//! effective-dated version. Mirrors [`PostingPolicyRepo`](super::PostingPolicyRepo). +//! Tenant-scoped via `SecureORM` (SQL-level BOLA); out-of-txn on a fresh scoped +//! connection (the mode is admin-plane, never the hot money path). + +use chrono::{DateTime, Utc}; +use sea_orm::ActiveValue::Set; +use sea_orm::{ColumnTrait, Condition, EntityTrait, Order}; +use toolkit_db::secure::{AccessScope, DBRunner, SecureEntityExt, SecureInsertExt}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::fx::revaluation_mode::RevaluationMode; +use crate::domain::model::RepoError; +use crate::infra::storage::entity::fx_revaluation_mode; + +/// `SeaORM`-backed FX revaluation-mode repository. +#[derive(Clone)] +pub struct FxRevaluationModeRepo { + db: DBProvider, +} + +impl FxRevaluationModeRepo { + /// Build over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// Resolve the revaluation mode in effect for `tenant` at instant `at` — the + /// row with the latest `effective_from <= at` (ties: highest `version`). + /// Returns `None` when the tenant has NO effective row, so the caller can + /// distinguish "unconfigured" (→ apply the fleet default + /// [`RevaluationMode::fleet_default`]) from an explicit `ModeA` opt-out. SQL-level + /// BOLA: a foreign tenant yields no rows (`None`). + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure, or when a stored value + /// fails to parse (an invariant breach — the column is CHECK-constrained and + /// only written via the validated domain type). + pub async fn read_effective_mode( + &self, + scope: &AccessScope, + tenant: Uuid, + at: DateTime, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = fx_revaluation_mode::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(fx_revaluation_mode::Column::TenantId.eq(tenant)) + .add(fx_revaluation_mode::Column::EffectiveFrom.lte(at)), + ) + .order_by(fx_revaluation_mode::Column::EffectiveFrom, Order::Desc) + .order_by(fx_revaluation_mode::Column::Version, Order::Desc) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read fx revaluation mode: {e}")))?; + let Some(row) = row else { + return Ok(None); + }; + RevaluationMode::parse(&row.revaluation_mode) + .map(Some) + .map_err(|e| { + RepoError::Db(format!( + "corrupt revaluation_mode for tenant {tenant} version {}: {e}", + row.version + )) + }) + } + + /// Append a new effective-dated mode version for `tenant`, effective from + /// `effective_from`. The version is `max(version) + 1` (`0` for the first). + /// Returns the new version. SQL-level BOLA. The `(tenant, version)` PK guards + /// a concurrent double-write (the loser fails with a storage error — accepted + /// for the rare admin-plane write). + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure. + pub async fn write_version( + &self, + scope: &AccessScope, + tenant: Uuid, + mode: RevaluationMode, + effective_from: DateTime, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let current = fx_revaluation_mode::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(fx_revaluation_mode::Column::TenantId.eq(tenant))) + .order_by(fx_revaluation_mode::Column::Version, Order::Desc) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read max fx-revaluation-mode version: {e}")))?; + let version = current.map_or(0, |r| r.version + 1); + let am = fx_revaluation_mode::ActiveModel { + tenant_id: Set(tenant), + version: Set(version), + effective_from: Set(effective_from), + revaluation_mode: Set(mode.as_str().to_owned()), + created_at_utc: Set(Utc::now()), + }; + fx_revaluation_mode::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_tenant_fx_revaluation_mode scope: {e}")))? + .exec(&conn) + .await + .map_err(|e| RepoError::Db(format!("insert ledger_tenant_fx_revaluation_mode: {e}")))?; + Ok(version) + } + + /// In-txn variant of [`Self::read_effective_mode`] — resolve the mode in effect + /// at `at` over an existing transaction/connection `runner`, so the period-close + /// gate can read it inside its serializable snapshot. Returns `None` when the + /// tenant has no effective row. Mirrors + /// [`FxRevaluationRunRepo::is_period_complete`](super::FxRevaluationRunRepo::is_period_complete). + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure or a corrupt stored value. + pub async fn read_effective_mode_in_txn( + runner: &R, + scope: &AccessScope, + tenant: Uuid, + at: DateTime, + ) -> Result, RepoError> { + let row = fx_revaluation_mode::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(fx_revaluation_mode::Column::TenantId.eq(tenant)) + .add(fx_revaluation_mode::Column::EffectiveFrom.lte(at)), + ) + .order_by(fx_revaluation_mode::Column::EffectiveFrom, Order::Desc) + .order_by(fx_revaluation_mode::Column::Version, Order::Desc) + .one(runner) + .await + .map_err(|e| RepoError::Db(format!("read fx revaluation mode (txn): {e}")))?; + let Some(row) = row else { + return Ok(None); + }; + RevaluationMode::parse(&row.revaluation_mode) + .map(Some) + .map_err(|e| { + RepoError::Db(format!( + "corrupt revaluation_mode for tenant {tenant} version {}: {e}", + row.version + )) + }) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/fx_revaluation_run_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/fx_revaluation_run_repo.rs new file mode 100644 index 000000000..267cef115 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/fx_revaluation_run_repo.rs @@ -0,0 +1,90 @@ +//! Repository for the Mode-B FX-revaluation completion marker +//! (`ledger_fx_revaluation_run`, VHP-1859 review C3). The revaluation job marks a +//! period COMPLETE after a clean period-end run; the period-close gate reads it. +//! Both ops are generic over `DBRunner` so they run on the caller's transaction. +//! Tenant-scoped via `SecureORM` (SQL-level BOLA). + +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{ + AccessScope, DBRunner, SecureEntityExt, SecureInsertExt, SecureOnConflict, +}; +use uuid::Uuid; + +use crate::domain::model::RepoError; +use crate::infra::storage::entity::fx_revaluation_run::{self, SCOPE_PERIOD, STATUS_COMPLETE}; + +/// `SeaORM`-backed FX-revaluation marker repository. Stateless: every method +/// takes the caller's `runner` (a txn or a scoped connection). +pub struct FxRevaluationRunRepo; + +impl FxRevaluationRunRepo { + /// Mark the period-end revaluation COMPLETE for `(tenant, period_id)` — + /// idempotent upsert (a re-run refreshes `completed_at_utc`). Called by the + /// revaluation job once `run_period` finishes every scope without error. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure. + pub async fn mark_complete( + runner: &R, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + ) -> Result<(), RepoError> { + let now = Utc::now(); + let am = fx_revaluation_run::ActiveModel { + tenant_id: Set(tenant), + period_id: Set(period_id.to_owned()), + scope: Set(SCOPE_PERIOD.to_owned()), + status: Set(STATUS_COMPLETE.to_owned()), + completed_at_utc: Set(now), + }; + let on_conflict = SecureOnConflict::::columns([ + fx_revaluation_run::Column::TenantId, + fx_revaluation_run::Column::PeriodId, + ]) + .value( + fx_revaluation_run::Column::Status, + Expr::value(STATUS_COMPLETE), + ) + .and_then(|oc| oc.value(fx_revaluation_run::Column::CompletedAtUtc, Expr::value(now))) + .map_err(|e| RepoError::Db(format!("fx_revaluation_run on_conflict: {e}")))?; + + fx_revaluation_run::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("fx_revaluation_run scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(runner) + .await + .map_err(|e| RepoError::Db(format!("fx_revaluation_run upsert: {e}")))?; + Ok(()) + } + + /// `true` when a COMPLETE marker exists for `(tenant, period_id)` — the + /// close gate's check that the period-end revaluation actually ran. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure. + pub async fn is_period_complete( + runner: &R, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + ) -> Result { + let row = fx_revaluation_run::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(fx_revaluation_run::Column::TenantId.eq(tenant)) + .add(fx_revaluation_run::Column::PeriodId.eq(period_id)) + .add(fx_revaluation_run::Column::Status.eq(STATUS_COMPLETE)), + ) + .one(runner) + .await + .map_err(|e| RepoError::Db(format!("read fx_revaluation_run: {e}")))?; + Ok(row.is_some()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/journal_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/journal_repo.rs new file mode 100644 index 000000000..aa70af152 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/journal_repo.rs @@ -0,0 +1,816 @@ +//! `JournalRepo` — append-only insert of an entry + its lines inside a +//! single passed-in transaction, plus a scoped read-back. Tenant +//! isolation runs through the `SecureORM` layer (`secure_insert` / +//! `.secure().scope_with(scope)`); P1 reads take an explicit +//! `AccessScope` that later phases populate from the PDP. + +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait, Order}; +use toolkit_db::odata::sea_orm_filter::{LimitCfg, paginate_odata}; +use toolkit_db::secure::{AccessScope, DBRunner, DbTx, SecureEntityExt, secure_insert}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_odata::{ODataOrderBy, ODataQuery, OrderKey, Page, SortDir}; +use uuid::Uuid; + +use crate::domain::model::{ + EntryKey, EntryRecord, EntryRef, LineRecord, NewEntry, NewLine, RepoError, +}; +use crate::infra::storage::entity::{ + account_balance, ar_invoice_balance, fx_rate_snapshot, journal_entry, journal_line, +}; +use crate::infra::storage::odata_mapping::{ + BalanceODataMapper, JournalEntryODataMapper, JournalLineODataMapper, +}; +use crate::odata::{BalanceFilterField, JournalEntryFilterField, JournalLineFilterField}; + +// Settlement journal markers for the period-scoped PSP-settled fold (C2) — mirror +// `bss_ledger_sdk::{SourceDocType, AccountClass, Side}` as stored on the rows. +const SETTLE_DOC: &str = "PAYMENT_SETTLE"; +const RETURN_DOC: &str = "SETTLEMENT_RETURN"; +const UNALLOCATED_CLASS: &str = "UNALLOCATED"; +const CREDIT_SIDE: &str = "CR"; +const DEBIT_SIDE: &str = "DR"; + +/// Per-endpoint pagination bounds for `GET /bss-ledger/v1/journal-lines` +/// (preserves the foundation's `DEFAULT_LINE_LIMIT` / `MAX_LINE_LIMIT`). +const LINE_LIMIT_CFG: LimitCfg = LimitCfg { + default: 25, + max: 200, +}; + +/// Per-endpoint pagination bounds for `GET /bss-ledger/v1/journal-entries` (the +/// entry-HEADER list, R5). One header per posted entry, so the same generous +/// default as the line list keeps the common single-page read one round-trip. +const ENTRY_LIMIT_CFG: LimitCfg = LimitCfg { + default: 25, + max: 200, +}; + +/// Per-endpoint pagination bounds for `GET /bss-ledger/v1/balances`. The chart +/// of accounts is small per tenant; a generous default keeps the common +/// single-page read one round-trip. +const BALANCE_LIMIT_CFG: LimitCfg = LimitCfg { + default: 25, + max: 200, +}; + +/// Inject a default keyset order on a bare list (no `$orderby`, no cursor) so +/// `paginate_odata` resolves a stable order. `field` is the entity's default +/// keyset column (a recognised `FilterField` variant); the `$filter` is left +/// intact because `paginate_odata` applies it itself (the ledger pre-applies +/// only the `tenant_id` predicate to the secured select, not the user filter). +/// Shared with [`super::reference_repo`]'s `list_accounts`. +pub(crate) fn query_with_default_order(query: &ODataQuery, field: &str) -> ODataQuery { + let mut out = query.clone(); + if out.cursor.is_none() && out.order.is_empty() { + out = out.with_order(ODataOrderBy(vec![OrderKey { + field: field.to_owned(), + dir: SortDir::Asc, + }])); + } + out +} + +/// Error of an `OData`-paginated list read. The two arms keep the caller-facing +/// `$filter` / cursor failures (a client error ⇒ canonical 400) separable from +/// a genuine storage fault (⇒ 500): the local client maps `Odata` through the +/// platform `CanonicalError::from(toolkit_odata::Error)` projection and `Db` +/// through an `Internal`. Mirrors RBAC's `map_odata_err_to_domain` split. +#[derive(Debug, thiserror::Error)] +pub enum OdataPageError { + /// Storage / connection failure (driver text bounded by the caller). + #[error("ledger list db error: {0}")] + Db(String), + /// Malformed `$filter` / `$orderby` / cursor — a client error. + #[error("ledger list odata error: {0}")] + Odata(#[from] toolkit_odata::Error), +} + +/// Map a `paginate_odata` failure into [`OdataPageError`]. The `Db` arm drops +/// the driver text into the `Db` variant (the caller redacts it for the +/// audit-side diagnostic); every parse/cursor variant stays as `Odata` so the +/// caller can project it to a canonical 400. Shared with +/// [`super::reference_repo`]'s `list_accounts`. +pub(crate) fn map_odata_err(err: toolkit_odata::Error) -> OdataPageError { + match err { + toolkit_odata::Error::Db(d) => OdataPageError::Db(d), + other => OdataPageError::Odata(other), + } +} + +/// SeaORM-backed journal repository. +#[derive(Clone)] +pub struct JournalRepo { + db: DBProvider, +} + +impl JournalRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// Insert a journal entry header and all its lines inside the same + /// passed-in transaction. `created_seq` is DB-generated and read + /// back from the inserted header row. The caller is responsible for + /// passing an already-balanced entry (the deferrable balance trigger + /// enforces this on Postgres; `SQLite` relies on the P3 app-level + /// assertion). + pub async fn insert_entry_with_lines( + &self, + txn: &DbTx<'_>, + entry: NewEntry, + lines: Vec, + ) -> Result { + let entry_id = entry.entry_id; + let tenant_id = entry.tenant_id; + let period_id = entry.period_id.clone(); + // One rate per entry (§4.3): the entry's locked snapshot is stamped onto + // every line below. `None` for a single-currency entry. Captured before + // the header consumes `entry`. + let rate_snapshot_ref = entry.rate_snapshot_ref; + // The insert scope is the entry's own tenant: a posting may only + // write rows it owns. allow-all is not used here on purpose. + let scope = AccessScope::for_tenant(tenant_id); + + let header = journal_entry::ActiveModel { + entry_id: Set(entry.entry_id), + tenant_id: Set(entry.tenant_id), + legal_entity_id: Set(entry.legal_entity_id), + period_id: Set(entry.period_id), + entry_currency: Set(entry.entry_currency), + source_doc_type: Set(entry.source_doc_type.as_str().to_owned()), + source_business_id: Set(entry.source_business_id), + reverses_entry_id: Set(entry.reverses_entry_id), + reverses_period_id: Set(entry.reverses_period_id), + posted_at_utc: Set(entry.posted_at_utc), + effective_at: Set(entry.effective_at), + origin: Set(entry.origin), + posted_by_actor_id: Set(entry.posted_by_actor_id), + correlation_id: Set(entry.correlation_id), + rounding_evidence: Set(entry.rounding_evidence), + // DB-generated: never set on insert. + created_seq: sea_orm::ActiveValue::NotSet, + row_hash: Set(None), + prev_hash: Set(None), + // Chain pointers are sealed by the tamper-evidence chain step, not + // at insert (mirrors `row_hash` / `prev_hash`). + prev_entry_id: Set(None), + prev_period_id: Set(None), + }; + + secure_insert::(header, &scope, txn) + .await + .map_err(|e| RepoError::Db(format!("insert journal_entry: {e}")))?; + + for line in lines { + let am = journal_line::ActiveModel { + line_id: Set(line.line_id), + entry_id: Set(entry_id), + tenant_id: Set(tenant_id), + period_id: Set(period_id.clone()), + payer_tenant_id: Set(line.payer_tenant_id), + seller_tenant_id: Set(line.seller_tenant_id), + resource_tenant_id: Set(line.resource_tenant_id), + account_id: Set(line.account_id), + account_class: Set(line.account_class.as_str().to_owned()), + gl_code: Set(line.gl_code), + side: Set(line.side.as_str().to_owned()), + amount_minor: Set(line.amount_minor), + currency: Set(line.currency), + currency_scale: Set(i16::from(line.currency_scale)), + invoice_id: Set(line.invoice_id), + due_date: Set(line.due_date), + revenue_stream: Set(line.revenue_stream), + mapping_status: Set(line.mapping_status.as_str().to_owned()), + functional_amount_minor: Set(line.functional_amount_minor), + functional_currency: Set(line.functional_currency), + tax_jurisdiction: Set(line.tax_jurisdiction), + tax_filing_period: Set(line.tax_filing_period), + tax_rate_ref: Set(line.tax_rate_ref), + legal_entity_id: Set(line.legal_entity_id), + invoice_item_ref: Set(line.invoice_item_ref), + sku_or_plan_ref: Set(line.sku_or_plan_ref), + price_id: Set(line.price_id), + pricing_snapshot_ref: Set(line.pricing_snapshot_ref), + po_allocation_group: Set(line.po_allocation_group), + credit_grant_event_type: Set(line.credit_grant_event_type), + ar_status: Set(line.ar_status), + // Slice 5: the entry's locked rate (one per entry, §4.3), stamped + // onto every line; `None` on a single-currency entry. + rate_snapshot_ref: Set(rate_snapshot_ref), + }; + secure_insert::(am, &scope, txn) + .await + .map_err(|e| RepoError::Db(format!("insert journal_line: {e}")))?; + } + + // Re-read the header to obtain the DB-generated `created_seq`: + // sea-orm does not project non-PK server-defaults back into the + // returned Model (and the SQLite sequence is trigger-assigned), + // so the freshly written value must be read inside the same txn. + let written = journal_entry::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(journal_entry::Column::EntryId.eq(entry_id)) + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::PeriodId.eq(period_id)), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("read-back journal_entry: {e}")))? + .ok_or_else(|| RepoError::RowVanished(format!("entry {entry_id}")))?; + + Ok(EntryRef { + entry_id: written.entry_id, + created_seq: written.created_seq, + }) + } + + /// Read back an entry and its lines under the supplied scope. The + /// `SecureORM` `scope_with` narrows by tenant; the key triple pins the + /// exact row. + pub async fn find_entry( + &self, + scope: &AccessScope, + key: EntryKey, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + + let header = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::EntryId.eq(key.entry_id)) + .add(journal_entry::Column::TenantId.eq(key.tenant_id)) + .add(journal_entry::Column::PeriodId.eq(key.period_id.clone())), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("find journal_entry: {e}")))?; + + let Some(header) = header else { + return Ok(None); + }; + + let line_rows = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_line::Column::EntryId.eq(key.entry_id)) + .add(journal_line::Column::TenantId.eq(key.tenant_id)) + .add(journal_line::Column::PeriodId.eq(key.period_id)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("find journal_line: {e}")))?; + + let lines = line_rows.into_iter().map(line_to_record).collect(); + Ok(Some(entry_to_record(header, lines))) + } + + /// Read an entry and its lines by `(tenant_id, entry_id)` under `scope`, + /// without the `period_id` (the `get_entry` read seam supplies only the + /// tenant + id). The unique `entry_id` pins exactly one header. SQL-level + /// BOLA: a foreign-tenant scope yields `None`. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn find_entry_with_lines( + &self, + scope: &AccessScope, + tenant_id: Uuid, + entry_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + + let header = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::EntryId.eq(entry_id)) + .add(journal_entry::Column::TenantId.eq(tenant_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("find journal_entry by id: {e}")))?; + + let Some(header) = header else { + return Ok(None); + }; + + let line_rows = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_line::Column::EntryId.eq(entry_id)) + .add(journal_line::Column::TenantId.eq(tenant_id)), + ) + .order_by(journal_line::Column::LineId, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("find journal_line by entry: {e}")))?; + + let lines = line_rows.into_iter().map(line_to_record).collect(); + Ok(Some(entry_to_record(header, lines))) + } + + /// List every entry (with its lines) for `(tenant, source_doc_type)` whose + /// `source_business_id` starts with `business_id_prefix`, under `scope` — the + /// unrealized-revaluation reversal's lookup of all per-payer `FX_REVALUATION` + /// entries for one `period:scope:` (each payer is its own entry, so the + /// reversal fans out over them). Ordered by `entry_id` for a deterministic + /// reversal order. SQL-level BOLA: a foreign tenant yields no entries. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn list_entries_with_lines_by_doc_prefix( + &self, + scope: &AccessScope, + tenant_id: Uuid, + source_doc_type: &str, + business_id_prefix: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let headers = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::SourceDocType.eq(source_doc_type)) + .add(journal_entry::Column::SourceBusinessId.starts_with(business_id_prefix)), + ) + .order_by(journal_entry::Column::EntryId, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list journal_entry by doc prefix: {e}")))?; + + let mut out = Vec::with_capacity(headers.len()); + for header in headers { + let entry_id = header.entry_id; + let line_rows = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_line::Column::EntryId.eq(entry_id)) + .add(journal_line::Column::TenantId.eq(tenant_id)), + ) + .order_by(journal_line::Column::LineId, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("find journal_line by entry: {e}")))?; + let lines = line_rows.into_iter().map(line_to_record).collect(); + out.push(entry_to_record(header, lines)); + } + Ok(out) + } + + /// Period-scoped NET settled total for the PSP reconciliation (C2). Sums the + /// `UNALLOCATED` legs on the period's settlement journal — `CR` on each + /// `PAYMENT_SETTLE` (`+gross`) minus `DR` on each `SETTLEMENT_RETURN` + /// (`−returned`) — yielding the period's net-of-returns settled, the SAME basis + /// the PSP report carries (`SettlementReport.settled_minor` is net of + /// refunds/returns). This replaces the lifetime per-payment + /// `payment_settlement.settled_minor` counter (PK `(tenant, payment_id)`, no + /// period column), which put the two recon sides on different bases. In-memory + /// fold (the gear exposes no SQL aggregate); the scoped read keeps SQL-level + /// BOLA. Runs on the caller's `runner` (the recon txn) so it joins that snapshot. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure, or if the period total + /// falls outside `i64` (a corrupt-data invariant breach — sums in `i128`). + pub async fn sum_period_settled_net( + &self, + runner: &R, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + ) -> Result<(i64, usize), RepoError> { + let entries = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant)) + .add(journal_entry::Column::PeriodId.eq(period_id)) + .add(journal_entry::Column::SourceDocType.is_in([SETTLE_DOC, RETURN_DOC])), + ) + .all(runner) + .await + .map_err(|e| RepoError::Db(format!("recon PSP: read settlement entries: {e}")))?; + if entries.is_empty() { + return Ok((0, 0)); + } + // PAYMENT_SETTLE entry count — the per-1000 rounding-tolerance basis the + // caller uses (returns excluded; they reduce the total, not the count). + let settle_count = entries + .iter() + .filter(|e| e.source_doc_type == SETTLE_DOC) + .count(); + let entry_ids: Vec = entries.iter().map(|e| e.entry_id).collect(); + let lines = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_line::Column::TenantId.eq(tenant)) + .add(journal_line::Column::EntryId.is_in(entry_ids)), + ) + .all(runner) + .await + .map_err(|e| RepoError::Db(format!("recon PSP: read settlement lines: {e}")))?; + // UNALLOCATED legs only: the settle's CR (gross) adds, a return's DR + // (reversal) subtracts — netting to the period's settled total. + let mut net: i128 = 0; + for line in &lines { + if line.account_class != UNALLOCATED_CLASS { + continue; + } + match line.side.as_str() { + CREDIT_SIDE => net += i128::from(line.amount_minor), + DEBIT_SIDE => net -= i128::from(line.amount_minor), + _ => {} + } + } + let net_i64 = i64::try_from(net).map_err(|_| { + RepoError::Db(format!( + "recon PSP: period settled total out of i64 range \ + (tenant {tenant}, period {period_id})" + )) + })?; + Ok((net_i64, settle_count)) + } + + /// List journal lines for `tenant_id` under `scope`, cursor-paginated via + /// the canonical `query` (`$filter` / `$orderby` / `limit` / `cursor`). The + /// caller's `tenant_id` predicate is pre-applied to the secured select; the + /// user `$filter` is **additive over** that scope (`paginate_odata` ANDs it + /// in), so a foreign filter value still ANDs the `tenant_id` + `SecureORM` + /// scope (SQL-level BOLA). Returns the canonical [`Page`] envelope. A bare + /// list defaults to `line_id ASC` (the entity PK), matching the foundation. + /// + /// Mirrors RBAC's `RoleAssignmentRepository::list`: + /// `Entity::find().secure().scope_with(scope)` pre-filtered by the + /// caller-derived predicate, then handed to `paginate_odata`. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor + /// (the caller projects it to a canonical 400). + pub async fn list_lines( + &self, + scope: &AccessScope, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + + // Pre-apply the tenant predicate to the secured select; the user + // `$filter` is applied additively by `paginate_odata` (it never + // replaces this scope — BOLA preserved). + let base_select = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(journal_line::Column::TenantId.eq(tenant_id))); + + let query = query_with_default_order(query, "line_id"); + paginate_odata::< + JournalLineFilterField, + JournalLineODataMapper, + journal_line::Entity, + journal_line::Model, + _, + _, + >( + base_select, + &conn, + &query, + ("line_id", SortDir::Asc), + LINE_LIMIT_CFG, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// List journal entry HEADERS for `tenant_id` under `scope`, cursor-paginated + /// via the canonical `query` (`$filter` over `source_doc_type` / + /// `source_business_id` / `period_id`, `$orderby` / `limit` / `cursor`). This + /// is the header-only list (R5) — a separate collection over `journal_entry` + /// (NOT a new `journal_line` filter) because `source_doc_type` / + /// `source_business_id` are columns on the entry HEADER, never on the line, so + /// "list all `MANUAL_ADJUSTMENT` entries" / "all `REFUND` / `CREDIT_NOTE` + /// entries" can only be served from the header table. The caller's `tenant_id` + /// predicate is pre-applied to the secured select; the user `$filter` is + /// **additive over** that scope (`paginate_odata` ANDs it in), so a foreign + /// filter value still ANDs the `tenant_id` + `SecureORM` scope (SQL-level + /// BOLA). Returns the canonical [`Page`] envelope of `journal_entry` headers + /// (NO lines — a caller reads the full entry+lines via `get_entry`). A bare + /// list defaults to `entry_id ASC` (the `journal_entry` PK's keyset leg). + /// Mirrors [`Self::list_lines`]. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor + /// (the caller projects it to a canonical 400). + pub async fn list_entries( + &self, + scope: &AccessScope, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + + // Pre-apply the tenant predicate to the secured select; the user + // `$filter` is applied additively by `paginate_odata` (it never + // replaces this scope — BOLA preserved). + let base_select = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(journal_entry::Column::TenantId.eq(tenant_id))); + + let query = query_with_default_order(query, "entry_id"); + paginate_odata::< + JournalEntryFilterField, + JournalEntryODataMapper, + journal_entry::Entity, + journal_entry::Model, + _, + _, + >( + base_select, + &conn, + &query, + ("entry_id", SortDir::Asc), + ENTRY_LIMIT_CFG, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// List the `account_balance` cache rows for `tenant_id` under `scope`, + /// cursor-paginated via the canonical `query` (`$filter` over + /// `account_class` / `currency`, `$orderby` / `limit` / `cursor`). The + /// tenant predicate is pre-applied to the secured select; the user + /// `$filter` is additive over it (SQL-level BOLA — a foreign value still + /// ANDs the scope). A bare list defaults to `account_id ASC`. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor + /// (the caller projects it to a canonical 400). + pub async fn list_balances( + &self, + scope: &AccessScope, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + + let base_select = account_balance::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(account_balance::Column::TenantId.eq(tenant_id))); + + let query = query_with_default_order(query, "account_id"); + paginate_odata::< + BalanceFilterField, + BalanceODataMapper, + account_balance::Entity, + account_balance::Model, + _, + _, + >( + base_select, + &conn, + &query, + ("account_id", SortDir::Asc), + BALANCE_LIMIT_CFG, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// List the `ar_invoice_balance` cache rows for `tenant_id` under `scope`, + /// optionally narrowed to one `payer_tenant_id`. SQL-level BOLA: a foreign- + /// tenant scope yields no rows. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn list_ar_invoice_balances( + &self, + scope: &AccessScope, + tenant_id: Uuid, + payer_tenant_id: Option, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let mut condition = + Condition::all().add(ar_invoice_balance::Column::TenantId.eq(tenant_id)); + if let Some(payer) = payer_tenant_id { + condition = condition.add(ar_invoice_balance::Column::PayerTenantId.eq(payer)); + } + let rows = ar_invoice_balance::Entity::find() + .secure() + .scope_with(scope) + .filter(condition) + .order_by(ar_invoice_balance::Column::InvoiceId, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list ar_invoice_balance: {e}")))?; + Ok(rows) + } + + /// Resolve the `entry_id`s for a `(tenant_id, source_business_id)` under + /// `scope` (the header carries `source_business_id`, the line does not). + /// Used by the `list_lines` read seam when the SDK filter pins a business + /// document. SQL-level BOLA: a foreign-tenant scope yields no ids. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn entry_ids_for_business_id( + &self, + scope: &AccessScope, + tenant_id: Uuid, + source_business_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::SourceBusinessId.eq(source_business_id)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("resolve entry ids by business id: {e}")))?; + Ok(rows.into_iter().map(|r| r.entry_id).collect()) + } + + /// The locked FX `rate_micro` of the posted entry identified by + /// `(source_business_id, source_doc_type)` — read from any cross-currency line's + /// `rate_snapshot_ref` (one rate per entry, §4.3) -> `fx_rate_snapshot`. `None` for + /// a single-currency entry (no snapshot) or an absent reference. Lets the + /// dual-control gate value the threshold at the OPERATION's own locked rate + /// (design D2), not a fresh gate-time rate. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn locked_rate_micro_for( + &self, + scope: &AccessScope, + tenant_id: Uuid, + source_business_id: &str, + source_doc_type: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let entry_ids: Vec = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant_id)) + .add(journal_entry::Column::SourceBusinessId.eq(source_business_id)) + .add(journal_entry::Column::SourceDocType.eq(source_doc_type)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("locked-rate entry lookup: {e}")))? + .into_iter() + .map(|r| r.entry_id) + .collect(); + if entry_ids.is_empty() { + return Ok(None); + } + // Any cross-currency line of the entry carries the lock's snapshot ref. + let line = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_line::Column::TenantId.eq(tenant_id)) + .add(journal_line::Column::EntryId.is_in(entry_ids)) + .add(journal_line::Column::RateSnapshotRef.is_not_null()), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("locked-rate line lookup: {e}")))?; + let Some(rate_id) = line.and_then(|l| l.rate_snapshot_ref) else { + return Ok(None); + }; + let snap = fx_rate_snapshot::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(fx_rate_snapshot::Column::TenantId.eq(tenant_id)) + .add(fx_rate_snapshot::Column::RateId.eq(rate_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("locked-rate snapshot lookup: {e}")))?; + Ok(snap.map(|s| s.rate_micro)) + } +} + +// The repo-side `LineReadFilter` / `BalanceReadFilter` structs are gone: the +// list endpoints now take a canonical `toolkit_odata::ODataQuery` (`$filter` / +// `$orderby` / `cursor`) which `paginate_odata` lowers to a `WHERE`/`ORDER BY` +// against the mapped columns, additive over the SecureORM tenant scope. + +/// Map a `journal_entry` row + its lines into the domain record. +fn entry_to_record(row: journal_entry::Model, lines: Vec) -> EntryRecord { + EntryRecord { + entry_id: row.entry_id, + tenant_id: row.tenant_id, + legal_entity_id: row.legal_entity_id, + period_id: row.period_id, + entry_currency: row.entry_currency, + source_doc_type: row.source_doc_type, + source_business_id: row.source_business_id, + reverses_entry_id: row.reverses_entry_id, + reverses_period_id: row.reverses_period_id, + posted_at_utc: row.posted_at_utc, + effective_at: row.effective_at, + origin: row.origin, + posted_by_actor_id: row.posted_by_actor_id, + correlation_id: row.correlation_id, + rounding_evidence: row.rounding_evidence, + created_seq: row.created_seq, + lines, + } +} + +/// Map a `journal_line` row into the domain record. +fn line_to_record(row: journal_line::Model) -> LineRecord { + LineRecord { + line_id: row.line_id, + entry_id: row.entry_id, + tenant_id: row.tenant_id, + period_id: row.period_id, + payer_tenant_id: row.payer_tenant_id, + seller_tenant_id: row.seller_tenant_id, + resource_tenant_id: row.resource_tenant_id, + account_id: row.account_id, + account_class: row.account_class, + gl_code: row.gl_code, + side: row.side, + amount_minor: row.amount_minor, + currency: row.currency, + currency_scale: row.currency_scale, + invoice_id: row.invoice_id, + due_date: row.due_date, + revenue_stream: row.revenue_stream, + mapping_status: row.mapping_status, + functional_amount_minor: row.functional_amount_minor, + functional_currency: row.functional_currency, + tax_jurisdiction: row.tax_jurisdiction, + tax_filing_period: row.tax_filing_period, + tax_rate_ref: row.tax_rate_ref, + legal_entity_id: row.legal_entity_id, + invoice_item_ref: row.invoice_item_ref, + sku_or_plan_ref: row.sku_or_plan_ref, + price_id: row.price_id, + pricing_snapshot_ref: row.pricing_snapshot_ref, + po_allocation_group: row.po_allocation_group, + credit_grant_event_type: row.credit_grant_event_type, + ar_status: row.ar_status, + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/payer_state_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/payer_state_repo.rs new file mode 100644 index 000000000..f2bfa7440 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/payer_state_repo.rs @@ -0,0 +1,148 @@ +//! `PayerStateRepo` — the payer lifecycle row (`bss.ledger_payer_state`) plus the +//! closure balance check (VHP-1852 Phase 2). Absence of a row means OPEN; closure +//! upserts the row to `CLOSED` with the approver + the closed-with-open-balance +//! marker. The outstanding-balance check reads the per-payer AR cache +//! (`ledger_ar_payer_balance`); a non-zero grain means closing strands a balance, +//! which routes the closure through dual-control. +//! +//! Reads + the closure upsert run out-of-txn through the PDP-compiled scope +//! (SQL-level BOLA). The closure upsert is a single idempotent statement +//! (`INSERT … ON CONFLICT DO UPDATE`), so it needs no explicit transaction. + +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{AccessScope, SecureEntityExt, SecureInsertExt, SecureOnConflict}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::status::PAYER_LIFECYCLE_CLOSED; +use crate::infra::storage::entity::{ar_payer_balance, payer_state}; + +/// SeaORM-backed payer-lifecycle repository. +#[derive(Clone)] +pub struct PayerStateRepo { + db: DBProvider, +} + +impl PayerStateRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// Read the payer-lifecycle row, or `None` (absence ⇒ OPEN). + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn read( + &self, + scope: &AccessScope, + tenant: Uuid, + payer_tenant_id: Uuid, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + payer_state::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(payer_state::Column::TenantId.eq(tenant)) + .add(payer_state::Column::PayerTenantId.eq(payer_tenant_id)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read ledger_payer_state: {e}"))) + } + + /// Whether the payer still holds a non-zero AR grain — closing it would strand + /// a balance, so the closure must go through dual-control (design 01 §4.2). + /// MVP reads the AR cache only; unallocated / reusable-credit customer balances + /// are a follow-up. + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn has_outstanding_balance( + &self, + scope: &AccessScope, + tenant: Uuid, + payer_tenant_id: Uuid, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let nonzero = ar_payer_balance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(ar_payer_balance::Column::TenantId.eq(tenant)) + .add(ar_payer_balance::Column::PayerTenantId.eq(payer_tenant_id)) + .add(ar_payer_balance::Column::BalanceMinor.ne(0)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read ledger_ar_payer_balance: {e}")))?; + Ok(nonzero.is_some()) + } + + /// Upsert the payer-lifecycle row to `CLOSED`, stamping the approver, the + /// closed-with-open-balance marker, and the change time. Idempotent + /// (`ON CONFLICT DO UPDATE`); a re-close lands on the same PK. + /// + /// # Errors + /// [`DomainError::Internal`] on a scope or storage failure. + pub async fn close( + &self, + scope: &AccessScope, + tenant: Uuid, + payer_tenant_id: Uuid, + approved_by: Uuid, + closed_with_open_balance: bool, + ) -> Result<(), DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let now = Utc::now(); + let am = payer_state::ActiveModel { + tenant_id: Set(tenant), + payer_tenant_id: Set(payer_tenant_id), + lifecycle_state: Set(PAYER_LIFECYCLE_CLOSED.to_owned()), + closed_with_open_balance: Set(closed_with_open_balance), + approved_by: Set(Some(approved_by)), + changed_at: Set(Some(now)), + }; + let on_conflict = SecureOnConflict::::columns([ + payer_state::Column::TenantId, + payer_state::Column::PayerTenantId, + ]) + .value( + payer_state::Column::LifecycleState, + Expr::value(PAYER_LIFECYCLE_CLOSED.to_owned()), + ) + .and_then(|oc| { + oc.value( + payer_state::Column::ClosedWithOpenBalance, + Expr::value(closed_with_open_balance), + ) + }) + .and_then(|oc| oc.value(payer_state::Column::ApprovedBy, Expr::value(approved_by))) + .and_then(|oc| oc.value(payer_state::Column::ChangedAt, Expr::value(now))) + .map_err(|e| DomainError::Internal(format!("ledger_payer_state on_conflict: {e}")))?; + payer_state::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| DomainError::Internal(format!("ledger_payer_state scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(&conn) + .await + .map_err(|e| DomainError::Internal(format!("close ledger_payer_state: {e}")))?; + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/payment_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/payment_repo.rs new file mode 100644 index 000000000..1af14d4f5 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/payment_repo.rs @@ -0,0 +1,1508 @@ +//! `PaymentRepo` — the payment counter tables (`payment_settlement`, +//! `payment_allocation`, `payment_allocation_refund`) plus the allocation +//! candidate / view reads. +//! +//! The counter **writes** (`seed_settlement`, `add_allocated`, +//! `insert_allocation_rows`, `bump_allocation_refund`) run inside the +//! passed-in posting transaction (the in-txn sidecar, decision M): each +//! mirrors the projector's `upsert_ar_payer` shape — a scoped insert with a +//! `SecureOnConflict` that nets `col + delta` and bumps `version + 1`. The +//! per-payment cap CHECKs (`allocated_minor <= settled_minor`, the +//! refund-vs-allocated CHECK) are the concurrency backstop under +//! `SERIALIZABLE`; a violation surfaces as [`RepoError::MoneyOutCapExceeded`] +//! (the sidecar turns it into the `ALLOCATION_EXCEEDS_SETTLED` wire code). +//! +//! The **reads** (`list_open_ar_invoices`, `list_payment_allocations`, +//! `read_unallocated`, `read_settlement`, `read_effective_policy`) take the +//! PDP-compiled `AccessScope` and run through `.secure().scope_with(scope)` +//! (SQL-level BOLA — a foreign tenant yields no rows). + +use bss_ledger_sdk::SourceDocType; +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, DbErr, EntityTrait, Order}; +use toolkit_db::secure::{ + AccessScope, DbTx, ScopeError, SecureEntityExt, SecureInsertExt, SecureOnConflict, + SecureUpdateExt, +}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::model::RepoError; +use crate::domain::payment::credit::CreditSubgrain; +use crate::domain::payment::precedence::PrecedenceStrategy; +use crate::infra::posting::idempotency::STATUS_POSTED; +use crate::infra::storage::entity::{ + account_balance, ar_invoice_balance, idempotency_dedup, payment_allocation, + payment_allocation_refund, payment_settlement, reusable_credit_subbalance, + tenant_precedence_policy, unallocated_balance, +}; + +/// A `payment_allocation` row to insert (one per allocation split). +pub struct NewAllocationRow { + pub tenant_id: Uuid, + pub allocation_id: Uuid, + pub payer_tenant_id: Uuid, + pub payment_id: String, + pub invoice_id: String, + pub amount_minor: i64, + pub currency: String, + pub precedence_policy_ref: String, + pub allocated_at_utc: DateTime, +} + +/// One open AR invoice in the allocation candidate set (oldest-first ordered by +/// the caller's read). `balance_minor` is the still-open amount (`> 0`). +pub struct OpenArInvoice { + pub invoice_id: String, + pub balance_minor: i64, + pub original_posted_at: Option>, + pub currency: String, + /// The grain's carried functional balance (Slice 5). `Some` only when the + /// invoice was posted cross-currency (S1 stamped a functional translation); + /// `None` for a single-currency invoice (functional ≡ transaction). The + /// realized-FX poster reads it to value each AR leg's close at the grain's + /// WAC carried rate. + pub functional_balance_minor: Option, +} + +/// The unallocated pool's carried balance for a `(payer, currency)` — the read +/// the realized-FX poster needs at allocation close (Slice 5). `balance_minor` +/// is the pool's transaction balance (the WAC denominator); the functional +/// fields are `Some` only on a cross-currency pool (S2 settle stamped them) and +/// drive the realized-FX cross-currency detect + the DR UNALLOCATED leg's +/// carried functional value. +pub struct UnallocatedCarried { + pub balance_minor: i64, + pub functional_balance_minor: Option, + pub functional_currency: Option, +} + +/// A single balance-cache grain's carried `(transaction, functional)` value — the +/// read the chargeback functional carry-forward (Slice 5 F3) needs for the grain a +/// dispute phase CLOSES (`account_balance` for a `CASH_HOLD`'s `CASH_CLEARING` / +/// `DISPUTE_HOLD`; `ar_invoice_balance` for an `AR_RECLASS` invoice). `functional_*` +/// are `Some` only on a cross-currency grain (S1/S2 stamped them); `None` ⇒ a +/// single-currency close (no carry-forward). `balance_minor` is `0` (functional +/// `None`) when the grain row is absent. +pub struct CarriedBalance { + pub balance_minor: i64, + pub functional_balance_minor: Option, + pub functional_currency: Option, +} + +/// One open, cross-currency **monetary** grain to remeasure at period end (Slice 5 +/// Phase 3, the unrealized-revaluation scan). Only grains that carry a +/// `functional_currency` (cross-currency, decision 8) and an open +/// `balance_minor > 0` are listed, so `functional_balance_minor` / +/// `functional_currency` are NON-optional here (the scan filtered NULLs out). The +/// run translates `balance_minor` at the period-end rate and remeasures it against +/// `functional_balance_minor`. `invoice_id` is `Some` for an AR grain; +/// `credit_grant_event_type` is `Some` for a `REUSABLE_CREDIT` grain — both feed the +/// projector's grain key when the adjusting line is built. +pub struct RevaluationGrain { + pub payer_tenant_id: Uuid, + pub account_id: Uuid, + pub currency: String, + pub invoice_id: Option, + pub credit_grant_event_type: Option, + pub balance_minor: i64, + pub functional_balance_minor: i64, + pub functional_currency: String, +} + +/// SeaORM-backed payment counter + read repository. +#[derive(Clone)] +pub struct PaymentRepo { + db: DBProvider, +} + +impl PaymentRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + // --- In-txn counter writes (called by the payment post sidecars) --- + + /// Seed the `payment_settlement` row for a fresh settlement + /// (`settled_minor = settled_minor` gross, `fee_minor = fee_minor` the PSP + /// cut withheld so `net = settled_minor − fee_minor` is derivable; every + /// other counter starts at 0). Idempotent under the posting txn's + /// `SERIALIZABLE` + idempotency gate: a replay returns before the sidecar, + /// so this is only reached on the first post for `(tenant, payment_id)`. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn seed_settlement( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + currency: &str, + settled_minor: i64, + fee_minor: i64, + ) -> Result<(), RepoError> { + let am = payment_settlement::ActiveModel { + tenant_id: Set(tenant), + payment_id: Set(payment_id.to_owned()), + currency: Set(currency.to_owned()), + settled_minor: Set(settled_minor), + fee_minor: Set(fee_minor), + allocated_minor: Set(0), + refunded_minor: Set(0), + refunded_unallocated_minor: Set(0), + clawed_back_minor: Set(0), + version: Set(0), + }; + payment_settlement::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("payment_settlement scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("seed payment_settlement: {e}")))?; + Ok(()) + } + + /// Increment `payment_settlement.allocated_minor` by `delta` for an + /// allocation, bumping `version`. The `allocated_minor <= settled_minor` + /// cap CHECK enforces that the running total never exceeds what was + /// settled; a violation maps to [`RepoError::MoneyOutCapExceeded`] + /// (`ALLOCATION_EXCEEDS_SETTLED`). This is a scoped UPDATE, not an upsert: + /// the settlement row is always seeded first, and an `INSERT … ON CONFLICT` + /// would trip the cap CHECK on the INSERT VALUES tuple during arbitration + /// (see the body comment). Returns [`RepoError::Db`] if no row matched + /// (payment not settled). + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when the cap CHECK rejects the + /// increment; [`RepoError::Db`] on any other scope / storage failure. + pub async fn add_allocated( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + // The settlement row always pre-exists (settle precedes allocate; an + // allocate of an un-settled payment is rejected upstream), so this is a + // scoped UPDATE — NOT an upsert. An `INSERT … ON CONFLICT` cannot be + // used here: Postgres evaluates the CHECK against the INSERT VALUES + // tuple during ON CONFLICT arbitration (see projector.rs), and a seed of + // `(settled_minor = 0, allocated_minor = delta)` trips + // `allocated_minor <= settled_minor` before the DO UPDATE can net + // against the real settled amount. The UPDATE evaluates the CHECK + // against the resulting row (`allocated_minor + delta <= settled_minor`), + // and SSI + retry serialize concurrent allocates of the same payment — + // an over-cap surfaces as the CHECK violation, mapped to + // `MoneyOutCapExceeded`. + let result = payment_settlement::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + payment_settlement::Column::AllocatedMinor, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::AllocatedMinor, + )) + .add(delta), + ) + .col_expr( + payment_settlement::Column::Version, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(payment_settlement::Column::TenantId.eq(tenant)) + .add(payment_settlement::Column::PaymentId.eq(payment_id)), + ) + .exec(txn) + .await + .map_err(|e| map_cap_violation("add allocated_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "payment_settlement row absent for ({tenant}, {payment_id}) — not settled" + ))); + } + Ok(()) + } + + /// Adjust `payment_settlement.settled_minor` by `delta` — **negative** for a + /// settlement return that claws a receipt back out — bumping `version`. A + /// return that would drop `settled_minor` below the already + /// allocated / refunded / clawed-back total (or below zero) trips a + /// `chk_payment_settlement_*` cap CHECK and surfaces as + /// [`RepoError::MoneyOutCapExceeded`] (the settlement-return sidecar maps it + /// to `SETTLEMENT_RETURN_OVER_ALLOCATED`). A scoped UPDATE, not an upsert: + /// the settlement row always pre-exists (a return of an un-settled payment + /// is rejected upstream); `rows_affected == 0` ⇒ not settled. SSI + retry + /// serialize concurrent writers of the same payment. + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when a cap CHECK rejects the change; + /// [`RepoError::Db`] when no row matched or on any other scope / storage + /// failure. + pub async fn add_settled( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + // Scoped UPDATE (not an upsert): the settlement row always pre-exists, so + // the CHECK evaluates against the resulting row + // (`settled_minor + delta >= allocated / refunded / clawed-back`, and + // `>= 0`). An over-claw surfaces as the CHECK violation, mapped to + // `MoneyOutCapExceeded`; the return sidecar refines it to + // `SettlementReturnOverAllocated`. + let result = payment_settlement::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + payment_settlement::Column::SettledMinor, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::SettledMinor, + )) + .add(delta), + ) + .col_expr( + payment_settlement::Column::Version, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(payment_settlement::Column::TenantId.eq(tenant)) + .add(payment_settlement::Column::PaymentId.eq(payment_id)), + ) + .exec(txn) + .await + .map_err(|e| map_cap_violation("adjust settled_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "payment_settlement row absent for ({tenant}, {payment_id}) — not settled" + ))); + } + Ok(()) + } + + /// Adjust `payment_settlement.fee_minor` by `delta` — **negative** for a + /// settlement return that reverses the proportional fee slice (Model N, D1) + /// — bumping `version`. Mirrors [`Self::add_settled`] exactly: a scoped + /// UPDATE (not an upsert; the settlement row always pre-exists, so + /// `rows_affected == 0` ⇒ not settled), evaluating the CHECK against the + /// resulting row. The `fee_minor >= 0` (`chk_payment_settlement_nonneg`) and + /// `fee_minor <= settled_minor` (`chk_payment_settlement_fee_le_settled`) + /// CHECKs are the backstop — a violation surfaces as + /// [`RepoError::MoneyOutCapExceeded`] (the same mapping `add_settled` uses + /// for its cap CHECK; the return sidecar maps both to + /// [`crate::domain::error::DomainError::SettlementReturnOverAllocated`]). SSI + /// + retry serialize concurrent writers of the same payment. + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when a CHECK rejects the change; + /// [`RepoError::Db`] when no row matched or on any other scope / storage + /// failure. + pub async fn add_fee( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + // Scoped UPDATE (not an upsert), exactly like `add_settled`: the CHECK + // evaluates against the resulting row (`fee_minor + delta >= 0` and + // `<= settled_minor`), so an over-reverse surfaces as the CHECK + // violation, mapped to `MoneyOutCapExceeded`; the return sidecar refines + // it to `SettlementReturnOverAllocated`. + let result = payment_settlement::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + payment_settlement::Column::FeeMinor, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::FeeMinor, + )) + .add(delta), + ) + .col_expr( + payment_settlement::Column::Version, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(payment_settlement::Column::TenantId.eq(tenant)) + .add(payment_settlement::Column::PaymentId.eq(payment_id)), + ) + .exec(txn) + .await + .map_err(|e| map_cap_violation("adjust fee_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "payment_settlement row absent for ({tenant}, {payment_id}) — not settled" + ))); + } + Ok(()) + } + + /// Increment `payment_settlement.clawed_back_minor` by `delta` for a + /// chargeback `lost`/cash-out, bumping `version`. The total money-out cap + /// CHECK (`refunded_minor + clawed_back_minor <= settled_minor`, + /// `chk_payment_settlement_moneyout_le_settled`) enforces that a settlement is + /// never paid out twice (refund + clawback); a violation maps to + /// [`RepoError::MoneyOutCapExceeded`] (the chargeback sidecar refines it to + /// [`crate::domain::error::DomainError::ChargebackExceedsSettled`]). A scoped + /// UPDATE, not an upsert: the settlement row always pre-exists (a chargeback + /// references a settled payment); `rows_affected == 0` ⇒ not settled. SSI + + /// retry serialize concurrent writers of the same payment. Mirrors + /// [`Self::add_settled`]. + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when the cap CHECK rejects the + /// increment; [`RepoError::Db`] when no row matched or on any other scope / + /// storage failure. + pub async fn add_clawed_back( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + // Scoped UPDATE (not an upsert), exactly like `add_allocated` / + // `add_settled`: the CHECK evaluates against the resulting row + // (`refunded_minor + clawed_back_minor + delta <= settled_minor`), so an + // over-claw surfaces as the CHECK violation, mapped to + // `MoneyOutCapExceeded`; the chargeback sidecar refines it to + // `ChargebackExceedsSettled`. + let result = payment_settlement::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + payment_settlement::Column::ClawedBackMinor, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::ClawedBackMinor, + )) + .add(delta), + ) + .col_expr( + payment_settlement::Column::Version, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(payment_settlement::Column::TenantId.eq(tenant)) + .add(payment_settlement::Column::PaymentId.eq(payment_id)), + ) + .exec(txn) + .await + .map_err(|e| map_cap_violation("add clawed_back_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "payment_settlement row absent for ({tenant}, {payment_id}) — not settled" + ))); + } + Ok(()) + } + + /// Increment `payment_settlement.refunded_minor` by `delta` for a refund + /// stage-1 initiation, bumping `version`. This is the TOTAL money-out counter + /// (both refund patterns bump it): the + /// `chk_payment_settlement_moneyout_le_settled` CHECK + /// (`refunded_minor + clawed_back_minor <= settled_minor`) — plus the + /// `chk_payment_settlement_refunded_le_settled` CHECK (`refunded_minor <= + /// settled_minor`) — enforces that a settlement is never paid out twice + /// (refund + clawback); a violation maps to [`RepoError::MoneyOutCapExceeded`] + /// (the refund sidecar refines it to + /// [`crate::domain::error::DomainError::RefundExceedsSettled`]). A scoped + /// UPDATE, not an upsert: the settlement row always pre-exists (a refund + /// unwinds a settled receipt, resolved out-of-txn before the post); + /// `rows_affected == 0` ⇒ not settled. SSI + retry serialize concurrent + /// writers of the same payment. Mirrors [`Self::add_clawed_back`]. + /// + /// The Group-C stage-1 **reversal** (PSP `rejected`/`voided`) calls this with + /// a NEGATIVE `delta` to release the cap reserved at initiation. A decrement + /// never trips a cap CHECK (it lowers the LHS); it could only trip the nonneg + /// CHECK (`refunded_minor >= 0`), which is impossible here — the reversal + /// decrements EXACTLY the amount the matching stage-1 initiation incremented, + /// so the counter cannot underflow (the full out-of-order refund-of-refund + /// underflow handling is Group E, not here). + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when a cap / nonneg CHECK rejects the + /// change; [`RepoError::Db`] when no row matched or on any other scope / + /// storage failure. + pub async fn add_refunded( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + // Scoped UPDATE (not an upsert), exactly like `add_clawed_back`: the CHECK + // evaluates against the resulting row + // (`refunded_minor + clawed_back_minor + delta <= settled_minor` and + // `refunded_minor + delta <= settled_minor`), so an over-refund surfaces as + // the CHECK violation, mapped to `MoneyOutCapExceeded`; the refund sidecar + // refines it to `RefundExceedsSettled`. + let result = payment_settlement::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + payment_settlement::Column::RefundedMinor, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::RefundedMinor, + )) + .add(delta), + ) + .col_expr( + payment_settlement::Column::Version, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(payment_settlement::Column::TenantId.eq(tenant)) + .add(payment_settlement::Column::PaymentId.eq(payment_id)), + ) + .exec(txn) + .await + .map_err(|e| map_cap_violation("add refunded_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "payment_settlement row absent for ({tenant}, {payment_id}) — not settled" + ))); + } + Ok(()) + } + + /// Increment `payment_settlement.refunded_unallocated_minor` by `delta` for a + /// **Pattern A** (`A_UNALLOCATED`) refund stage-1 initiation, bumping + /// `version`. This is the *spendable-headroom* counter: the + /// `chk_payment_settlement_alloc_refu_le_settled` CHECK + /// (`allocated_minor + refunded_unallocated_minor <= settled_minor`) enforces + /// that refunded on-account cash can no longer ALSO be allocated to an invoice + /// (the receipt left the spendable pool). A violation maps to + /// [`RepoError::MoneyOutCapExceeded`] (the refund sidecar refines it to + /// [`crate::domain::error::DomainError::RefundExceedsSettled`]). Only Pattern A + /// touches this counter — a Pattern B refund restores AR (it never drew the + /// unallocated pool), so it bumps only `refunded_minor` + the per-`(payment, + /// invoice)` `payment_allocation_refund.refunded_minor`. A scoped UPDATE, not + /// an upsert (the row pre-exists); `rows_affected == 0` ⇒ not settled. SSI + + /// retry serialize concurrent writers. Mirrors [`Self::add_refunded`]. + /// + /// The stage-1 reversal calls this with a NEGATIVE `delta` to release the + /// headroom reserved at initiation; a decrement never trips the cap CHECK and + /// cannot underflow the nonneg CHECK (it decrements exactly what initiation + /// added — Group E owns the out-of-order case). + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when a cap / nonneg CHECK rejects the + /// change; [`RepoError::Db`] when no row matched or on any other scope / + /// storage failure. + pub async fn add_refunded_unallocated( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + // Scoped UPDATE (not an upsert), exactly like `add_allocated`: the CHECK + // evaluates against the resulting row + // (`allocated_minor + refunded_unallocated_minor + delta <= settled_minor`), + // so refunding cash that would no longer fit the spendable pool surfaces as + // the CHECK violation, mapped to `MoneyOutCapExceeded`; the refund sidecar + // refines it to `RefundExceedsSettled`. + let result = payment_settlement::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + payment_settlement::Column::RefundedUnallocatedMinor, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::RefundedUnallocatedMinor, + )) + .add(delta), + ) + .col_expr( + payment_settlement::Column::Version, + Expr::col(( + payment_settlement::Entity, + payment_settlement::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(payment_settlement::Column::TenantId.eq(tenant)) + .add(payment_settlement::Column::PaymentId.eq(payment_id)), + ) + .exec(txn) + .await + .map_err(|e| map_cap_violation("add refunded_unallocated_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "payment_settlement row absent for ({tenant}, {payment_id}) — not settled" + ))); + } + Ok(()) + } + + /// Increment `payment_allocation_refund.refunded_minor` for a `(payment, + /// invoice)` by `delta` — the **Pattern B** (`B_RESTORE_AR`) per-invoice refund + /// cap, bumping `version`. The `chk_par_refunded_le_allocated` CHECK + /// (`refunded_minor <= allocated_minor`) enforces that a `(payment, invoice)` + /// pair is never refunded for more than was allocated to it; a violation maps + /// to [`RepoError::MoneyOutCapExceeded`] (the refund sidecar refines it to + /// [`crate::domain::error::DomainError::RefundExceedsAllocated`]). + /// + /// A scoped UPDATE, not an upsert (contrast [`Self::bump_allocation_refund`], + /// which seeds `allocated_minor` at allocation time): the `payment_allocation_refund` + /// row is seeded by the allocation that applied this payment to the invoice, so + /// it always pre-exists when a Pattern-B refund of that same `(payment, + /// invoice)` runs. An `INSERT … ON CONFLICT` would trip `refunded_minor <= + /// allocated_minor` on the INSERT VALUES tuple (`allocated_minor = 0`) during + /// arbitration before the DO UPDATE can net against the real allocated amount — + /// the `add_allocated` rationale. `rows_affected == 0` ⇒ the `(payment, + /// invoice)` was never allocated (a Pattern-B refund of an unallocated + /// receipt — an upstream contract violation); surfaced as [`RepoError::Db`]. + /// SSI + retry serialize concurrent writers. + /// + /// The stage-1 reversal calls this with a NEGATIVE `delta` to release the + /// per-invoice reservation; a decrement never trips the cap CHECK and cannot + /// underflow the nonneg CHECK (it decrements exactly the initiation amount). + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when the per-invoice cap CHECK rejects + /// the increment; [`RepoError::Db`] when no row matched or on any other scope / + /// storage failure. + pub async fn add_allocation_refund_refunded( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + invoice_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + // Scoped UPDATE (not an upsert): the row is seeded with the real + // `allocated_minor` by `bump_allocation_refund` at allocation time, so the + // CHECK evaluates against the resulting row + // (`refunded_minor + delta <= allocated_minor`). An over-refund of the + // pair surfaces as the CHECK violation, mapped to `MoneyOutCapExceeded`; + // the refund sidecar refines it to `RefundExceedsAllocated`. + let result = payment_allocation_refund::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + payment_allocation_refund::Column::RefundedMinor, + Expr::col(( + payment_allocation_refund::Entity, + payment_allocation_refund::Column::RefundedMinor, + )) + .add(delta), + ) + .col_expr( + payment_allocation_refund::Column::Version, + Expr::col(( + payment_allocation_refund::Entity, + payment_allocation_refund::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(payment_allocation_refund::Column::TenantId.eq(tenant)) + .add(payment_allocation_refund::Column::PaymentId.eq(payment_id)) + .add(payment_allocation_refund::Column::InvoiceId.eq(invoice_id)), + ) + .exec(txn) + .await + .map_err(|e| map_cap_violation("add allocation_refund refunded_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "payment_allocation_refund row absent for ({tenant}, {payment_id}, {invoice_id}) \ + — payment was never allocated to this invoice" + ))); + } + Ok(()) + } + + /// Insert the N `payment_allocation` rows for one allocation. The PK + /// `(tenant, allocation_id, invoice_id)` makes a replay of the same + /// `allocation_id` collide — but a replay returns before the sidecar, so + /// this is only reached on the first post; an unexpected duplicate + /// surfaces as [`RepoError::Db`]. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn insert_allocation_rows( + txn: &DbTx<'_>, + scope: &AccessScope, + rows: &[NewAllocationRow], + ) -> Result<(), RepoError> { + for row in rows { + let am = payment_allocation::ActiveModel { + tenant_id: Set(row.tenant_id), + allocation_id: Set(row.allocation_id), + invoice_id: Set(row.invoice_id.clone()), + payer_tenant_id: Set(row.payer_tenant_id), + payment_id: Set(row.payment_id.clone()), + amount_minor: Set(row.amount_minor), + currency: Set(row.currency.clone()), + precedence_policy_ref: Set(row.precedence_policy_ref.clone()), + allocated_at_utc: Set(row.allocated_at_utc), + }; + payment_allocation::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("payment_allocation scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert payment_allocation: {e}")))?; + } + Ok(()) + } + + /// Increment `payment_allocation_refund.allocated_minor` for a + /// `(payment, invoice)` by `delta` (the per-invoice amount this allocation + /// applied), bumping `version`. Feeds Slice 3's refund cap (the + /// `refunded_minor <= allocated_minor` CHECK); a CHECK violation maps to + /// [`RepoError::MoneyOutCapExceeded`]. + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when the cap CHECK rejects the + /// increment; [`RepoError::Db`] on any other scope / storage failure. + pub async fn bump_allocation_refund( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + invoice_id: &str, + delta: i64, + ) -> Result<(), RepoError> { + let am = payment_allocation_refund::ActiveModel { + tenant_id: Set(tenant), + payment_id: Set(payment_id.to_owned()), + invoice_id: Set(invoice_id.to_owned()), + allocated_minor: Set(delta), + refunded_minor: Set(0), + version: Set(0), + }; + let on_conflict = SecureOnConflict::::columns([ + payment_allocation_refund::Column::TenantId, + payment_allocation_refund::Column::PaymentId, + payment_allocation_refund::Column::InvoiceId, + ]) + .value( + payment_allocation_refund::Column::AllocatedMinor, + Expr::col(( + payment_allocation_refund::Entity, + payment_allocation_refund::Column::AllocatedMinor, + )) + .add(delta), + ) + .and_then(|oc| { + oc.value( + payment_allocation_refund::Column::Version, + Expr::col(( + payment_allocation_refund::Entity, + payment_allocation_refund::Column::Version, + )) + .add(1), + ) + }) + .map_err(|e| RepoError::Db(format!("payment_allocation_refund on_conflict: {e}")))?; + + payment_allocation_refund::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("payment_allocation_refund scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| map_cap_violation("bump allocation_refund", &e))?; + Ok(()) + } + + // --- Out-of-txn reads (PDP In-scoped; SQL-level BOLA) --- + + /// List the open AR invoices for `(payer, currency)` — the allocation + /// candidate set — ordered oldest-first (`original_posted_at` then + /// `invoice_id`). Filters `balance_minor > 0`. SQL-level BOLA: a foreign + /// tenant yields no rows. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_open_ar_invoices( + &self, + scope: &AccessScope, + tenant: Uuid, + payer: Uuid, + currency: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = ar_invoice_balance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(ar_invoice_balance::Column::TenantId.eq(tenant)) + .add(ar_invoice_balance::Column::PayerTenantId.eq(payer)) + .add(ar_invoice_balance::Column::Currency.eq(currency)) + .add(ar_invoice_balance::Column::BalanceMinor.gt(0)), + ) + .order_by(ar_invoice_balance::Column::OriginalPostedAt, Order::Asc) + .order_by(ar_invoice_balance::Column::InvoiceId, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list open ar invoices: {e}")))?; + Ok(rows + .into_iter() + .map(|m| OpenArInvoice { + invoice_id: m.invoice_id, + balance_minor: m.balance_minor, + original_posted_at: m.original_posted_at, + currency: m.currency, + functional_balance_minor: m.functional_balance_minor, + }) + .collect()) + } + + /// List the payer's spendable reusable-credit sub-grains for `(payer, currency)` + /// — the wallet draw-down candidate set — ordered oldest-grant-first + /// (`first_granted_at` then `credit_grant_event_type`). Filters `balance_minor > 0`. + /// SQL-level BOLA: a foreign tenant yields no rows. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_credit_subgrains( + &self, + scope: &AccessScope, + tenant: Uuid, + payer: Uuid, + currency: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = reusable_credit_subbalance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(reusable_credit_subbalance::Column::TenantId.eq(tenant)) + .add(reusable_credit_subbalance::Column::PayerTenantId.eq(payer)) + .add(reusable_credit_subbalance::Column::Currency.eq(currency)) + .add(reusable_credit_subbalance::Column::BalanceMinor.gt(0)), + ) + .order_by( + reusable_credit_subbalance::Column::FirstGrantedAt, + Order::Asc, + ) + .order_by( + reusable_credit_subbalance::Column::CreditGrantEventType, + Order::Asc, + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list credit subgrains: {e}")))?; + Ok(rows + .into_iter() + .map(|m| CreditSubgrain { + credit_grant_event_type: m.credit_grant_event_type, + available_minor: m.balance_minor, + }) + .collect()) + } + + /// List the `payment_allocation` rows for `(tenant, payment_id)` (the + /// `GET …/allocations` view), ordered by `invoice_id`. SQL-level BOLA. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_payment_allocations( + &self, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = payment_allocation::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(payment_allocation::Column::TenantId.eq(tenant)) + .add(payment_allocation::Column::PaymentId.eq(payment_id)), + ) + .order_by(payment_allocation::Column::InvoiceId, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list payment_allocation: {e}")))?; + Ok(rows) + } + + /// Read the payer's unallocated pool balance for `(payer, currency)`. The + /// grain is single-row per `(tenant, payer, account, currency)`; 2a posts + /// one UNALLOCATED account per currency, so this sums the matching rows + /// (zero or one in practice) and returns the total, or 0 when empty. + /// SQL-level BOLA. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_unallocated( + &self, + scope: &AccessScope, + tenant: Uuid, + payer: Uuid, + currency: &str, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = unallocated_balance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(unallocated_balance::Column::TenantId.eq(tenant)) + .add(unallocated_balance::Column::PayerTenantId.eq(payer)) + .add(unallocated_balance::Column::Currency.eq(currency)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("read unallocated: {e}")))?; + Ok(rows.iter().map(|r| r.balance_minor).sum()) + } + + /// Read the payer's unallocated pool with its carried functional balance for + /// `(payer, currency)` — the realized-FX poster's read at allocation close + /// (Slice 5, design §3.5). Like [`Self::read_unallocated`] this sums the + /// matching grain rows (zero or one in practice — 2a posts one UNALLOCATED + /// account per currency); the functional column is `Some` only when the pool + /// is cross-currency (S2 settle stamped it), and `functional_currency` is the + /// first non-NULL grain currency (one functional currency per seller). A + /// `None` functional balance ⇒ a single-currency close: no realized FX. + /// SQL-level BOLA: a foreign tenant yields no rows. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_unallocated_carried( + &self, + scope: &AccessScope, + tenant: Uuid, + payer: Uuid, + currency: &str, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = unallocated_balance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(unallocated_balance::Column::TenantId.eq(tenant)) + .add(unallocated_balance::Column::PayerTenantId.eq(payer)) + .add(unallocated_balance::Column::Currency.eq(currency)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("read unallocated carried: {e}")))?; + let balance_minor = rows.iter().map(|r| r.balance_minor).sum(); + // Functional is NULL on a single-currency pool: keep it NULL unless at + // least one grain row carries it (then sum the populated values — one row + // in practice). Mirrors the projector's no-COALESCE NULL discipline. + let functional_balance_minor = if rows.iter().any(|r| r.functional_balance_minor.is_some()) + { + Some(rows.iter().filter_map(|r| r.functional_balance_minor).sum()) + } else { + None + }; + let functional_currency = rows.iter().find_map(|r| r.functional_currency.clone()); + Ok(UnallocatedCarried { + balance_minor, + functional_balance_minor, + functional_currency, + }) + } + + /// Read a general `account_balance` grain's carried `(transaction, functional)` + /// value for `(tenant, account_id, currency)` — the chargeback functional + /// carry-forward's read for a `CASH_HOLD` dispute (the `CASH_CLEARING` grain it + /// closes at `opened`, the `DISPUTE_HOLD` grain it closes at `won`/`lost`). + /// Returns a zero / functional-`None` [`CarriedBalance`] when no row exists. + /// SQL-level BOLA: a foreign tenant yields no row. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_account_carried( + &self, + scope: &AccessScope, + tenant: Uuid, + account_id: Uuid, + currency: &str, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = account_balance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(account_balance::Column::TenantId.eq(tenant)) + .add(account_balance::Column::AccountId.eq(account_id)) + .add(account_balance::Column::Currency.eq(currency)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read account balance carried: {e}")))?; + Ok(row.map_or( + CarriedBalance { + balance_minor: 0, + functional_balance_minor: None, + functional_currency: None, + }, + |m| CarriedBalance { + balance_minor: m.balance_minor, + functional_balance_minor: m.functional_balance_minor, + functional_currency: m.functional_currency, + }, + )) + } + + /// Read one AR invoice grain's carried `(transaction, functional)` value for + /// `(tenant, payer, invoice_id, currency)` — the chargeback functional + /// carry-forward's read for an `AR_RECLASS` dispute (the disputed receivable it + /// reclasses / writes off). Targeted single-invoice read (unlike + /// [`Self::list_open_ar_invoices`], which lists all open candidates and filters + /// `balance_minor > 0`). Returns a zero / functional-`None` [`CarriedBalance`] + /// when no row exists. SQL-level BOLA. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_ar_invoice_carried( + &self, + scope: &AccessScope, + tenant: Uuid, + payer: Uuid, + invoice_id: &str, + currency: &str, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = ar_invoice_balance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(ar_invoice_balance::Column::TenantId.eq(tenant)) + .add(ar_invoice_balance::Column::PayerTenantId.eq(payer)) + .add(ar_invoice_balance::Column::InvoiceId.eq(invoice_id)) + .add(ar_invoice_balance::Column::Currency.eq(currency)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read ar invoice carried: {e}")))?; + Ok(row.map_or( + CarriedBalance { + balance_minor: 0, + functional_balance_minor: None, + functional_currency: None, + }, + |m| CarriedBalance { + balance_minor: m.balance_minor, + functional_balance_minor: m.functional_balance_minor, + functional_currency: m.functional_currency, + }, + )) + } + + /// List the open, cross-currency AR-invoice grains for `tenant` to remeasure + /// at period end (Slice 5 Phase 3): `ar_invoice_balance` rows with + /// `balance_minor > 0` and a non-NULL `functional_currency` (cross-currency, + /// decision 8). A row whose `functional_currency` is set but + /// `functional_balance_minor` is NULL (an impossible projector state) is + /// skipped. SQL-level BOLA: a foreign tenant yields no rows. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_ar_invoices_to_revalue( + &self, + scope: &AccessScope, + tenant: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = ar_invoice_balance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(ar_invoice_balance::Column::TenantId.eq(tenant)) + .add(ar_invoice_balance::Column::FunctionalCurrency.is_not_null()) + .add(ar_invoice_balance::Column::BalanceMinor.gt(0)), + ) + .order_by(ar_invoice_balance::Column::InvoiceId, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list ar invoices to revalue: {e}")))?; + Ok(rows + .into_iter() + .filter_map(|m| { + Some(RevaluationGrain { + payer_tenant_id: m.payer_tenant_id, + account_id: m.account_id, + currency: m.currency, + invoice_id: Some(m.invoice_id), + credit_grant_event_type: None, + balance_minor: m.balance_minor, + functional_balance_minor: m.functional_balance_minor?, + functional_currency: m.functional_currency?, + }) + }) + .collect()) + } + + /// List the open, cross-currency unallocated-pool grains for `tenant` to + /// remeasure at period end (Slice 5 Phase 3): `unallocated_balance` rows with + /// `balance_minor > 0` and a non-NULL `functional_currency`. SQL-level BOLA. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_unallocated_to_revalue( + &self, + scope: &AccessScope, + tenant: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = unallocated_balance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(unallocated_balance::Column::TenantId.eq(tenant)) + .add(unallocated_balance::Column::FunctionalCurrency.is_not_null()) + .add(unallocated_balance::Column::BalanceMinor.gt(0)), + ) + .order_by(unallocated_balance::Column::PayerTenantId, Order::Asc) + .order_by(unallocated_balance::Column::Currency, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list unallocated to revalue: {e}")))?; + Ok(rows + .into_iter() + .filter_map(|m| { + Some(RevaluationGrain { + payer_tenant_id: m.payer_tenant_id, + account_id: m.account_id, + currency: m.currency, + invoice_id: None, + credit_grant_event_type: None, + balance_minor: m.balance_minor, + functional_balance_minor: m.functional_balance_minor?, + functional_currency: m.functional_currency?, + }) + }) + .collect()) + } + + /// List the open, cross-currency reusable-credit (wallet) grains for `tenant` + /// to remeasure at period end (Slice 5 Phase 3): `reusable_credit_subbalance` + /// rows with `balance_minor > 0` and a non-NULL `functional_currency`. Each + /// `credit_grant_event_type` sub-bucket is its own grain. SQL-level BOLA. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_reusable_credit_to_revalue( + &self, + scope: &AccessScope, + tenant: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = reusable_credit_subbalance::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(reusable_credit_subbalance::Column::TenantId.eq(tenant)) + .add(reusable_credit_subbalance::Column::FunctionalCurrency.is_not_null()) + .add(reusable_credit_subbalance::Column::BalanceMinor.gt(0)), + ) + .order_by( + reusable_credit_subbalance::Column::PayerTenantId, + Order::Asc, + ) + .order_by(reusable_credit_subbalance::Column::Currency, Order::Asc) + .order_by( + reusable_credit_subbalance::Column::CreditGrantEventType, + Order::Asc, + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list reusable credit to revalue: {e}")))?; + Ok(rows + .into_iter() + .filter_map(|m| { + Some(RevaluationGrain { + payer_tenant_id: m.payer_tenant_id, + account_id: m.account_id, + currency: m.currency, + invoice_id: None, + credit_grant_event_type: Some(m.credit_grant_event_type), + balance_minor: m.balance_minor, + functional_balance_minor: m.functional_balance_minor?, + functional_currency: m.functional_currency?, + }) + }) + .collect()) + } + + /// Read the `payment_settlement` row for `(tenant, payment_id)` (counters + + /// currency), or `None` if the payment was never settled. SQL-level BOLA. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_settlement( + &self, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = payment_settlement::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(payment_settlement::Column::TenantId.eq(tenant)) + .add(payment_settlement::Column::PaymentId.eq(payment_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read payment_settlement: {e}")))?; + Ok(row) + } + + /// Read the `payment_settlement` row for `(tenant, payment_id)` UNDER the + /// rank-1 row lock, INSIDE the passed-in posting transaction — the claw-back + /// underflow pre-check (Group E, design §4.4). A refund-of-refund claw-back + /// DECREMENTS `refunded_minor`; if the decrement would drive the counter below + /// zero (a PSP claw-back that arrived BEFORE / without the matching outbound + /// refund stage-1, or claws back MORE than was refunded) the design DEFERS it — + /// it must NOT be applied and must NOT hard-abort on the `refunded_minor >= 0` + /// CHECK. So the handler reads the current counters HERE under the same rank-1 + /// `payment_settlement` lock the decrement takes, decides `current - amount < 0`, + /// and either decrements (in-order / sufficient) or defers (would underflow) — + /// the CHECK stays a defense-in-depth backstop that must never fire. + /// + /// Locked with `FOR UPDATE` on Postgres (serializes against a concurrent + /// outbound stage-1 that is raising `refunded_minor`, so the read-then-decrement + /// is atomic); `SQLite` has no `FOR UPDATE` and omits the clause (the unit/test + /// path uses `SERIALIZABLE` semantics already). Takes `&self` to reach the + /// provider for the backend probe (mirrors [`PendingQueueRepo::claim_due`]). + /// Returns `None` when the payment was never settled — an upstream contract + /// violation for a claw-back (nothing to claw back), surfaced by the caller. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_settlement_for_update( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + ) -> Result, RepoError> { + use sea_orm::QuerySelect as _; + use sea_orm::sea_query::LockType; + // Apply the row lock on the raw `find()` before SecureORM wraps it (the lock + // rides the underlying SelectStatement, surviving `.secure().scope_with`) — + // the same shape `PendingQueueRepo::claim_due` uses. Plain `FOR UPDATE` (NOT + // SKIP LOCKED): the pre-check must BLOCK on a concurrent writer of this row, + // not skip it, so it reads the post-write counter and serializes the + // read-then-decrement against a concurrent outbound stage-1. + let mut find = payment_settlement::Entity::find(); + if self.db.db().backend() == sea_orm::DatabaseBackend::Postgres { + find = find.lock(LockType::Update); + } + let row = find + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(payment_settlement::Column::TenantId.eq(tenant)) + .add(payment_settlement::Column::PaymentId.eq(payment_id)), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("read payment_settlement for update: {e}")))?; + Ok(row) + } + + /// Read the `payment_allocation_refund.refunded_minor` for a `(payment, invoice)` + /// UNDER the row lock, INSIDE the posting transaction — the Pattern-B leg of the + /// claw-back underflow pre-check (Group E). A Pattern-B claw-back DECREMENTS this + /// per-invoice counter; the handler reads it here under `FOR UPDATE` to decide + /// whether `current - amount < 0` (and defer instead of underflowing the + /// `chk_par_refunded_le_allocated` / nonneg CHECK). Returns `0` when no + /// `payment_allocation_refund` row exists — which a Pattern-B claw-back of an + /// unallocated `(payment, invoice)` would be, so `0 < amount` defers it (the same + /// out-of-order treatment). Locked with `FOR UPDATE` on Postgres (blocks on a + /// concurrent outbound Pattern-B refund raising the counter); omitted on + /// `SQLite`. Takes `&self` for the backend probe (mirrors + /// [`Self::read_settlement_for_update`]). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_allocation_refund_refunded_for_update( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + invoice_id: &str, + ) -> Result { + use sea_orm::QuerySelect as _; + use sea_orm::sea_query::LockType; + let mut find = payment_allocation_refund::Entity::find(); + if self.db.db().backend() == sea_orm::DatabaseBackend::Postgres { + find = find.lock(LockType::Update); + } + let row = find + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(payment_allocation_refund::Column::TenantId.eq(tenant)) + .add(payment_allocation_refund::Column::PaymentId.eq(payment_id)) + .add(payment_allocation_refund::Column::InvoiceId.eq(invoice_id)), + ) + .one(txn) + .await + .map_err(|e| { + RepoError::Db(format!("read payment_allocation_refund for update: {e}")) + })?; + Ok(row.map_or(0, |r| r.refunded_minor)) + } + + /// Look up a FINALIZED idempotent post by its `(tenant, source_doc_type, + /// business_id)` key, returning the prior entry id + stored request + /// `payload_hash` when the key already posted (dedup status `POSTED`), else + /// `None`. An orchestrator whose + /// pre-post validation depends on mutable ledger state (the credit-application + /// caps re-read open AR / the wallet) calls this FIRST, so a + /// retry-after-success returns the prior posting as a replay instead of + /// re-validating against the now-drained state and spuriously rejecting. The + /// authoritative dedup is still the engine's in-txn claim inside `post` (this + /// out-of-txn read is racy by nature — a `None` only means "proceed and let + /// `post` claim", which still guards a concurrent first post). SQL-level BOLA + /// via the scope. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn lookup_finalized_post( + &self, + scope: &AccessScope, + tenant: Uuid, + source_doc_type: SourceDocType, + business_id: &str, + ) -> Result, RepoError> { + // Only a finalized (POSTED) row carries an authoritative prior entry id; a + // still-CLAIMED row is an in-flight concurrent post (and a QUEUED row is a + // pending deferred apply) — fall through and let the engine's in-txn claim + // serialize against it. The prior entry id is paired with the stored + // request `payload_hash` so the caller's replay short-circuit can reject a + // same-key / different-payload reuse instead of replaying it. + Ok(self + .lookup_dedup_status(scope, tenant, source_doc_type, business_id) + .await? + .and_then(|(status, entry_id, payload_hash)| { + if status == STATUS_POSTED { + entry_id.map(|id| (id, payload_hash)) + } else { + None + } + })) + } + + /// Read the dedup row for `(tenant, source_doc_type, business_id)`, + /// returning its `(status, result_entry_id, payload_hash)` (or `None` when no + /// row exists). The generalized form behind [`Self::lookup_finalized_post`]: + /// callers that need to distinguish `CLAIMED` (in-flight inline) vs + /// `QUEUED` (pending deferred apply) vs `POSTED` (finalized) on a known key + /// read the raw status here, rather than collapsing everything-but-POSTED + /// to "proceed". Like `lookup_finalized_post`, this out-of-txn read is racy + /// by nature — the authoritative dedup is the engine's in-txn claim. SQL-level + /// BOLA via the scope. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn lookup_dedup_status( + &self, + scope: &AccessScope, + tenant: Uuid, + source_doc_type: SourceDocType, + business_id: &str, + ) -> Result, String)>, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = idempotency_dedup::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(idempotency_dedup::Column::TenantId.eq(tenant)) + .add(idempotency_dedup::Column::Flow.eq(source_doc_type.as_str())) + .add(idempotency_dedup::Column::BusinessId.eq(business_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("lookup dedup status: {e}")))?; + Ok(row.map(|r| (r.status, r.result_entry_id, r.payload_hash))) + } + + /// Read the precedence policy in effect for `tenant` at instant `at` — the + /// row with the latest `effective_from <= at` (ties broken by the highest + /// `version`), mapped to its [`PrecedenceStrategy`] and `version`. Returns + /// `None` when the tenant has no policy effective at `at` (the caller falls + /// back to oldest-first). SQL-level BOLA: a foreign tenant yields no rows. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure, or when a stored + /// `strategy` is not a known policy id (an invariant breach — the column is + /// only ever written from [`PrecedenceStrategy::policy_ref`]). + pub async fn read_effective_policy( + &self, + scope: &AccessScope, + tenant: Uuid, + at: DateTime, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = tenant_precedence_policy::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(tenant_precedence_policy::Column::TenantId.eq(tenant)) + .add(tenant_precedence_policy::Column::EffectiveFrom.lte(at)), + ) + .order_by(tenant_precedence_policy::Column::EffectiveFrom, Order::Desc) + .order_by(tenant_precedence_policy::Column::Version, Order::Desc) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read precedence policy: {e}")))?; + let Some(row) = row else { + return Ok(None); + }; + let strategy = PrecedenceStrategy::parse(&row.strategy).ok_or_else(|| { + RepoError::Db(format!( + "unknown stored precedence strategy {:?} for tenant {tenant} version {}", + row.strategy, row.version + )) + })?; + Ok(Some((strategy, row.version))) + } +} + +/// Map a counter-write [`ScopeError`] to [`RepoError`]: a CHECK-constraint +/// violation (the per-payment cap) becomes [`RepoError::MoneyOutCapExceeded`]; +/// anything else stays a plain [`RepoError::Db`]. Only `ScopeError::Db` can +/// carry a driver CHECK error; the scope-validation variants never do. +fn map_cap_violation(context: &str, err: &ScopeError) -> RepoError { + if let ScopeError::Db(db_err) = err + && is_check_violation(db_err) + { + return RepoError::MoneyOutCapExceeded(format!("{context}: {err}")); + } + RepoError::Db(format!("{context}: {err}")) +} + +/// Returns `true` iff `err` is a `CHECK`-constraint violation on either +/// supported backend. `sea_orm::SqlErr` has no `Check` discriminant, so a real +/// CHECK violation always surfaces unstructured (`sql_err() == None`); a +/// structured error (unique / FK) is therefore never a CHECK and is refused +/// here. The keyword / SQLSTATE-anchored fallbacks mirror the RBAC gear's +/// `is_check_violation` (Postgres `23514`, `SQLite` extended code `275`). +fn is_check_violation(err: &DbErr) -> bool { + if err.sql_err().is_some() { + return false; + } + let msg = err.to_string().to_lowercase(); + // The constraint NAME is the most stable signal — it survives the driver / + // locale changes that can reshape the SQLSTATE text the keyword fallbacks + // below anchor on. The cap CHECKs (`chk_payment_settlement_*` on the + // settlement counters, `chk_par_*` on the allocation-refund counters) are + // the only constraints whose violation must map to `MoneyOutCapExceeded`, + // so match them by name first and keep the SQLSTATE anchors as a fallback. + if msg.contains("chk_payment_settlement_") || msg.contains("chk_par_") { + return true; + } + msg.contains("check constraint") + || msg.contains("check_violation") + || msg.contains("sqlite_constraint_check") + || msg.contains("sqlstate 23514") + || msg.contains("sqlstate: 23514") + || msg.contains("sqlstate=23514") + || msg.contains("code 23514") + || msg.contains("code: 23514") + || msg.contains("(23514)") + || msg.contains("(23514:") + || msg.starts_with("23514:") + || msg.contains(" 23514:") + || (msg.contains("sqlite") + && (msg.contains("code 275") + || msg.contains("code: 275") + || msg.contains("(275)") + || msg.contains("(275:"))) +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/pending_queue_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/pending_queue_repo.rs new file mode 100644 index 000000000..b8daa3661 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/pending_queue_repo.rs @@ -0,0 +1,451 @@ +//! `PendingQueueRepo` — the durable work-state Source-of-Truth (`SoT`) for the +//! deferred-apply queue (`bss.ledger_pending_event_queue`), keyed by +//! `(tenant_id, flow, business_id)`. +//! +//! ## Two-transaction `SoT` split +//! A deferred request rides on **two** durable rows written in the same intake +//! transaction, with distinct authoritative roles: +//! - **this** queue row holds the *work-state* `SoT` — the financial-key snapshot +//! (`payload`) plus the lifecycle `status` (`QUEUED` → `APPLIED` | `CANCELLED`) +//! and the retry `attempts` the applier drives. The applier reads, claims, and +//! flips these rows; nothing else owns "is this work still to do". +//! - the **idempotency-dedup** row (written by [`IdempotencyGate::claim_queued`]) +//! holds the *replay reference* `SoT` — it answers "has this `(tenant, flow, +//! business_id)` already been accepted?" for a duplicate intake, and later +//! carries the posted `result_entry_id` once the apply finalizes. It is the +//! at-most-once gate, not the work list. +//! +//! Keeping the two apart means a replayed intake is rejected by the dedup gate +//! without ever touching the queue, and the applier drains the queue without +//! re-deriving dedup state. (`IdempotencyGate`: [`crate::infra::posting::idempotency`].) +//! +//! ## In-txn vs out-of-txn +//! Writes (`insert_queued` at intake; `claim_due` / `mark_*` / +//! `bump_attempts_and_defer` in the applier) run inside a passed-in secure +//! transaction (`txn: &DbTx<'_>`) +//! — they mirror [`PaymentRepo`](super::PaymentRepo)'s in-txn counter writes +//! (scoped insert via `.secure().scope_with_model`; scoped `update_many` via +//! `.secure().scope_with`). The single read (`count_by_status`, a metric / sweep +//! depth) runs out-of-txn via `self.db.conn()`. Every query is scoped +//! (`.secure().scope_with…`) for SQL-level BOLA — a foreign tenant sees no rows. + +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ + ActiveValue::Set, + ColumnTrait, Condition, DbBackend, EntityTrait, Order, QuerySelect, + sea_query::{LockBehavior, LockType}, +}; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt, SecureInsertExt, SecureUpdateExt}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::model::RepoError; +use crate::infra::posting::idempotency::STATUS_QUEUED; +use crate::infra::storage::entity::pending_event_queue; + +/// Status literal for a queue row whose deferred effect has been durably +/// applied (drained) in a later transaction — terminal, never re-claimed. +const STATUS_APPLIED: &str = "APPLIED"; +/// Status literal for a queue row whose deferred effect was abandoned before +/// apply (e.g. quarantine give-up) — terminal, never re-claimed. +const STATUS_CANCELLED: &str = "CANCELLED"; + +/// One queue row to enqueue at intake. The lifecycle starts at `QUEUED` with +/// `attempts = 0`; `apply_after` is the earliest instant the applier may claim +/// the row (`None` = immediately eligible). +pub struct NewQueueRow { + pub tenant_id: Uuid, + pub flow: String, + pub business_id: String, + pub payload: serde_json::Value, + pub queued_at: DateTime, + pub apply_after: Option>, +} + +/// SeaORM-backed deferred-apply queue repository (work-state `SoT`). See the +/// module docs for the two-transaction `SoT` split against the dedup row. +#[derive(Clone)] +pub struct PendingQueueRepo { + db: DBProvider, +} + +impl PendingQueueRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + // --- In-txn writes (intake enqueue + applier claim / lifecycle flips) --- + + /// Enqueue one queue row at intake: a scoped insert seeding `status = QUEUED` + /// and `attempts = 0` (mirrors [`PaymentRepo::insert_allocation_rows`]). The + /// PK `(tenant, flow, business_id)` makes a duplicate enqueue of the same + /// business key collide — but the dedup gate's `claim_queued` runs first in + /// the same intake txn and short-circuits a replay before reaching here, so + /// an unexpected duplicate surfaces as [`RepoError::Db`]. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn insert_queued( + txn: &DbTx<'_>, + scope: &AccessScope, + row: &NewQueueRow, + ) -> Result<(), RepoError> { + let am = pending_event_queue::ActiveModel { + tenant_id: Set(row.tenant_id), + flow: Set(row.flow.clone()), + business_id: Set(row.business_id.clone()), + payload: Set(row.payload.clone()), + queued_at: Set(row.queued_at), + apply_after: Set(row.apply_after), + status: Set(STATUS_QUEUED.to_owned()), + attempts: Set(0), + }; + pending_event_queue::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("pending_event_queue scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert pending_event_queue: {e}")))?; + Ok(()) + } + + /// Claim up to `limit` *due* `QUEUED` rows for `(tenant, flow)`, oldest + /// `queued_at` first. "Due" means `apply_after IS NULL OR apply_after <= + /// now`. The claimed rows are returned **still `QUEUED`** — claiming only + /// reserves them under the row lock for this applier pass; the apply path + /// flips each to `APPLIED` / `CANCELLED` (and re-gates it against the dedup, + /// so this read is deliberately not payment-specific). + /// + /// ## SKIP LOCKED (Postgres only) + /// On Postgres the select takes `FOR UPDATE SKIP LOCKED` so a concurrent + /// applier skips the rows another pass is currently holding rather than + /// blocking on them. This only REDUCES overlap — it does NOT hand each + /// applier a disjoint batch: the row lock lives only for this short claim + /// txn, and claiming does NOT flip the status (the `→APPLIED` flip rides the + /// later apply txn), so the rows stay `QUEUED` and a second applier whose + /// claim runs after this one commits can re-select the very same rows. + /// Exactly-once is therefore enforced downstream by the apply's `SERIALIZABLE` + /// post txn (dedup-row finalize + the `payment_allocation` PK), NOT by this + /// lock. `SQLite` has no `FOR UPDATE`; the lock clause is omitted there (the + /// queue applier is a Postgres-runtime feature — there are no concurrent + /// appliers under `SQLite`, which the unit/test path uses). The backend is + /// read from the provider (`self.db`), not the opaque `txn` handle, which + /// exposes no backend accessor. + /// + /// Takes `&self` (unlike the other in-txn writes) precisely to reach the + /// provider for that backend probe — mirroring [`JournalRepo`]'s in-txn + /// `&self, txn` methods. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn claim_due( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + flow: &str, + now: DateTime, + limit: u64, + ) -> Result, RepoError> { + // Apply the row lock on the raw `find()` Select *before* wrapping it in + // SecureORM: `SecureSelect` exposes `.filter/.order_by/.limit` but no + // lock passthrough, and the lock clause is carried on the underlying + // SelectStatement, so it survives `.secure().scope_with(...)`. + let mut find = pending_event_queue::Entity::find(); + if self.db.db().backend() == DbBackend::Postgres { + find = find.lock_with_behavior(LockType::Update, LockBehavior::SkipLocked); + } + let due = Condition::any() + .add(pending_event_queue::Column::ApplyAfter.is_null()) + .add(pending_event_queue::Column::ApplyAfter.lte(now)); + let rows = find + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(pending_event_queue::Column::TenantId.eq(tenant)) + .add(pending_event_queue::Column::Flow.eq(flow)) + .add(pending_event_queue::Column::Status.eq(STATUS_QUEUED)) + .add(due), + ) + .order_by(pending_event_queue::Column::QueuedAt, Order::Asc) + .limit(limit) + .all(txn) + .await + .map_err(|e| RepoError::Db(format!("claim due pending_event_queue: {e}")))?; + Ok(rows) + } + + /// Flip one queue row to `APPLIED` (terminal) — the drain succeeded. + /// Scoped `update_many` keyed on the full PK (mirrors the + /// [`IdempotencyGate::finalize`] / [`PaymentRepo::add_allocated`] update + /// shape). A zero-row update means the row vanished or was already + /// terminal; surfaced as [`RepoError::Db`]. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure or if no row matched. + pub async fn mark_applied( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + flow: &str, + business_id: &str, + ) -> Result<(), RepoError> { + set_status(txn, scope, tenant, flow, business_id, STATUS_APPLIED).await + } + + /// Flip one queue row to `CANCELLED` (terminal) — the work was abandoned + /// before apply (e.g. quarantine give-up). See [`Self::mark_applied`] for + /// the update shape. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure or if no row matched. + pub async fn mark_cancelled( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + flow: &str, + business_id: &str, + ) -> Result<(), RepoError> { + set_status(txn, scope, tenant, flow, business_id, STATUS_CANCELLED).await + } + + /// Increment one queue row's `attempts` (`attempts = attempts + 1`) AND defer + /// its next eligibility to `apply_after` (the backoff instant) — recording a + /// failed/retried apply pass that must not be re-claimed until then. Setting + /// `apply_after` is what keeps `claim_due` / `list_all_due` (both gate on + /// `apply_after IS NULL OR apply_after <= now`) from re-selecting a durably + /// `Blocked` row on every pass. Scoped `update_many` with a `col + 1` + /// expression (mirrors [`PaymentRepo::add_allocated`]'s version bump). A + /// zero-row update surfaces as [`RepoError::Db`]. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure or if no row matched. + pub async fn bump_attempts_and_defer( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + flow: &str, + business_id: &str, + apply_after: DateTime, + ) -> Result<(), RepoError> { + let result = pending_event_queue::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + pending_event_queue::Column::Attempts, + Expr::col(( + pending_event_queue::Entity, + pending_event_queue::Column::Attempts, + )) + .add(1), + ) + .col_expr( + pending_event_queue::Column::ApplyAfter, + Expr::value(Some(apply_after)), + ) + .filter( + Condition::all() + .add(pending_event_queue::Column::TenantId.eq(tenant)) + .add(pending_event_queue::Column::Flow.eq(flow)) + .add(pending_event_queue::Column::BusinessId.eq(business_id)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("bump pending_event_queue attempts: {e}")))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "pending_event_queue row absent for ({tenant}, {flow}, {business_id})" + ))); + } + Ok(()) + } + + // --- Out-of-txn cross-tenant read (system-context sweep; UNSCOPED) --- + + /// List up to `limit` *due* `QUEUED` rows for `flow` ACROSS ALL TENANTS, + /// oldest `queued_at` first — the system-context feed for the periodic sweep + /// job ([`crate::infra::jobs::queue_applier`]). "Due" means `apply_after IS + /// NULL OR apply_after <= now`, exactly as [`Self::claim_due`]. + /// + /// DELIBERATELY UNSCOPED (`AccessScope::allow_all()`, no `.secure()` tenant + /// narrowing) — this is a cross-tenant admin/system read, the same sanctioned + /// pattern as [`crate::infra::storage::repo::ReferenceRepo::list_all_fiscal_calendars`] + /// (the period-open sweep). The caller (the sweep job) re-narrows to one + /// tenant via `AccessScope::for_tenant(row.tenant_id)` before applying each + /// row, so every WRITE remains tenant-scoped; only this read is broad. Runs + /// out-of-txn via `self.db.conn()` (the apply then claims under SKIP LOCKED in + /// its own txn, so this read is just the candidate feed — a row that another + /// applier grabs first is simply skipped at claim time). + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn list_all_due( + &self, + flow: &str, + now: DateTime, + limit: u64, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let due = Condition::any() + .add(pending_event_queue::Column::ApplyAfter.is_null()) + .add(pending_event_queue::Column::ApplyAfter.lte(now)); + let rows = pending_event_queue::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .filter( + Condition::all() + .add(pending_event_queue::Column::Flow.eq(flow)) + .add(pending_event_queue::Column::Status.eq(STATUS_QUEUED)) + .add(due), + ) + .order_by(pending_event_queue::Column::QueuedAt, Order::Asc) + .limit(limit) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list all due pending_event_queue: {e}")))?; + Ok(rows) + } + + // --- Out-of-txn read (metric / sweep depth; SQL-level BOLA) --- + + /// Read the single queue row for `(tenant, flow, business_id)`, or `None` + /// when absent. The out-of-txn counterpart to the intake `insert_queued`: + /// the allocate early-dedup check (`AllocationService::allocate_inner`) calls + /// this on a `QUEUED` dedup-status replay to surface the prior row's + /// `queued_at` for the `Queued` handle (the dedup row carries no + /// `queued_at`; the queue row is its work-state `SoT`). Like the other reads + /// it runs via `self.db.conn()` and is scoped (`.secure().scope_with`) for + /// SQL-level BOLA — a foreign tenant reads `None`. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn get( + &self, + scope: &AccessScope, + tenant: Uuid, + flow: &str, + business_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = pending_event_queue::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(pending_event_queue::Column::TenantId.eq(tenant)) + .add(pending_event_queue::Column::Flow.eq(flow)) + .add(pending_event_queue::Column::BusinessId.eq(business_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("get pending_event_queue: {e}")))?; + Ok(row) + } + + /// Count the `flow` rows currently in `status` ACROSS ALL TENANTS — the + /// system-context queue-depth feed for the sweep job's + /// `ledger_allocation_queue_depth` gauge. DELIBERATELY UNSCOPED + /// (`AccessScope::allow_all()`), the same sanctioned cross-tenant pattern as + /// [`Self::list_all_due`]; the gauge is an operational backlog metric, not a + /// tenant-visible read. Returned as `i64` for the metric sink. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn count_all_by_status(&self, flow: &str, status: &str) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let count = pending_event_queue::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .filter( + Condition::all() + .add(pending_event_queue::Column::Flow.eq(flow)) + .add(pending_event_queue::Column::Status.eq(status)), + ) + .count(&conn) + .await + .map_err(|e| RepoError::Db(format!("count all pending_event_queue by status: {e}")))?; + Ok(i64::try_from(count).unwrap_or(i64::MAX)) + } + + /// Count the `(tenant, flow)` rows currently in `status` (e.g. queue depth + /// for a `QUEUED` gauge, or drained/cancelled totals). SQL-level BOLA: a + /// foreign tenant counts zero. `SeaORM`'s count is `u64`; it is returned as + /// `i64` for the metric sink — a queue depth never approaches `i64::MAX`. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn count_by_status( + &self, + scope: &AccessScope, + tenant: Uuid, + flow: &str, + status: &str, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let count = pending_event_queue::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(pending_event_queue::Column::TenantId.eq(tenant)) + .add(pending_event_queue::Column::Flow.eq(flow)) + .add(pending_event_queue::Column::Status.eq(status)), + ) + .count(&conn) + .await + .map_err(|e| RepoError::Db(format!("count pending_event_queue by status: {e}")))?; + Ok(i64::try_from(count).unwrap_or(i64::MAX)) + } +} + +/// Scoped `update_many` flipping one queue row's `status` to `new_status`, keyed +/// on the full PK. Shared by `mark_applied` / `mark_cancelled`. A zero-row +/// update means the row vanished or was already terminal — surfaced as +/// [`RepoError::Db`]. +async fn set_status( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + flow: &str, + business_id: &str, + new_status: &str, +) -> Result<(), RepoError> { + let result = pending_event_queue::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + pending_event_queue::Column::Status, + Expr::value(new_status.to_owned()), + ) + .filter( + Condition::all() + .add(pending_event_queue::Column::TenantId.eq(tenant)) + .add(pending_event_queue::Column::Flow.eq(flow)) + .add(pending_event_queue::Column::BusinessId.eq(business_id)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("set pending_event_queue status {new_status}: {e}")))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "pending_event_queue row absent for ({tenant}, {flow}, {business_id})" + ))); + } + Ok(()) +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/period_close_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/period_close_repo.rs new file mode 100644 index 000000000..1a402ef67 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/period_close_repo.rs @@ -0,0 +1,120 @@ +//! `PeriodCloseRepo` — the close-process table (`bss.ledger_period_close`), +//! keyed by `(tenant_id, legal_entity_id, period_id)`. Owns the lifecycle row +//! the two-phase close drives (Slice 7, design §4.5). The Foundation +//! `fiscal_period.status` stays the posting gate; this row tracks the process. + +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use serde_json::Value as JsonValue; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt, SecureInsertExt, SecureOnConflict}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::RepoError; +use crate::infra::storage::entity::period_close; + +/// SeaORM-backed period-close-process repository. +#[derive(Clone)] +pub struct PeriodCloseRepo { + db: DBProvider, +} + +impl PeriodCloseRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// Upsert the close-process row to `status` (create-or-advance). The mutable + /// columns (`status`, `blocked_reasons`, `recon_watermark`, `closed_at`) are + /// set from the args; the PK identifies the period. `initiated_by` is set + /// only on the initial INSERT (the first closer). + #[allow( + clippy::too_many_arguments, + reason = "the close-process row carries the full gate result; a param object would not reduce the call-site count" + )] + pub async fn upsert_status( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + legal_entity: Uuid, + period_id: &str, + status: &str, + initiated_by: &str, + blocked_reasons: Option, + recon_watermark: Option, + closed_at: Option>, + ) -> Result<(), RepoError> { + let am = period_close::ActiveModel { + tenant_id: Set(tenant), + legal_entity_id: Set(legal_entity), + period_id: Set(period_id.to_owned()), + status: Set(status.to_owned()), + initiated_by: Set(initiated_by.to_owned()), + blocked_reasons: Set(blocked_reasons.clone()), + recon_watermark: Set(recon_watermark), + reopen_approval_id: Set(None), + reopened_by: Set(None), + closed_at: Set(closed_at), + }; + let on_conflict = SecureOnConflict::::columns([ + period_close::Column::TenantId, + period_close::Column::LegalEntityId, + period_close::Column::PeriodId, + ]) + .value(period_close::Column::Status, Expr::value(status)) + .and_then(|oc| { + oc.value( + period_close::Column::BlockedReasons, + Expr::value(blocked_reasons), + ) + }) + .and_then(|oc| { + oc.value( + period_close::Column::ReconWatermark, + Expr::value(recon_watermark), + ) + }) + .and_then(|oc| oc.value(period_close::Column::ClosedAt, Expr::value(closed_at))) + .map_err(|e| RepoError::Db(format!("ledger_period_close on_conflict: {e}")))?; + period_close::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_period_close scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| RepoError::Db(format!("upsert ledger_period_close: {e}")))?; + Ok(()) + } + + /// Read the close-process row (out-of-txn). SQL-level BOLA: a foreign tenant + /// yields no row. + pub async fn read( + &self, + scope: &AccessScope, + tenant: Uuid, + legal_entity: Uuid, + period_id: &str, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let row = period_close::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(period_close::Column::TenantId.eq(tenant)) + .add(period_close::Column::LegalEntityId.eq(legal_entity)) + .add(period_close::Column::PeriodId.eq(period_id)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read ledger_period_close: {e}")))?; + Ok(row) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/posting_policy_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/posting_policy_repo.rs new file mode 100644 index 000000000..db672fcac --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/posting_policy_repo.rs @@ -0,0 +1,134 @@ +//! Repository for the per-tenant invoice-posting policy +//! (`ledger_tenant_posting_policy`, VHP-1853): resolve the version in effect +//! (latest `effective_from <= at`, highest `version` on a tie) and append a new +//! effective-dated version. Mirrors `PaymentRepo::read_effective_policy` +//! (precedence) + `ApprovalRepo::insert_policy_row` (dual-control). Tenant-scoped +//! via `SecureORM` (SQL-level BOLA); out-of-txn on a fresh scoped connection (the +//! policy is admin-plane, never the hot money path). + +use chrono::{DateTime, Utc}; +use sea_orm::ActiveValue::Set; +use sea_orm::{ColumnTrait, Condition, EntityTrait, Order}; +use toolkit_db::secure::{AccessScope, SecureEntityExt, SecureInsertExt}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::invoice::policy::{AgingThresholds, MissingMappingMode, PostingPolicy}; +use crate::domain::model::RepoError; +use crate::infra::storage::entity::posting_policy; + +/// `SeaORM`-backed posting-policy repository. +#[derive(Clone)] +pub struct PostingPolicyRepo { + db: DBProvider, +} + +impl PostingPolicyRepo { + /// Build over one database provider. + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// Resolve the posting policy in effect for `tenant` at instant `at` — the + /// row with the latest `effective_from <= at` (ties: highest `version`). + /// Returns the gear default (`Suspense` + `[30,60,90]`, the prior hardcoded + /// behaviour) when the tenant has no effective row, so the caller always gets + /// an applicable policy. SQL-level BOLA: a foreign tenant yields no rows. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure, or when a stored value + /// fails to parse (an invariant breach — the columns are CHECK-constrained + /// and only written via the validated domain types). + pub async fn read_effective_policy( + &self, + scope: &AccessScope, + tenant: Uuid, + at: DateTime, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = posting_policy::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(posting_policy::Column::TenantId.eq(tenant)) + .add(posting_policy::Column::EffectiveFrom.lte(at)), + ) + .order_by(posting_policy::Column::EffectiveFrom, Order::Desc) + .order_by(posting_policy::Column::Version, Order::Desc) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read posting policy: {e}")))?; + let Some(row) = row else { + return Ok(PostingPolicy::default()); + }; + let missing_mapping_mode = + MissingMappingMode::parse(&row.missing_mapping_mode).map_err(|e| { + RepoError::Db(format!( + "corrupt missing_mapping_mode for tenant {tenant} version {}: {e}", + row.version + )) + })?; + let aging_thresholds = + AgingThresholds::parse_csv(&row.ar_aging_thresholds).map_err(|e| { + RepoError::Db(format!( + "corrupt ar_aging_thresholds for tenant {tenant} version {}: {e}", + row.version + )) + })?; + Ok(PostingPolicy { + missing_mapping_mode, + aging_thresholds, + }) + } + + /// Append a new effective-dated policy version for `tenant`, effective from + /// `effective_from`. The version is `max(version) + 1` (`0` for the first). + /// Returns the new version. SQL-level BOLA. The `(tenant, version)` PK guards + /// a concurrent double-write (the loser fails with a storage error — accepted + /// for the rare admin-plane write). + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure. + pub async fn write_version( + &self, + scope: &AccessScope, + tenant: Uuid, + policy: &PostingPolicy, + effective_from: DateTime, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let current = posting_policy::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(posting_policy::Column::TenantId.eq(tenant))) + .order_by(posting_policy::Column::Version, Order::Desc) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read max posting-policy version: {e}")))?; + let version = current.map_or(0, |r| r.version + 1); + let am = posting_policy::ActiveModel { + tenant_id: Set(tenant), + version: Set(version), + effective_from: Set(effective_from), + missing_mapping_mode: Set(policy.missing_mapping_mode.as_str().to_owned()), + ar_aging_thresholds: Set(policy.aging_thresholds.to_csv()), + created_at_utc: Set(Utc::now()), + }; + posting_policy::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_tenant_posting_policy scope: {e}")))? + .exec(&conn) + .await + .map_err(|e| RepoError::Db(format!("insert ledger_tenant_posting_policy: {e}")))?; + Ok(version) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/recognition_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/recognition_repo.rs new file mode 100644 index 000000000..2fc916faf --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/recognition_repo.rs @@ -0,0 +1,1768 @@ +//! `RecognitionRepo` — the ASC 606 revenue-recognition tables +//! (`recognition_schedule`, `recognition_segment`, `recognition_run`), keyed by +//! `(tenant_id, schedule_id)` / `(tenant_id, schedule_id, segment_no)` / +//! `(tenant_id, run_id)`. +//! +//! The **writes** (`insert_schedule`, `insert_segments`, `add_recognized`) run +//! inside the passed-in posting transaction (the in-txn sidecar, decision M), +//! mirroring [`PaymentRepo`](super::PaymentRepo)'s `seed_settlement` / +//! `insert_allocation_rows` / `add_*` shape: a scoped insert via +//! `.secure().scope_with_model`, a scoped `update_many` via +//! `.secure().scope_with`. `insert_schedule` materializes a fresh ACTIVE +//! schedule in the same transaction as the Slice 1 Contract-liability credit +//! (design §4.2 — a deferred balance never exists without a schedule); +//! `add_recognized` is the counter-delta-under-lock the `RecognitionRunner` +//! (Phase 2) applies per released segment. The `recognized_minor <= +//! total_deferred_minor` cap CHECK is the authoritative per-obligation +//! over-recognition guard under `SERIALIZABLE` (design §4.3 / §7); a violation +//! surfaces as [`RepoError::MoneyOutCapExceeded`] (the runner's stamp sidecar +//! turns it into the `OVER_RECOGNITION` wire code), exactly as +//! `PaymentRepo::add_*` maps its per-payment cap CHECKs. +//! +//! The **reads** (`read_schedule`, `list_segments`) take the PDP-compiled +//! `AccessScope` and run out-of-txn through `.secure().scope_with(scope)` +//! (SQL-level BOLA — a foreign tenant yields no rows); segments are ordered by +//! `segment_no` (which is 1:1 with `period_id`, so this is also period order). + +use std::collections::HashMap; + +use bss_ledger_sdk::{AccountClass, Side, SourceDocType}; +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, DbErr, EntityTrait, Order}; +use toolkit_db::secure::{ + AccessScope, DbTx, ScopeError, SecureEntityExt, SecureInsertExt, SecureUpdateExt, +}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::model::RepoError; +use crate::domain::status::{ + PERIOD_STATUS_OPEN, RUN_STATUS_DONE, RUN_STATUS_FAILED, RUN_STATUS_RUNNING, + SCHEDULE_STATUS_ACTIVE, SCHEDULE_STATUS_COMPLETED, SEGMENT_STATUS_DONE, SEGMENT_STATUS_PENDING, + SEGMENT_STATUS_QUEUED, +}; +use toolkit_db::odata::sea_orm_filter::{LimitCfg, paginate_odata}; +use toolkit_odata::{ODataQuery, Page, SortDir}; + +use crate::infra::storage::entity::{ + fiscal_period, journal_entry, journal_line, recognition_run, recognition_schedule, + recognition_segment, +}; +use crate::infra::storage::odata_mapping::RecognitionRunODataMapper; +use crate::infra::storage::repo::journal_repo::{ + OdataPageError, map_odata_err, query_with_default_order, +}; +use crate::odata::RecognitionRunFilterField; + +/// The `recognition_schedule` row to insert for a freshly materialized ACTIVE +/// schedule (one per revenue stream, design §3.5 / §4.5). `recognized_minor` +/// starts at 0 and `version` at 0 (set by the repo); `status` is stamped +/// `ACTIVE`. +pub struct NewSchedule { + pub tenant_id: Uuid, + pub schedule_id: String, + pub payer_tenant_id: Uuid, + pub source_invoice_id: String, + pub source_invoice_item_ref: String, + pub po_allocation_group: Option, + pub subscription_ref: Option, + pub revenue_stream: String, + pub currency: String, + pub total_deferred_minor: i64, + pub policy_ref: String, + pub ssp_snapshot_ref: Option, + pub vc_estimate_ref: Option, + pub vc_method_ref: Option, +} + +/// One `recognition_segment` row to insert — a time- or milestone-slice of a +/// schedule. `segment_no` is immutable and 1:1 with `period_id`; rows are seeded +/// `PENDING` with `recognized_at`/`run_id` NULL (stamped on release in Phase 2). +pub struct NewSegment { + pub tenant_id: Uuid, + pub schedule_id: String, + pub segment_no: i32, + pub period_id: String, + pub amount_minor: i64, +} + +/// A new ACTIVE **replacement** schedule version minted by a `replace` change +/// (Group H, design §3.6): the successor of a now-`REPLACED` schedule. Carries +/// the SAME business-key dims as its predecessor (so the partial UNIQUE one-live +/// guard still holds — the old flips `REPLACED` in the SAME txn before this +/// inserts), an explicit `version = old.version + 1`, and the REMAINING deferred +/// (`old.total_deferred − old.recognized`) as its `total_deferred_minor`. Mirrors +/// [`NewSchedule`] but with the explicit lineage `version` (the build path always +/// seeds `version = 0`). +pub struct ReplacementSchedule { + pub tenant_id: Uuid, + pub schedule_id: String, + pub payer_tenant_id: Uuid, + pub source_invoice_id: String, + pub source_invoice_item_ref: String, + pub po_allocation_group: Option, + pub subscription_ref: Option, + pub revenue_stream: String, + pub currency: String, + pub total_deferred_minor: i64, + pub policy_ref: String, + pub ssp_snapshot_ref: Option, + pub vc_estimate_ref: Option, + pub vc_method_ref: Option, + /// The lineage version of the successor (`= old.version + 1`). + pub version: i64, +} + +/// A due `PENDING` segment paired with the stream/currency/account context of +/// its owning ACTIVE schedule — the unit the `RecognitionRunner` releases. The +/// join to `recognition_schedule` carries the `revenue_stream` + `currency` +/// (both legs of the `DR CONTRACT_LIABILITY / CR REVENUE` post need them) and +/// the `total_deferred_minor`/`recognized_minor` snapshot (read-only context; +/// the authoritative over-recognition guard is the in-txn cap CHECK, not this +/// snapshot — design §4.3). A foreign tenant yields no rows (SQL-level BOLA). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DuePendingSegment { + pub schedule_id: String, + pub segment_no: i32, + pub period_id: String, + pub amount_minor: i64, + pub revenue_stream: String, + pub currency: String, + pub total_deferred_minor: i64, + pub recognized_minor: i64, +} + +/// One disaggregated recognized-revenue grain (design §3.5 / §4.5): the **net** +/// revenue RECOGNIZED into `revenue_stream` during `period_id` — the *actual* +/// posting period (see below) — in minor units of `currency`. The +/// [`RecognitionRepo::list_revenue_disaggregation`] read sources this from the +/// **journal** (not the segment rows): the `REVENUE` lines of the tenant's +/// `RECOGNITION` entries (each release posts `DR CONTRACT_LIABILITY / CR +/// REVENUE`; each clawback the mirror `DR REVENUE / CR CONTRACT_LIABILITY`), +/// grouped by `(period_id, revenue_stream)` and **signed-summed** (a `CR` release +/// adds, a `DR` reversal subtracts), ordered by `(period_id, revenue_stream)`. A +/// foreign tenant yields no rows (SQL-level BOLA). +/// +/// **Why the journal, not the segment's `period_id`.** A segment keeps its +/// *planned* `period_id` as the audit target even when an E-2 missed-close +/// releases it into the current OPEN period; the journal entry (and so its lines) +/// carries that actual open period. Sourcing from the entry's REVENUE lines +/// therefore reports the period the revenue truly landed in, and nets out +/// reversals — both of which a DONE-segment scan (gross, planned-period) cannot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecognizedStreamEntry { + pub period_id: String, + pub revenue_stream: String, + pub recognized_minor: i64, + pub currency: String, +} + +/// SeaORM-backed recognition schedule/segment/run repository. +#[derive(Clone)] +pub struct RecognitionRepo { + db: DBProvider, +} + +impl RecognitionRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + // --- In-txn writes (called by the schedule-build / recognition sidecars) --- + + /// Insert the `recognition_schedule` row for a freshly materialized ACTIVE + /// schedule (`recognized_minor = 0`, `version = 0`, `status = ACTIVE`). The + /// partial `UNIQUE (tenant, source_invoice_id, source_invoice_item_ref, + /// revenue_stream) WHERE status='ACTIVE'` is the at-most-one-live guard — a + /// concurrent second live schedule for the same business key collides; but a + /// duplicate build is short-circuited by the `SCHEDULE_BUILD` idempotency + /// claim before the sidecar, so an unexpected collision surfaces as + /// [`RepoError::Db`]. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn insert_schedule( + txn: &DbTx<'_>, + scope: &AccessScope, + schedule: &NewSchedule, + ) -> Result<(), RepoError> { + let am = recognition_schedule::ActiveModel { + tenant_id: Set(schedule.tenant_id), + schedule_id: Set(schedule.schedule_id.clone()), + payer_tenant_id: Set(schedule.payer_tenant_id), + source_invoice_id: Set(schedule.source_invoice_id.clone()), + source_invoice_item_ref: Set(schedule.source_invoice_item_ref.clone()), + po_allocation_group: Set(schedule.po_allocation_group.clone()), + subscription_ref: Set(schedule.subscription_ref.clone()), + revenue_stream: Set(schedule.revenue_stream.clone()), + currency: Set(schedule.currency.clone()), + total_deferred_minor: Set(schedule.total_deferred_minor), + recognized_minor: Set(0), + policy_ref: Set(schedule.policy_ref.clone()), + ssp_snapshot_ref: Set(schedule.ssp_snapshot_ref.clone()), + vc_estimate_ref: Set(schedule.vc_estimate_ref.clone()), + vc_method_ref: Set(schedule.vc_method_ref.clone()), + status: Set(SCHEDULE_STATUS_ACTIVE.to_owned()), + version: Set(0), + }; + recognition_schedule::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("recognition_schedule scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert recognition_schedule: {e}")))?; + Ok(()) + } + + /// Insert the N `recognition_segment` rows for one schedule (each seeded + /// `PENDING`, `recognized_at`/`run_id` NULL). The PK + /// `(tenant, schedule_id, segment_no)` and the period UNIQUE make a replay of + /// the same schedule collide — but a duplicate build returns before the + /// sidecar (the `SCHEDULE_BUILD` claim), so this is only reached on the first + /// build; an unexpected duplicate surfaces as a storage [`DbError`]. + /// + /// Returns [`DbError`] (NOT [`RepoError`]): this is an in-txn write driven by + /// both the build sidecar and the Group H change txn, and the change txn + /// retries on a serialization conflict — so the inner `sea_orm::DbErr` is + /// PRESERVED (`DbError::Sea`) for the retry helper's `as_db_err`, mirroring + /// [`crate::infra::period_close`]'s `scope_to_db`. A scope-construction fault + /// (never a serialization conflict) stays a non-retryable `DbError::Other`. + /// + /// # Errors + /// [`DbError`] on a scope or storage failure. + pub async fn insert_segments( + txn: &DbTx<'_>, + scope: &AccessScope, + segments: &[NewSegment], + ) -> Result<(), DbError> { + for seg in segments { + let am = recognition_segment::ActiveModel { + tenant_id: Set(seg.tenant_id), + schedule_id: Set(seg.schedule_id.clone()), + segment_no: Set(seg.segment_no), + period_id: Set(seg.period_id.clone()), + amount_minor: Set(seg.amount_minor), + status: Set(SEGMENT_STATUS_PENDING.to_owned()), + recognized_at: Set(None), + run_id: Set(None), + }; + recognition_segment::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(scope_to_db)? + .exec(txn) + .await + .map_err(scope_to_db)?; + } + Ok(()) + } + + /// In-txn scoped read of the `recognition_schedule` row for + /// `(tenant, schedule_id)`, or `None` when absent — the read-half a Group H + /// schedule change runs INSIDE its serializable change transaction (so the + /// read, the status flip, and any replacement insert share one snapshot and + /// conflict with a concurrent release under SSI). The out-of-txn + /// [`Self::read_schedule`] is the read surface for REST `GET`s; this is its + /// in-txn twin. SQL-level BOLA: a foreign tenant yields no row. + /// + /// Returns [`DbError`] (NOT [`RepoError`]): driven by the Group H change txn, + /// which retries on a serialization conflict — so the inner `sea_orm::DbErr` + /// (incl. a `40001` raised reading the contended schedule row mid-statement) + /// is PRESERVED as `DbError::Sea` for the retry helper, mirroring + /// [`crate::infra::period_close`]'s `scope_to_db`. + /// + /// # Errors + /// [`DbError`] on a scope or storage failure. + pub async fn read_schedule_in_txn( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + ) -> Result, DbError> { + let row = recognition_schedule::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::ScheduleId.eq(schedule_id)), + ) + .one(txn) + .await + .map_err(scope_to_db)?; + Ok(row) + } + + /// In-txn scoped read of the ACTIVE successor of a `REPLACED` schedule (the + /// Group H replay path): the one ACTIVE schedule sharing the predecessor's + /// business key (`source_invoice_id`, `source_invoice_item_ref`, + /// `revenue_stream`) at the successor lineage `version` (`= old.version + 1`). + /// A `replace` mints exactly one such row, so this resolves the new + /// `schedule_id` an idempotent change replay reports. `None` when no such + /// ACTIVE successor exists (e.g. the successor was itself later replaced — a + /// degenerate replay window). Runs INSIDE the change txn (the claim guard + /// already holds the task-local conn-bypass guard, so an out-of-txn `conn()` + /// would fail). SQL-level BOLA: a foreign tenant yields no row. + /// + /// Returns [`DbError`] (NOT [`RepoError`]) so a serialization conflict raised + /// mid-statement in the change txn stays retryable (`DbError::Sea`), mirroring + /// [`crate::infra::period_close`]'s `scope_to_db`. + /// + /// # Errors + /// [`DbError`] on a scope or storage failure. + pub async fn read_active_successor_in_txn( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + source_invoice_id: &str, + source_invoice_item_ref: &str, + revenue_stream: &str, + version: i64, + ) -> Result, DbError> { + let row = recognition_schedule::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::SourceInvoiceId.eq(source_invoice_id)) + .add( + recognition_schedule::Column::SourceInvoiceItemRef + .eq(source_invoice_item_ref), + ) + .add(recognition_schedule::Column::RevenueStream.eq(revenue_stream)) + .add(recognition_schedule::Column::Version.eq(version)) + .add(recognition_schedule::Column::Status.eq(SCHEDULE_STATUS_ACTIVE)), + ) + .one(txn) + .await + .map_err(scope_to_db)?; + Ok(row) + } + + /// In-txn scoped read of the LIVE (`ACTIVE`) schedule for a business key + /// `(tenant, source_invoice_id, source_invoice_item_ref, revenue_stream)`, + /// regardless of `version` — the at-most-one-live partial UNIQUE guarantees 0 + /// or 1 row. A later deferring note (a debit note) reads it here to EXTEND it + /// (one ACTIVE schedule per key) rather than mint a second the partial UNIQUE + /// would reject. SQL-level BOLA: a foreign tenant yields no row. + /// + /// # Errors + /// [`DbError`] on a scope or storage failure. + pub async fn read_active_schedule_in_txn( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + source_invoice_id: &str, + source_invoice_item_ref: &str, + revenue_stream: &str, + ) -> Result, DbError> { + let row = recognition_schedule::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::SourceInvoiceId.eq(source_invoice_id)) + .add( + recognition_schedule::Column::SourceInvoiceItemRef + .eq(source_invoice_item_ref), + ) + .add(recognition_schedule::Column::RevenueStream.eq(revenue_stream)) + .add(recognition_schedule::Column::Status.eq(SCHEDULE_STATUS_ACTIVE)), + ) + .one(txn) + .await + .map_err(scope_to_db)?; + Ok(row) + } + + /// Transition a schedule's `status` from `from_status` to `to_status` (e.g. + /// `ACTIVE → REPLACED` / `ACTIVE → CANCELLED`), bumping `version`, for + /// `(tenant, schedule_id)` — the Group H mark step (design §3.6 / §4.6). The + /// filter requires the current status to be `from_status`, so the flip is the + /// idempotency/race backstop: a concurrent change (or a replay that already + /// transitioned the row) matches no row (`rows_affected == 0`), which the + /// caller treats as "already transitioned" rather than an error. A scoped + /// `update_many` inside the change txn. Returns the number of rows flipped + /// (`0` or `1`). + /// + /// Returns [`DbError`] (NOT [`RepoError`]): the change txn retries on a + /// serialization conflict, and the `ACTIVE` row this flips is exactly what a + /// concurrent release contends — so a `40001` raised here is PRESERVED as + /// `DbError::Sea` for the retry helper, mirroring + /// [`crate::infra::period_close`]'s `scope_to_db` (a non-retryable + /// scope-construction fault stays `DbError::Other`). + /// + /// # Errors + /// [`DbError`] on a scope or storage failure. + pub async fn mark_schedule_status( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + from_status: &str, + to_status: &str, + ) -> Result { + let result = recognition_schedule::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + recognition_schedule::Column::Status, + Expr::value(to_status.to_owned()), + ) + .col_expr( + recognition_schedule::Column::Version, + Expr::col(( + recognition_schedule::Entity, + recognition_schedule::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::ScheduleId.eq(schedule_id)) + .add(recognition_schedule::Column::Status.eq(from_status)), + ) + .exec(txn) + .await + .map_err(scope_to_db)?; + Ok(result.rows_affected) + } + + /// In-txn scoped read of the highest `period_id` among a schedule's + /// already-`DONE` segments, or `None` when none are `DONE` — the floor a + /// Group H `replace` validates its replacement periods against (design §4.6): + /// a replacement segment may never re-target a period the old schedule has + /// ALREADY recognized (that would re-recognize a closed period across the + /// version boundary — cross-version double-recognition), so the first + /// replacement period MUST be strictly greater than this. `period_id` is the + /// `YYYYMM` lexical-sortable string, so `ORDER BY period_id DESC LIMIT 1` is + /// the max-DONE-period read (1:1 with `segment_no` within a schedule). Runs + /// INSIDE the change txn (the claim guard already holds the task-local + /// conn-bypass guard, so an out-of-txn `conn()` would fail), so it joins the + /// serializable snapshot. SQL-level BOLA: a foreign tenant yields no row. + /// + /// Returns [`DbError`] (NOT [`RepoError`]) so a serialization conflict raised + /// mid-statement in the change txn stays retryable (`DbError::Sea`), mirroring + /// the other in-txn change helpers. + /// + /// # Errors + /// [`DbError`] on a scope or storage failure. + pub async fn max_done_segment_period_in_txn( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + ) -> Result, DbError> { + let row = recognition_segment::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_segment::Column::TenantId.eq(tenant)) + .add(recognition_segment::Column::ScheduleId.eq(schedule_id)) + .add(recognition_segment::Column::Status.eq(SEGMENT_STATUS_DONE)), + ) + .order_by(recognition_segment::Column::PeriodId, Order::Desc) + .one(txn) + .await + .map_err(scope_to_db)?; + Ok(row.map(|r| r.period_id)) + } + + /// Count `recognition_segment`s whose `period_id` is `<=` the closing period + /// and not yet `DONE` — the period-close gate input (a due segment that has + /// not released blocks close, design §4.5). In-txn so it joins the close's + /// `SERIALIZABLE` snapshot (a concurrent release conflicts under SSI). + /// Returns [`DbError`] so a serialization conflict stays retryable. + pub async fn count_due_not_done_in_txn( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + ) -> Result { + let rows = recognition_segment::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_segment::Column::TenantId.eq(tenant)) + .add(recognition_segment::Column::PeriodId.lte(period_id)) + .add(recognition_segment::Column::Status.ne(SEGMENT_STATUS_DONE)), + ) + .all(txn) + .await + .map_err(scope_to_db)?; + Ok(rows.len()) + } + + /// Insert a fresh ACTIVE **replacement** schedule version (Group H `replace`, + /// design §3.6): `recognized_minor = 0`, `status = ACTIVE`, and the explicit + /// lineage `version` the caller computed (`= old.version + 1`). Mirrors + /// [`Self::insert_schedule`] but threads the explicit `version` (the build + /// path always seeds `0`). The old schedule must already be flipped `REPLACED` + /// in the SAME txn before this runs, so the partial `UNIQUE (tenant, + /// source_invoice_id, source_invoice_item_ref, revenue_stream) WHERE + /// status='ACTIVE'` one-live guard holds; an unexpected collision surfaces as + /// a storage [`DbError`] and rolls the change back. + /// + /// Returns [`DbError`] (NOT [`RepoError`]) so a serialization conflict raised + /// mid-statement in the change txn stays retryable (`DbError::Sea`), mirroring + /// [`crate::infra::period_close`]'s `scope_to_db`. + /// + /// # Errors + /// [`DbError`] on a scope or storage failure. + pub async fn insert_replacement_schedule( + txn: &DbTx<'_>, + scope: &AccessScope, + schedule: &ReplacementSchedule, + ) -> Result<(), DbError> { + let am = recognition_schedule::ActiveModel { + tenant_id: Set(schedule.tenant_id), + schedule_id: Set(schedule.schedule_id.clone()), + payer_tenant_id: Set(schedule.payer_tenant_id), + source_invoice_id: Set(schedule.source_invoice_id.clone()), + source_invoice_item_ref: Set(schedule.source_invoice_item_ref.clone()), + po_allocation_group: Set(schedule.po_allocation_group.clone()), + subscription_ref: Set(schedule.subscription_ref.clone()), + revenue_stream: Set(schedule.revenue_stream.clone()), + currency: Set(schedule.currency.clone()), + total_deferred_minor: Set(schedule.total_deferred_minor), + recognized_minor: Set(0), + policy_ref: Set(schedule.policy_ref.clone()), + ssp_snapshot_ref: Set(schedule.ssp_snapshot_ref.clone()), + vc_estimate_ref: Set(schedule.vc_estimate_ref.clone()), + vc_method_ref: Set(schedule.vc_method_ref.clone()), + status: Set(SCHEDULE_STATUS_ACTIVE.to_owned()), + version: Set(schedule.version), + }; + recognition_schedule::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(scope_to_db)? + .exec(txn) + .await + .map_err(scope_to_db)?; + Ok(()) + } + + /// Increment `recognition_schedule.recognized_minor` by `amount` for a + /// released segment, bumping `version`. The `recognized_minor <= + /// total_deferred_minor` cap CHECK + /// (`chk_ledger_recognition_schedule_recognized_le_deferred`) is the + /// authoritative per-obligation over-recognition guard: it enforces that the + /// cumulative release never exceeds what was deferred, evaluated against the + /// resulting row (`recognized_minor + amount <= total_deferred_minor`). A + /// violation maps to [`RepoError::MoneyOutCapExceeded`] (the recognition + /// stamp sidecar refines it to the `OVER_RECOGNITION` 409). A scoped UPDATE, + /// not an upsert: the schedule row always pre-exists (the schedule is + /// materialized before any release), so an `INSERT … ON CONFLICT` would trip + /// the CHECK on the INSERT VALUES tuple during arbitration (see + /// `PaymentRepo::add_allocated`). SSI + retry serialize concurrent releases + /// of the same schedule; `rows_affected == 0` ⇒ no such schedule. + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when the cap CHECK rejects the + /// increment; [`RepoError::Db`] when no row matched or on any other scope / + /// storage failure. + pub async fn add_recognized( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + amount: i64, + ) -> Result<(), RepoError> { + // Scoped UPDATE (not an upsert), exactly like `PaymentRepo::add_*`: the + // CHECK evaluates against the resulting row (`recognized_minor + amount <= + // total_deferred_minor`), so an over-recognition surfaces as the CHECK + // violation, mapped to `MoneyOutCapExceeded`; the runner's stamp sidecar + // refines it to `OverRecognition` (409). + let result = recognition_schedule::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + recognition_schedule::Column::RecognizedMinor, + Expr::col(( + recognition_schedule::Entity, + recognition_schedule::Column::RecognizedMinor, + )) + .add(amount), + ) + .col_expr( + recognition_schedule::Column::Version, + Expr::col(( + recognition_schedule::Entity, + recognition_schedule::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::ScheduleId.eq(schedule_id)), + ) + .exec(txn) + .await + .map_err(|e| map_cap_violation("add recognized_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "recognition_schedule row absent for ({tenant}, {schedule_id})" + ))); + } + Ok(()) + } + + /// Decrement `recognition_schedule.total_deferred_minor` by `amount` (a + /// **positive** reduction over the not-yet-released remainder), bumping + /// `version` — the Slice-3 **credit-note deferred reduction** (design §4.2): + /// when a credit note debits `CONTRACT_LIABILITY` for a deferred portion it + /// reduces the owning schedule's deferred total in the SAME post txn, so a + /// later recognition run cannot re-recognize the credited-back amount. The + /// reduction is bounded by the schedule's remaining releasable amount + /// (`total_deferred_minor − recognized_minor`): the authoritative guard is the + /// existing `recognized_minor <= total_deferred_minor` CHECK + /// (`chk_ledger_recognition_schedule_recognized_le_deferred`) — it is evaluated + /// against the resulting row, so a reduction that would drop + /// `total_deferred_minor` below the already-`recognized_minor` (over-reducing an + /// in-flight schedule) is rejected; the `total_deferred_minor >= 0` CHECK is the + /// floor. A violation maps to [`RepoError::MoneyOutCapExceeded`] (the + /// `CreditNoteHandler` refines it — already-released segments are never + /// recomputed, mirroring Slice 4 §4.6 re-version semantics). + /// + /// A scoped UPDATE (not an upsert), exactly like [`Self::add_recognized`]: the + /// schedule row always pre-exists (a deferred portion implies an ACTIVE + /// schedule the split read). SSI + retry serialize a concurrent credit-note + /// reduction and a recognition release of the same schedule (both take the + /// rank-6 schedule row). `rows_affected == 0` ⇒ no such schedule (an invariant + /// breach — the split read it under the lock order). + /// + /// # Errors + /// [`RepoError::MoneyOutCapExceeded`] when a schedule CHECK rejects the + /// reduction (over-reduction past the releasable remainder, or below zero); + /// [`RepoError::Db`] when no row matched or on any other scope / storage + /// failure. + pub async fn reduce_deferred( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + amount: i64, + ) -> Result<(), RepoError> { + // Apply as a NEGATIVE delta on `total_deferred_minor` (`col + (−amount)`), + // the SAME `col_expr(col, Expr::col(col).add(delta))` shape `add_recognized` + // uses (the reversal path likewise feeds it a negative delta) — so the CHECK + // is evaluated against the resulting row exactly as for the recognized + // counter, and the SQL is the proven counter-delta-under-lock. + let neg_delta = amount.checked_neg().ok_or_else(|| { + RepoError::Db(format!( + "deferred reduction amount {amount} overflows on negate" + )) + })?; + let result = recognition_schedule::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + recognition_schedule::Column::TotalDeferredMinor, + Expr::col(( + recognition_schedule::Entity, + recognition_schedule::Column::TotalDeferredMinor, + )) + .add(neg_delta), + ) + .col_expr( + recognition_schedule::Column::Version, + Expr::col(( + recognition_schedule::Entity, + recognition_schedule::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::ScheduleId.eq(schedule_id)), + ) + .exec(txn) + .await + .map_err(|e| map_cap_violation("reduce total_deferred_minor", &e))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "recognition_schedule row absent for ({tenant}, {schedule_id})" + ))); + } + Ok(()) + } + + /// Increase `total_deferred_minor` by `amount` (a positive delta) + bump + /// `version`, for `(tenant, schedule_id)` — a later deferring note (a debit + /// note) ADDS its deferred part to the live schedule it extends. The same + /// `col_expr(col, col + delta)` shape as [`Self::reduce_deferred`] (its + /// inverse), so the `deferred >= 0` CHECK is evaluated against the resulting + /// row; the `recognized <= total_deferred` CHECK can only relax (the total + /// grows). `rows_affected == 0` ⇒ no such schedule. + /// + /// # Errors + /// [`RepoError::Db`] when no row matched or on a scope / storage failure. + pub async fn increase_total_deferred( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + amount: i64, + ) -> Result<(), RepoError> { + let result = recognition_schedule::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + recognition_schedule::Column::TotalDeferredMinor, + Expr::col(( + recognition_schedule::Entity, + recognition_schedule::Column::TotalDeferredMinor, + )) + .add(amount), + ) + .col_expr( + recognition_schedule::Column::Version, + Expr::col(( + recognition_schedule::Entity, + recognition_schedule::Column::Version, + )) + .add(1), + ) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::ScheduleId.eq(schedule_id)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("increase total_deferred_minor: {e}")))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "recognition_schedule row absent for ({tenant}, {schedule_id})" + ))); + } + Ok(()) + } + + /// In-txn scoped read of all segments for `(tenant, schedule_id)`, ascending by + /// `segment_no` — the read-half of a schedule EXTEND (a debit note merging its + /// segments into the live schedule needs the current period set + the max + /// `segment_no` within the SAME txn). The in-txn twin of [`Self::list_segments`]. + /// + /// # Errors + /// [`DbError`] on a scope or storage failure. + pub async fn list_segments_in_txn( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + ) -> Result, DbError> { + let rows = recognition_segment::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_segment::Column::TenantId.eq(tenant)) + .add(recognition_segment::Column::ScheduleId.eq(schedule_id)), + ) + .order_by(recognition_segment::Column::SegmentNo, Order::Asc) + .all(txn) + .await + .map_err(scope_to_db)?; + Ok(rows) + } + + /// Add `amount` (a positive delta) to an EXISTING `PENDING` segment's + /// `amount_minor`, for `(tenant, schedule_id, segment_no)` — a debit note + /// extending the live schedule on a period it already covers folds its amount + /// into that period's segment (one row per period, UNIQUE + /// (`schedule_id`, `period_id`)). The filter requires `status = 'PENDING'` so a + /// period already released (`DONE`) or parked (`QUEUED`) is NOT silently grown + /// (that would strand revenue the run already recognized); the caller treats + /// `rows_affected == 0` as "cannot extend that period" and rolls the post back. + /// + /// # Errors + /// [`RepoError::Db`] when no PENDING row matched or on a scope / storage failure. + pub async fn add_pending_segment_amount( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + segment_no: i32, + amount: i64, + ) -> Result<(), RepoError> { + let result = recognition_segment::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + recognition_segment::Column::AmountMinor, + Expr::col(( + recognition_segment::Entity, + recognition_segment::Column::AmountMinor, + )) + .add(amount), + ) + .filter( + Condition::all() + .add(recognition_segment::Column::TenantId.eq(tenant)) + .add(recognition_segment::Column::ScheduleId.eq(schedule_id)) + .add(recognition_segment::Column::SegmentNo.eq(segment_no)) + .add(recognition_segment::Column::Status.eq(SEGMENT_STATUS_PENDING)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("add segment amount: {e}")))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "no PENDING recognition_segment for ({tenant}, {schedule_id}, seg \ + {segment_no}) — cannot extend an already-released/parked period" + ))); + } + Ok(()) + } + + /// Transition a fully-drained schedule `ACTIVE → COMPLETED` (design §4.6), + /// for `(tenant, schedule_id)` — the terminal stamp the recognition stamp + /// sidecar applies on the RELEASE path after the last segment commits `DONE`. + /// The filter requires the current `status` to be `ACTIVE` AND + /// `recognized_minor == total_deferred_minor` (a column-to-column equality: + /// the schedule has recognized everything it deferred), so the flip fires + /// exactly once — on the release that drains the last segment — and is a + /// no-op (`rows_affected == 0`) on every earlier release (the schedule is not + /// yet drained) and on a replay (already `COMPLETED`). Calling it after every + /// `stamp_segment_done` is therefore correct + idempotent. + /// + /// **No `version` bump** (unlike [`Self::mark_schedule_status`] / + /// [`Self::add_recognized`]): `COMPLETED` is the SAME schedule reaching its + /// terminal state, not a new lineage (a `replace`/`cancel` mints a new + /// version; completion does not). A scoped `update_many` inside the release + /// post txn. Returns `true` iff the row flipped (the last segment just + /// drained it), `false` otherwise (not yet drained, or already terminal). + /// + /// Reaching `COMPLETED` frees the partial `UNIQUE (tenant, + /// source_invoice_id, source_invoice_item_ref, revenue_stream) WHERE + /// status='ACTIVE'` one-live slot (a fresh deferred re-build of the same + /// business key is then admitted) and drops the schedule from the runner's + /// ACTIVE-only due feed + the `ledger_schedule_active_total` gauge. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn complete_schedule_if_drained( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + ) -> Result { + let result = recognition_schedule::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + recognition_schedule::Column::Status, + Expr::value(SCHEDULE_STATUS_COMPLETED.to_owned()), + ) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::ScheduleId.eq(schedule_id)) + .add(recognition_schedule::Column::Status.eq(SCHEDULE_STATUS_ACTIVE)) + .add( + Expr::col(recognition_schedule::Column::RecognizedMinor) + .eq(Expr::col(recognition_schedule::Column::TotalDeferredMinor)), + ), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("complete recognition_schedule: {e}")))?; + Ok(result.rows_affected > 0) + } + + /// Stamp one `recognition_segment` `DONE` (set `status = DONE`, + /// `recognized_at`, `run_id`) for `(tenant, schedule_id, segment_no)` — the + /// release marker the `RecognitionRunner`'s stamp sidecar writes in the SAME + /// post txn as the `DR CL / CR Revenue` entry (design §4.3). The filter + /// requires the current `status` to be `PENDING` or `QUEUED`, so a re-stamp of + /// an already-`DONE` segment matches no row (`rows_affected == 0`): this is the + /// at-most-once stamp guard, layered under the per-segment `RECOGNITION` + /// idempotency claim (a replay returns before the sidecar) and the + /// `UNIQUE (schedule, period_id)` key. `rows_affected == 0` therefore signals + /// either a foreign/absent segment or a concurrent release that already flipped + /// it — an invariant breach on the fresh-claim path, surfaced as + /// [`RepoError::Db`] so the post rolls back rather than double-crediting. + /// + /// # Errors + /// [`RepoError::Db`] when no `PENDING`/`QUEUED` row matched, or on any scope / + /// storage failure. + pub async fn stamp_segment_done( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + segment_no: i32, + run_id: Uuid, + recognized_at: DateTime, + ) -> Result<(), RepoError> { + let result = recognition_segment::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + recognition_segment::Column::Status, + Expr::value(SEGMENT_STATUS_DONE.to_owned()), + ) + .col_expr( + recognition_segment::Column::RecognizedAt, + Expr::value(Some(recognized_at)), + ) + .col_expr( + recognition_segment::Column::RunId, + Expr::value(Some(run_id)), + ) + .filter( + Condition::all() + .add(recognition_segment::Column::TenantId.eq(tenant)) + .add(recognition_segment::Column::ScheduleId.eq(schedule_id)) + .add(recognition_segment::Column::SegmentNo.eq(segment_no)) + .add( + recognition_segment::Column::Status + .is_in([SEGMENT_STATUS_PENDING, SEGMENT_STATUS_QUEUED]), + ), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("stamp recognition_segment DONE: {e}")))?; + if result.rows_affected == 0 { + return Err(RepoError::Db(format!( + "recognition_segment ({tenant}, {schedule_id}, {segment_no}) absent or not \ + PENDING/QUEUED at stamp time" + ))); + } + Ok(()) + } + + // --- Out-of-txn reads (PDP In-scoped; SQL-level BOLA) --- + + /// Read the `recognition_schedule` row for `(tenant, schedule_id)`, or `None` + /// when no such schedule exists. SQL-level BOLA: a foreign tenant yields no + /// row. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_schedule( + &self, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = recognition_schedule::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::ScheduleId.eq(schedule_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read recognition_schedule: {e}")))?; + Ok(row) + } + + /// List `recognition_schedule` headers for `tenant`, optionally narrowed to + /// one originating invoice (`source_invoice_id`) and/or one `revenue_stream`. + /// Backs the `GET /recognition-schedules` discovery surface and the + /// post-commit lookup that surfaces a freshly-minted `schedule_id` on + /// invoice-post. SQL-level BOLA: a foreign tenant yields no rows. + /// + /// Ordered by `(source_invoice_item_ref, version desc, schedule_id)` — the + /// trailing PK makes it a TOTAL order so the cap truncates deterministically + /// (schedules can share `(item_ref, version)`: different streams, archived + /// lineage). Returns `(rows, truncated)`: `truncated` is `true` when the + /// `(tenant[, stream])` scan exceeded the cap, so the caller can signal it + /// rather than silently drop the tail. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_schedules( + &self, + scope: &AccessScope, + tenant: Uuid, + invoice_id: Option<&str>, + revenue_stream: Option<&str>, + ) -> Result<(Vec, bool), RepoError> { + // A discovery lookup, not a paginated collection: a per-(tenant, invoice) + // result is tiny; the cap only fences a `revenue_stream`-only scan. Fetch + // one extra row to DETECT truncation (vs silently dropping the tail). + const SCHEDULE_LIST_CAP: usize = 500; + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let mut predicate = Condition::all().add(recognition_schedule::Column::TenantId.eq(tenant)); + if let Some(invoice_id) = invoice_id { + predicate = predicate.add(recognition_schedule::Column::SourceInvoiceId.eq(invoice_id)); + } + if let Some(revenue_stream) = revenue_stream { + predicate = + predicate.add(recognition_schedule::Column::RevenueStream.eq(revenue_stream)); + } + let mut rows = recognition_schedule::Entity::find() + .secure() + .scope_with(scope) + .filter(predicate) + .order_by( + recognition_schedule::Column::SourceInvoiceItemRef, + Order::Asc, + ) + .order_by(recognition_schedule::Column::Version, Order::Desc) + .order_by(recognition_schedule::Column::ScheduleId, Order::Asc) + .limit(SCHEDULE_LIST_CAP as u64 + 1) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list recognition_schedule: {e}")))?; + let truncated = rows.len() > SCHEDULE_LIST_CAP; + if truncated { + rows.truncate(SCHEDULE_LIST_CAP); + tracing::warn!( + tenant = %tenant, + cap = SCHEDULE_LIST_CAP, + "recognition-schedule list hit the cap; result truncated (no pagination)" + ); + } + Ok((rows, truncated)) + } + + /// List the `recognition_segment` rows for `(tenant, schedule_id)`, ordered + /// by `segment_no` (1:1 with `period_id`, so this is also period order). + /// SQL-level BOLA: a foreign tenant yields no rows. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_segments( + &self, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = recognition_segment::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_segment::Column::TenantId.eq(tenant)) + .add(recognition_segment::Column::ScheduleId.eq(schedule_id)), + ) + .order_by(recognition_segment::Column::SegmentNo, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list recognition_segment: {e}")))?; + Ok(rows) + } + + /// List the due releasable segments for `(tenant, period_id ≤ target)`, + /// joined to their ACTIVE schedule for the stream/currency/account context the + /// `RecognitionRunner` posts with, ordered by `schedule_id` then `segment_no` + /// (the runner releases each schedule's due segments in ascending `segment_no` + /// — the ordering GAP guard that the predecessor be `DONE` is Group E). A + /// segment whose schedule is not ACTIVE (`COMPLETED`/`REPLACED`/`CANCELLED`) is + /// dropped — a `COMPLETED` schedule has no due work and a `CANCELLED`/`REPLACED` + /// one is not released under its old id (design §4.6). SQL-level BOLA: a + /// foreign tenant yields no rows (both queries are `.secure().scope_with`). + /// + /// Implemented as a scoped segment scan + a per-distinct-schedule scoped + /// lookup (the gear has no cross-entity join idiom; every repo read is a + /// single-entity scoped query) — the schedule reads are bounded by the count + /// of distinct due schedules and memoized here. + /// + /// **Releasable-from set (`PENDING` + `QUEUED`).** Both `PENDING` and a + /// previously out-of-order-parked `QUEUED` segment are returned — the SAME + /// acceptance set [`Self::stamp_segment_done`] flips to `DONE`. This is the + /// Group F drain: a `QUEUED` segment is re-enumerated each run and releases + /// once its lower-period predecessor has committed `DONE` (the runner's + /// `count_predecessors_not_done` gate re-evaluates it); a still-blocked + /// `QUEUED` segment is simply re-parked (a no-op `mark_segment_queued`). Only + /// `DONE` segments are excluded (terminal — already released). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_due_pending_segments( + &self, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + + // Due releasable segments (PENDING or QUEUED, period_id ≤ target), ordered + // by schedule then segment_no. `period_id` is the `YYYYMM` + // lexical-sortable string, so a `<=` string compare is the period-order + // compare (1:1 with segment_no within a schedule). A QUEUED segment is + // re-enumerated so a later run drains it once its predecessor is DONE + // (Group F); only DONE is excluded (terminal). + let segments = recognition_segment::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_segment::Column::TenantId.eq(tenant)) + .add( + recognition_segment::Column::Status + .is_in([SEGMENT_STATUS_PENDING, SEGMENT_STATUS_QUEUED]), + ) + .add(recognition_segment::Column::PeriodId.lte(period_id)), + ) + .order_by(recognition_segment::Column::ScheduleId, Order::Asc) + .order_by(recognition_segment::Column::SegmentNo, Order::Asc) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list due recognition_segment: {e}")))?; + + // Resolve each distinct owning schedule once (scoped), keeping only ACTIVE + // ones; cache the join context per schedule_id. + let mut schedule_ctx: HashMap> = HashMap::new(); + let mut due = Vec::with_capacity(segments.len()); + for seg in segments { + if !schedule_ctx.contains_key(&seg.schedule_id) { + let row = recognition_schedule::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_schedule::Column::TenantId.eq(tenant)) + .add(recognition_schedule::Column::ScheduleId.eq(&seg.schedule_id)), + ) + .one(&conn) + .await + .map_err(|e| { + RepoError::Db(format!("read recognition_schedule for due segment: {e}")) + })?; + schedule_ctx.insert(seg.schedule_id.clone(), row); + } + // Only release segments of an ACTIVE schedule. + let Some(schedule) = schedule_ctx + .get(&seg.schedule_id) + .and_then(Option::as_ref) + .filter(|s| s.status == SCHEDULE_STATUS_ACTIVE) + else { + continue; + }; + due.push(DuePendingSegment { + schedule_id: seg.schedule_id, + segment_no: seg.segment_no, + period_id: seg.period_id, + amount_minor: seg.amount_minor, + revenue_stream: schedule.revenue_stream.clone(), + currency: schedule.currency.clone(), + total_deferred_minor: schedule.total_deferred_minor, + recognized_minor: schedule.recognized_minor, + }); + } + Ok(due) + } + + /// Disaggregate **net** RECOGNIZED revenue by stream for `(tenant, period_id?)` + /// (design §3.5 / §4.5), sourced from the **journal** — the `REVENUE` lines of + /// the tenant's `RECOGNITION` entries. Each segment release posts a + /// `DR CONTRACT_LIABILITY / CR REVENUE` entry; each clawback the mirror + /// `DR REVENUE / CR CONTRACT_LIABILITY`. The read groups those REVENUE lines by + /// `(period_id, revenue_stream)` and **signed-sums** them (a `CR` release adds, + /// a `DR` reversal subtracts → reversal-aware), ordered by + /// `(period_id, revenue_stream)`. `period_id` `None` ⇒ every period; `Some(_)` + /// narrows to that period. SQL-level BOLA: a foreign tenant yields no rows + /// (every query is `.secure().scope_with`). + /// + /// **Period = the journal entry's period, not the segment's.** A segment keeps + /// its *planned* `period_id` as the audit target even when an E-2 missed-close + /// releases it into the current OPEN period (§4.3); the journal entry — and so + /// each of its lines (every `journal_line.period_id` is persisted from the + /// owning entry's period) — carries the actual open period. Reading the entry's + /// REVENUE lines therefore reports the period the revenue truly landed in (and + /// nets out reversals), which a DONE-segment scan (gross, planned-period) + /// cannot. + /// + /// Implemented as two scoped single-entity reads + an in-memory group/SUM (the + /// gear has no cross-entity join / DB-side `GROUP BY` idiom): (1) the tenant's + /// `RECOGNITION` `journal_entry` ids (bounded to the recognition domain — one + /// per release/reversal, the same cardinality as the DONE segments), then (2) + /// their `REVENUE` `journal_line`s via `entry_id IN (…)`. The grouping key + /// `(period_id, revenue_stream)` is a `BTreeMap` key, so the result is ordered + /// by `(period_id, revenue_stream)`. Releases of a since-terminal schedule + /// (`COMPLETED`/`REPLACED`/`CANCELLED`) still contribute — their journal entries + /// are immutable historical fact, independent of the schedule's later lifecycle. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_revenue_disaggregation( + &self, + scope: &AccessScope, + tenant: Uuid, + period_id: Option<&str>, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + + // (1) The tenant's RECOGNITION entry ids, optionally narrowed to the period + // (an entry's period == its lines' period, so this also prunes the line + // scan). Scoped (SQL-level BOLA); bounded to the recognition domain. + let mut entry_filter = Condition::all() + .add(journal_entry::Column::TenantId.eq(tenant)) + .add(journal_entry::Column::SourceDocType.eq(SourceDocType::Recognition.as_str())); + if let Some(period_id) = period_id { + entry_filter = entry_filter.add(journal_entry::Column::PeriodId.eq(period_id)); + } + let entry_ids: Vec = journal_entry::Entity::find() + .secure() + .scope_with(scope) + .filter(entry_filter) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list recognition journal_entry: {e}")))? + .into_iter() + .map(|e| e.entry_id) + .collect(); + if entry_ids.is_empty() { + return Ok(Vec::new()); + } + + // (2) Their REVENUE lines. `journal_line.period_id` is the entry's period + // (the actual open period on an E-2 missed-close); `revenue_stream` is the + // per-stream tag both legs carry. Scoped. + let lines = journal_line::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(journal_line::Column::TenantId.eq(tenant)) + .add(journal_line::Column::AccountClass.eq(AccountClass::Revenue.as_str())) + .add(journal_line::Column::EntryId.is_in(entry_ids)), + ) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list recognition REVENUE journal_line: {e}")))?; + + // Net by (period_id, revenue_stream) → (Σ signed amount, currency). A + // BTreeMap key orders the result by (period_id, revenue_stream). + let mut grouped: std::collections::BTreeMap<(String, String), (i64, String)> = + std::collections::BTreeMap::new(); + for line in lines { + // Recognition REVENUE legs always tag a stream; skip an untagged line + // defensively (no per-stream grain to attribute it to). + let Some(stream) = line.revenue_stream.clone() else { + continue; + }; + // Signed: a CR REVENUE release recognizes (+), a DR REVENUE reversal + // claws back (−). + let signed = if line.side == Side::Credit.as_str() { + line.amount_minor + } else { + -line.amount_minor + }; + let entry = grouped + .entry((line.period_id.clone(), stream)) + .or_insert_with(|| (0, line.currency.clone())); + // i64 sum (the per-schedule cap CHECK bounds each schedule's release to + // its deferred total; the per-account no-negative CHECK bounds the + // aggregate — a single tenant/period/stream stays within i64). + entry.0 = entry.0.saturating_add(signed); + } + Ok(grouped + .into_iter() + .map( + |((period_id, revenue_stream), (recognized_minor, currency))| { + RecognizedStreamEntry { + period_id, + revenue_stream, + recognized_minor, + currency, + } + }, + ) + .collect()) + } + + /// Enumerate the distinct `(tenant_id, period_id)` pairs that have at least + /// one due releasable (`PENDING` or `QUEUED`) recognition segment — the + /// **cross-tenant work feed** the Group F `RecognitionRunJob` ticker triggers a + /// run for (one run per pair). An UNSCOPED, system-context read under the + /// sanctioned all-tenants [`AccessScope::allow_all`] (the AM reaper / tie-out / + /// queue-applier pattern), capped at `limit` rows scanned so a pathological + /// backlog can't load an unbounded set into memory; the distinct pairs are + /// folded in memory (the gear has no `DISTINCT`/`GROUP BY` access). A `QUEUED` + /// segment's own period IS enumerated so the next tick re-runs it and drains + /// the segment once its lower-period predecessor commits `DONE` (the runner's + /// predecessor gate re-evaluates it); only `DONE` segments contribute no work. + /// `effective_at` ordering is irrelevant — the per-pair run is idempotent and + /// re-evaluates due work itself. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn list_due_tenant_periods( + &self, + limit: u64, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let segments = recognition_segment::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .filter( + Condition::all().add( + recognition_segment::Column::Status + .is_in([SEGMENT_STATUS_PENDING, SEGMENT_STATUS_QUEUED]), + ), + ) + .order_by(recognition_segment::Column::TenantId, Order::Asc) + .order_by(recognition_segment::Column::PeriodId, Order::Asc) + .limit(limit) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("enumerate due tenant/periods: {e}")))?; + // Distinct (tenant, period) pairs, folded in memory (no DB-side DISTINCT). + let mut seen: std::collections::BTreeSet<(Uuid, String)> = + std::collections::BTreeSet::new(); + for seg in segments { + seen.insert((seg.tenant_id, seg.period_id)); + } + Ok(seen.into_iter().collect()) + } + + // --- Ordering guard (Group E1, design §4.6) --- + + /// Count this schedule's segments with a lower `period_id` (an earlier + /// period) that are NOT yet `DONE` — the **predecessor-not-done** check the + /// runner consults before releasing a segment (design §4.6 ordering). A + /// non-zero count means an earlier-period segment of the SAME schedule is + /// still `PENDING`/`QUEUED`, so this segment must be parked `QUEUED` rather + /// than released early. `period_id` is the `YYYYMM` lexical-sortable string, + /// so a `<` string compare is the period-order compare (1:1 with + /// `segment_no` within a schedule). Scoped (SQL-level BOLA); runs in the + /// runner's read connection (not the post txn). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn count_predecessors_not_done( + &self, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + period_id: &str, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let count = recognition_segment::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_segment::Column::TenantId.eq(tenant)) + .add(recognition_segment::Column::ScheduleId.eq(schedule_id)) + .add(recognition_segment::Column::PeriodId.lt(period_id)) + .add(recognition_segment::Column::Status.ne(SEGMENT_STATUS_DONE)), + ) + .count(&conn) + .await + .map_err(|e| RepoError::Db(format!("count predecessors not done: {e}")))?; + Ok(count) + } + + /// Mark a `PENDING` segment `QUEUED` — the out-of-order park (design §4.6): + /// a due segment whose lower-period predecessor is not yet `DONE` is not + /// released, it is moved `PENDING → QUEUED` so a later run drains it once the + /// predecessor commits. The filter requires the current status to be + /// `PENDING` (a `QUEUED`/`DONE` segment is left untouched — `rows_affected == + /// 0` is then a benign no-op, not an error: a concurrent run may have already + /// queued/released it). A scoped UPDATE outside any post txn (queuing posts + /// no journal entry). `recognized_at`/`run_id` stay NULL — nothing was + /// recognized. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn mark_segment_queued( + &self, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, + segment_no: i32, + ) -> Result<(), RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + recognition_segment::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + recognition_segment::Column::Status, + Expr::value(SEGMENT_STATUS_QUEUED.to_owned()), + ) + .filter( + Condition::all() + .add(recognition_segment::Column::TenantId.eq(tenant)) + .add(recognition_segment::Column::ScheduleId.eq(schedule_id)) + .add(recognition_segment::Column::SegmentNo.eq(segment_no)) + .add(recognition_segment::Column::Status.eq(SEGMENT_STATUS_PENDING)), + ) + .exec(&conn) + .await + .map_err(|e| RepoError::Db(format!("mark recognition_segment QUEUED: {e}")))?; + Ok(()) + } + + // --- Run-row orchestration (Group E2, design §4.3 / §7) --- + + /// Read the `recognition_run` row for `(tenant, period_id, run_id)`, or `None` + /// when no such run exists — the run-trigger **dedup** read (design §4.3): a + /// trigger + /// whose `(tenant, period_id, run_id)` already has a row replays that run + /// reference instead of starting a second run. SQL-level BOLA: a foreign + /// tenant yields no row. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_run( + &self, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + run_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = recognition_run::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_run::Column::TenantId.eq(tenant)) + .add(recognition_run::Column::PeriodId.eq(period_id)) + .add(recognition_run::Column::RunId.eq(run_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read recognition_run: {e}")))?; + Ok(row) + } + + /// Read the `recognition_run` row for `(tenant, run_id)` — the + /// `GET /recognition-runs/{run_id}` by-id source (read surface R4). The entity + /// PK is the 3-column `(tenant_id, period_id, run_id)`, but the surrogate + /// `run_id` is itself unique within a tenant in practice (the run-service mints + /// a fresh `run_id` per run; the period is folded into the PK only to defend a + /// client that REUSES one `run_id` across two periods), so + /// a by-`run_id` read yields at most one row in the common case — and when a + /// `run_id` WAS reused across periods this returns the first match (the by-id + /// read is a single-run lookup, not the period-qualified dedup [`Self::read_run`] + /// the run-service uses). Returns `None` when no run with that id exists for the + /// tenant. Scoped (SQL-level BOLA — a foreign tenant yields `None`, the same 404 + /// as absent, no existence leak). Out-of-txn on a fresh scoped connection (a + /// pure read). Mirrors `AdjustmentRepo::read_refund_out_of_txn` (filtered on the + /// `Uuid` `run_id` rather than a `varchar` id). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn read_run_out_of_txn( + &self, + scope: &AccessScope, + tenant: Uuid, + run_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + recognition_run::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(recognition_run::Column::TenantId.eq(tenant)) + .add(recognition_run::Column::RunId.eq(run_id)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read recognition_run by run_id: {e}"))) + } + + /// List the `recognition_run` record rows for `tenant` under `scope`, + /// cursor-paginated via the canonical `query` (`$filter` over `run_id` / + /// `period_id` / `status`, `$orderby` / `limit` / `cursor`). The tenant + /// predicate is pre-applied to the secured select; the user `$filter` is + /// additive over it (SQL-level BOLA — a foreign value still ANDs the scope, so a + /// cross-tenant run never leaks). A bare list defaults to `run_id ASC`. The + /// `GET /recognition-runs` read-surface source; out-of-txn on a fresh scoped + /// connection. Mirrors `AdjustmentRepo::list_refunds`. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor + /// (the caller projects it to a canonical 400). + pub async fn list_runs( + &self, + scope: &AccessScope, + tenant: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + // Pre-apply the tenant predicate to the secured select; the user `$filter` + // is applied additively by `paginate_odata` (it never replaces this scope — + // BOLA preserved). + let base_select = recognition_run::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(recognition_run::Column::TenantId.eq(tenant))); + let query = query_with_default_order(query, "run_id"); + paginate_odata::< + RecognitionRunFilterField, + RecognitionRunODataMapper, + recognition_run::Entity, + recognition_run::Model, + _, + _, + >( + base_select, + &conn, + &query, + ("run_id", SortDir::Asc), + LimitCfg { + default: 25, + max: 200, + }, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// Insert a fresh `recognition_run` row in state `RUNNING` (design §4.3): + /// the orchestration wrapper around a [`RecognitionRunner`] pass. The run is + /// **not** itself the at-most-once dedup key (that is the per-segment + /// `RECOGNITION` gate); this row records the run for dedup + the + /// single-active-run guard + audit. The PK `(tenant, period_id, run_id)` makes + /// a duplicate run within one period collide — but the run-service dedups on a + /// prior [`Self::read_run`] before inserting, so a collision here surfaces as + /// [`RepoError::Db`]. A scoped insert (not in a post txn — the run row + /// brackets the runner, it is not part of any single segment's post). + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn insert_run( + &self, + scope: &AccessScope, + tenant: Uuid, + run_id: Uuid, + period_id: &str, + started_at_utc: DateTime, + ) -> Result<(), RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let am = recognition_run::ActiveModel { + tenant_id: Set(tenant), + period_id: Set(period_id.to_owned()), + run_id: Set(run_id), + started_at_utc: Set(started_at_utc), + status: Set(RUN_STATUS_RUNNING.to_owned()), + }; + recognition_run::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("recognition_run scope: {e}")))? + .exec(&conn) + .await + .map_err(|e| RepoError::Db(format!("insert recognition_run: {e}")))?; + Ok(()) + } + + /// Finish a `recognition_run`: flip `RUNNING → DONE` (`done = true`) or + /// `RUNNING → FAILED` (`done = false`) for `(tenant, period_id, run_id)` + /// (design §4.3). The filter carries the FULL 3-column key: + /// `run_id` alone is not unique — a client may reuse one `run_id` across two + /// periods (then BOTH run rows exist), so a `(tenant, run_id)` filter would flip + /// the sibling period's run row too. The filter also requires the current status + /// to be `RUNNING`, so a double-finish matches no row (`rows_affected == 0`) and + /// is a benign no-op (the run already reached a terminal state). A scoped UPDATE. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn finish_run( + &self, + scope: &AccessScope, + tenant: Uuid, + period_id: &str, + run_id: Uuid, + done: bool, + ) -> Result<(), RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let next = if done { + RUN_STATUS_DONE + } else { + RUN_STATUS_FAILED + }; + recognition_run::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr( + recognition_run::Column::Status, + Expr::value(next.to_owned()), + ) + .filter( + Condition::all() + .add(recognition_run::Column::TenantId.eq(tenant)) + .add(recognition_run::Column::PeriodId.eq(period_id)) + .add(recognition_run::Column::RunId.eq(run_id)) + .add(recognition_run::Column::Status.eq(RUN_STATUS_RUNNING)), + ) + .exec(&conn) + .await + .map_err(|e| RepoError::Db(format!("finish recognition_run: {e}")))?; + Ok(()) + } + + // --- Current open period lookup (Group E3 missed-close, design §4.3 E-2) --- + + /// The tenant's current OPEN fiscal period id (`YYYYMM`), or `None` when the + /// tenant has no open period — the **missed-close** target the runner falls + /// back to (design §4.3 E-2): a segment whose own target period is CLOSED + /// posts into the current open period instead (with its original target + /// recorded as audit linkage), never into a closed period. v1 has one legal + /// entity per tenant (the LE = the tenant), so the lookup keys on + /// `(tenant, legal_entity = tenant)`; the **lowest** `period_id` among the + /// tenant's OPEN periods is the current open period (periods open + /// chronologically and close oldest-first, so the earliest still-OPEN one is + /// "current"). Scoped (SQL-level BOLA). + /// + /// This is a minimal lookup the gear lacks otherwise (the foundation's + /// [`FiscalPeriodGuard`](crate::infra::posting::period::FiscalPeriodGuard) + /// only *asserts* a given period is OPEN; it does not *select* the current + /// open one) — flagged for the controller: confirm the "lowest OPEN + /// `period_id` = current" convention against the foundation's period-open job + /// semantics. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn current_open_period( + &self, + scope: &AccessScope, + tenant: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = fiscal_period::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(fiscal_period::Column::TenantId.eq(tenant)) + // v1: one legal entity per tenant (LE = tenant). + .add(fiscal_period::Column::LegalEntityId.eq(tenant)) + .add(fiscal_period::Column::Status.eq(PERIOD_STATUS_OPEN)), + ) + .order_by(fiscal_period::Column::PeriodId, Order::Asc) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("read current open period: {e}")))?; + Ok(row.map(|p| p.period_id)) + } +} + +/// Map a `SecureORM` [`ScopeError`] into a [`DbError`], PRESERVING the inner +/// `sea_orm::DbErr` so a serialization failure (`40001`) raised at a scoped read +/// / write inside the Group H change txn stays retryable via the retry helper's +/// `as_db_err`. Mirrors [`crate::infra::period_close`]'s `scope_to_db`. The +/// scope-validation variants (which never carry a driver error) become a +/// non-retryable `DbError::Other`. Used by the in-txn change helpers +/// (`read_schedule_in_txn`, `mark_schedule_status`, `insert_segments`, …); the +/// `add_recognized` cap path keeps its own [`map_cap_violation`] (it must classify +/// the over-recognition CHECK, not retryability). +fn scope_to_db(e: ScopeError) -> DbError { + match e { + ScopeError::Db(db_err) => DbError::Sea(db_err), + other => DbError::Other(anyhow::anyhow!("scope: {other}")), + } +} + +/// Map a counter-write [`ScopeError`] to [`RepoError`]: a CHECK-constraint +/// violation (the per-obligation over-recognition cap) becomes +/// [`RepoError::MoneyOutCapExceeded`]; anything else stays a plain +/// [`RepoError::Db`]. Mirrors `PaymentRepo::map_cap_violation`. Only +/// `ScopeError::Db` can carry a driver CHECK error; the scope-validation +/// variants never do. +fn map_cap_violation(context: &str, err: &ScopeError) -> RepoError { + if let ScopeError::Db(db_err) = err + && is_check_violation(db_err) + { + return RepoError::MoneyOutCapExceeded(format!("{context}: {err}")); + } + RepoError::Db(format!("{context}: {err}")) +} + +/// Returns `true` iff `err` is a `CHECK`-constraint violation on either +/// supported backend. Replicates `PaymentRepo::is_check_violation` (a private +/// fn in that module) with the recognition constraint-name prefix added: +/// `sea_orm::SqlErr` has no `Check` discriminant, so a real CHECK violation +/// always surfaces unstructured (`sql_err() == None`); a structured error +/// (unique / FK) is therefore never a CHECK and is refused here. The constraint +/// NAME is the most stable signal — the over-recognition cap +/// (`chk_ledger_recognition_schedule_recognized_le_deferred`, prefix +/// `chk_ledger_recognition_schedule_`) is the only recognition constraint whose +/// violation must map to `MoneyOutCapExceeded`, so match it by name first and +/// keep the SQLSTATE-anchored fallbacks (Postgres `23514`, `SQLite` extended +/// code `275`) as a backstop. +fn is_check_violation(err: &DbErr) -> bool { + if err.sql_err().is_some() { + return false; + } + let msg = err.to_string().to_lowercase(); + if msg.contains("chk_ledger_recognition_schedule_") { + return true; + } + msg.contains("check constraint") + || msg.contains("check_violation") + || msg.contains("sqlite_constraint_check") + || msg.contains("sqlstate 23514") + || msg.contains("sqlstate: 23514") + || msg.contains("sqlstate=23514") + || msg.contains("code 23514") + || msg.contains("code: 23514") + || msg.contains("(23514)") + || msg.contains("(23514:") + || msg.starts_with("23514:") + || msg.contains(" 23514:") + || (msg.contains("sqlite") + && (msg.contains("code 275") + || msg.contains("code: 275") + || msg.contains("(275)") + || msg.contains("(275:"))) +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/reconciliation_run_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/reconciliation_run_repo.rs new file mode 100644 index 000000000..baa81b1f0 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/reconciliation_run_repo.rs @@ -0,0 +1,130 @@ +//! `ReconciliationRunRepo` — the reconciliation-run table +//! (`bss.ledger_reconciliation_run`), keyed by `(tenant_id, run_id)`. The +//! framework `start`s a RUNNING row then `finalize`s it with the variance; +//! an out-of-tolerance run feeds an `exception_queue` row + the close gate +//! (Slice 7, design §4.3). + +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use serde_json::Value as JsonValue; +use toolkit_db::secure::{AccessScope, DbTx, SecureEntityExt, SecureInsertExt, SecureUpdateExt}; +use toolkit_db::{DBProvider, DbError}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::RepoError; +use crate::infra::storage::entity::reconciliation_run; + +/// SeaORM-backed reconciliation-run repository. +#[derive(Clone)] +pub struct ReconciliationRunRepo { + db: DBProvider, +} + +impl ReconciliationRunRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// Create a RUNNING reconciliation-run row. + pub async fn start( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + run_id: Uuid, + period_id: &str, + check_type: &str, + ) -> Result<(), RepoError> { + let am = reconciliation_run::ActiveModel { + tenant_id: Set(tenant), + run_id: Set(run_id), + period_id: Set(period_id.to_owned()), + check_type: Set(check_type.to_owned()), + variance_minor: Set(0), + within_tolerance: Set(true), + status: Set("RUNNING".to_owned()), + watermark: Set(None), + detail: Set(None), + at_utc: Set(Utc::now()), + }; + reconciliation_run::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("ledger_reconciliation_run scope: {e}")))? + .exec_with_returning(txn) + .await + .map_err(|e| RepoError::Db(format!("insert ledger_reconciliation_run: {e}")))?; + Ok(()) + } + + /// Finalize a run with its variance result. + #[allow( + clippy::too_many_arguments, + reason = "a finalized run records its full variance result in one write" + )] + pub async fn finalize( + txn: &DbTx<'_>, + scope: &AccessScope, + tenant: Uuid, + run_id: Uuid, + status: &str, + variance_minor: i64, + within_tolerance: bool, + watermark: Option, + detail: Option, + ) -> Result<(), RepoError> { + reconciliation_run::Entity::update_many() + .secure() + .scope_with(scope) + .col_expr(reconciliation_run::Column::Status, Expr::value(status)) + .col_expr( + reconciliation_run::Column::VarianceMinor, + Expr::value(variance_minor), + ) + .col_expr( + reconciliation_run::Column::WithinTolerance, + Expr::value(within_tolerance), + ) + .col_expr( + reconciliation_run::Column::Watermark, + Expr::value(watermark), + ) + .col_expr(reconciliation_run::Column::Detail, Expr::value(detail)) + .filter( + Condition::all() + .add(reconciliation_run::Column::TenantId.eq(tenant)) + .add(reconciliation_run::Column::RunId.eq(run_id)), + ) + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("finalize ledger_reconciliation_run: {e}")))?; + Ok(()) + } + + /// Read a run (out-of-txn). SQL-level BOLA: a foreign tenant yields no row. + pub async fn read( + &self, + scope: &AccessScope, + tenant: Uuid, + run_id: Uuid, + ) -> Result, DomainError> { + let conn = self + .db + .conn() + .map_err(|e| DomainError::Internal(format!("conn: {e}")))?; + let row = reconciliation_run::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(reconciliation_run::Column::TenantId.eq(tenant)) + .add(reconciliation_run::Column::RunId.eq(run_id)), + ) + .one(&conn) + .await + .map_err(|e| DomainError::Internal(format!("read ledger_reconciliation_run: {e}")))?; + Ok(row) + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/reference_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/reference_repo.rs new file mode 100644 index 000000000..e7c647597 --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/reference_repo.rs @@ -0,0 +1,662 @@ +//! `ReferenceRepo` — CRUD over the reference tables a posting consults +//! (currency-scale registry, chart of accounts). Tenant isolation runs +//! through the `SecureORM` layer; P1 reads take an explicit `AccessScope`. + +use sea_orm::sea_query::OnConflict; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::odata::sea_orm_filter::{LimitCfg, paginate_odata}; +use toolkit_db::secure::{ + AccessScope, DbTx, ScopeError, SecureEntityExt, SecureInsertExt, TxConfig, secure_insert, +}; +use toolkit_db::{DBProvider, DbError}; +use toolkit_odata::{ODataQuery, Page, SortDir}; +use uuid::Uuid; + +use crate::domain::model::{ + AccountRow, CurrencyScaleRow, FiscalCalendarRow, FiscalPeriodRow, RepoError, +}; +use crate::domain::money::scale_fits_headroom; +use crate::infra::storage::entity::{ + currency_scale_registry, fiscal_calendar, fiscal_period, journal_line, tenant_account, + tenant_posting_lock, +}; +use crate::infra::storage::odata_mapping::AccountInfoODataMapper; +use crate::infra::storage::repo::journal_repo::{ + OdataPageError, map_odata_err, query_with_default_order, +}; +use crate::odata::AccountInfoFilterField; + +/// Per-endpoint pagination bounds for `GET /bss-ledger/v1/accounts`. The chart +/// of accounts is small per tenant, so the platform-standard default 25 still +/// keeps the common read one round-trip. +const ACCOUNT_LIMIT_CFG: LimitCfg = LimitCfg { + default: 25, + max: 200, +}; + +/// SeaORM-backed reference-data repository. +#[derive(Clone)] +pub struct ReferenceRepo { + db: DBProvider, +} + +impl ReferenceRepo { + #[must_use] + pub fn new(db: DBProvider) -> Self { + Self { db } + } + + /// Insert or update a currency-scale registry row keyed by + /// `(tenant_id, currency)`. Rejects an out-of-headroom scale at + /// registration (`ScaleOutOfRange`) and a changed scale once postings + /// exist for the currency (`CurrencyScaleLocked`, architecture I-1); + /// the same scale is an idempotent no-op. + /// + /// # Errors + /// [`RepoError::ScaleOutOfRange`] / [`RepoError::CurrencyScaleLocked`] + /// per the guards above, or [`RepoError::Db`] on a storage failure. + pub async fn upsert_currency_scale(&self, row: CurrencyScaleRow) -> Result<(), RepoError> { + if !scale_fits_headroom(row.minor_units, row.plausible_max_major) { + return Err(RepoError::ScaleOutOfRange(row.currency)); + } + // ONE `SERIALIZABLE` transaction so the scale-immutability check-then-act + // is atomic: the `posted?` probe and a concurrent first posting for the + // currency form a read-write conflict under Postgres SSI, so a post that + // lands between the probe and the upsert aborts the loser — a changed + // scale can never be committed once postings exist. `Locked` carries the + // rejection out on the commit path (it writes nothing, so committing is a + // harmless no-op), avoiding a sentinel round-trip. + let currency = row.currency.clone(); + let result: Result = self + .db + .db() + .transaction_with_retry(TxConfig::serializable(), as_db_err, move |txn| { + let row = row.clone(); + Box::pin(async move { upsert_currency_scale_in_txn(txn, row).await }) + }) + .await; + match result { + Ok(ScaleUpsertResult::Done) => Ok(()), + Ok(ScaleUpsertResult::Locked) => Err(RepoError::CurrencyScaleLocked(currency)), + Err(db_err) => Err(RepoError::Db(format!( + "upsert currency_scale txn: {db_err}" + ))), + } + } + + /// Read a currency-scale row under the supplied scope. + pub async fn find_currency_scale( + &self, + scope: &AccessScope, + tenant_id: Uuid, + currency: &str, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = currency_scale_registry::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(currency_scale_registry::Column::TenantId.eq(tenant_id)) + .add(currency_scale_registry::Column::Currency.eq(currency)), + ) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("find currency_scale: {e}")))?; + Ok(row.map(|m| CurrencyScaleRow { + tenant_id: m.tenant_id, + currency: m.currency, + minor_units: m.minor_units, + plausible_max_major: m.plausible_max_major, + source: m.source, + })) + } + + /// Insert a chart-of-accounts row. + pub async fn insert_account(&self, row: AccountRow) -> Result<(), RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let scope = AccessScope::for_tenant(row.tenant_id); + + let am = tenant_account::ActiveModel { + account_id: Set(row.account_id), + tenant_id: Set(row.tenant_id), + legal_entity_id: Set(row.legal_entity_id), + account_class: Set(row.account_class), + currency: Set(row.currency), + revenue_stream: Set(row.revenue_stream), + normal_side: Set(row.normal_side), + may_go_negative: Set(row.may_go_negative), + lifecycle_state: Set(row.lifecycle_state), + }; + + secure_insert::(am, &scope, &conn) + .await + .map_err(|e| RepoError::Db(format!("insert tenant_account: {e}")))?; + Ok(()) + } + + /// Read a chart-of-accounts row by id under the supplied scope. + /// Whether the tenant's posting kill-switch (`tenant_posting_lock`) is + /// currently held (design §3.2 PostingService pre-transaction gate). A + /// missing row means never locked (the table is written only when a lock is + /// set / cleared), so absence reads as `false`. Read on its own connection + /// BEFORE the post transaction, mirroring the account-lifecycle pre-check: + /// tolerable that a lock set CONCURRENTLY (after this read, before COMMIT) is + /// not caught, since termination is a rare admin op. + /// + /// # Errors + /// [`RepoError::Db`] on a connection or query failure. + pub async fn is_tenant_posting_locked( + &self, + scope: &AccessScope, + tenant_id: Uuid, + ) -> Result { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = tenant_posting_lock::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(tenant_posting_lock::Column::TenantId.eq(tenant_id))) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("find tenant_posting_lock: {e}")))?; + Ok(row.is_some_and(|m| m.locked)) + } + + pub async fn find_account( + &self, + scope: &AccessScope, + account_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = tenant_account::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(tenant_account::Column::AccountId.eq(account_id))) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("find tenant_account: {e}")))?; + Ok(row.map(|m| AccountRow { + account_id: m.account_id, + tenant_id: m.tenant_id, + legal_entity_id: m.legal_entity_id, + account_class: m.account_class, + currency: m.currency, + revenue_stream: m.revenue_stream, + normal_side: m.normal_side, + may_go_negative: m.may_go_negative, + lifecycle_state: m.lifecycle_state, + })) + } + + /// Full chart-of-accounts read for the tenant — **NOT paginated**. The + /// invoice-post chart resolver needs every account to bind line ids, so it + /// bypasses the `OData` page of the REST `list_accounts` (a page would + /// truncate a chart with many per-stream Revenue accounts). SecureORM-scoped + /// (BOLA): a foreign tenant yields an empty `Vec`. + /// + /// # Errors + /// [`RepoError::Db`] on a storage / connection failure. + pub async fn all_accounts( + &self, + scope: &AccessScope, + tenant_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + tenant_account::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(tenant_account::Column::TenantId.eq(tenant_id))) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("all_accounts: {e}"))) + } + + /// List the chart-of-accounts rows for a tenant under `scope`, cursor- + /// paginated via the canonical `query` (`$filter` over `account_class` / + /// `currency` / `revenue_stream` / `lifecycle_state`, `$orderby` / `limit` / + /// `cursor`). The tenant predicate is pre-applied to the secured select; the + /// user `$filter` is **additive over** it (SQL-level BOLA — a tenant outside + /// the caller's scope yields an empty page, and a foreign filter value still + /// ANDs the scope). A bare list defaults to `account_id ASC`. Returns the + /// raw `tenant_account` model page; the caller projects each row to the SDK + /// `AccountInfo`. + /// + /// # Errors + /// [`OdataPageError::Db`] on a storage / connection failure; + /// [`OdataPageError::Odata`] on a malformed `$filter` / `$orderby` / cursor. + pub async fn list_accounts( + &self, + scope: &AccessScope, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, OdataPageError> { + let conn = self + .db + .conn() + .map_err(|e| OdataPageError::Db(format!("conn: {e}")))?; + + let base_select = tenant_account::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(tenant_account::Column::TenantId.eq(tenant_id))); + + let query = query_with_default_order(query, "account_id"); + paginate_odata::< + AccountInfoFilterField, + AccountInfoODataMapper, + tenant_account::Entity, + tenant_account::Model, + _, + _, + >( + base_select, + &conn, + &query, + ("account_id", SortDir::Asc), + ACCOUNT_LIMIT_CFG, + |m| m, + ) + .await + .map_err(map_odata_err) + } + + /// List ALL fiscal-calendar rows across every tenant — a **cross-tenant + /// system read** for the period-open job. Uses the secure layer with + /// [`AccessScope::allow_all`] (the sanctioned all-tenants system scope, + /// same as AM's reaper/lease paths), NOT a per-request tenant scope; the + /// per-calendar period insert is then scoped by + /// `insert_fiscal_period_if_absent_txn`. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn list_all_fiscal_calendars(&self) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let rows = fiscal_calendar::Entity::find() + .secure() + .scope_with(&AccessScope::allow_all()) + .all(&conn) + .await + .map_err(|e| RepoError::Db(format!("list fiscal_calendar: {e}")))?; + Ok(rows + .into_iter() + .map(|m| FiscalCalendarRow { + tenant_id: m.tenant_id, + legal_entity_id: m.legal_entity_id, + fiscal_tz: m.fiscal_tz, + granularity: m.granularity, + fy_start_month: m.fy_start_month, + functional_currency: m.functional_currency, + }) + .collect()) + } + + /// The tenant's legal-entity functional currency (S5-F3), or `None` when the + /// tenant has no fiscal calendar, or its calendar carries no functional + /// currency (a single-currency tenant — the `RateLocker` then short-circuits). + /// v1 assumes one legal entity per tenant (decision 5): the tenant's single + /// calendar row carries the functional currency; a multi-LE tenant (deferred) + /// needs the per-`legal_entity_id` lookup. Reference data, tenant-axis scope. + /// + /// # Errors + /// [`RepoError::Db`] on a scope or storage failure. + pub async fn functional_currency( + &self, + scope: &AccessScope, + tenant_id: Uuid, + ) -> Result, RepoError> { + let conn = self + .db + .conn() + .map_err(|e| RepoError::Db(format!("conn: {e}")))?; + let row = fiscal_calendar::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(fiscal_calendar::Column::TenantId.eq(tenant_id))) + .one(&conn) + .await + .map_err(|e| RepoError::Db(format!("find fiscal_calendar functional_currency: {e}")))?; + Ok(row.and_then(|m| m.functional_currency)) + } + + // --- Additive seed methods (run inside one provisioning transaction) --- + // + // Each takes the active `DbTx` so reads + inserts share the one txn (no + // fresh `self.db.conn()` — that would open a new connection and trip the + // in-tx guard). Idempotency is per-row SELECT-then-INSERT keyed on the + // natural key; existing rows are left untouched (no `on_conflict`). + + /// Find a chart-of-accounts row by its coordinate key under `scope`, + /// running on the supplied transaction. Returns the `account_id` if a + /// matching row exists. The `revenue_stream` arm matches the + /// `COALESCE(revenue_stream,'-')` unique-index semantics: `Some(s)` → + /// equality, `None` → `IS NULL`. + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + // The CoA natural key is wide (tenant + legal-entity + class + currency + + // revenue-stream); passing it positionally mirrors the unique index columns. + #[allow(clippy::too_many_arguments)] + pub async fn find_account_by_key_txn( + &self, + txn: &DbTx<'_>, + scope: &AccessScope, + tenant_id: Uuid, + legal_entity_id: Uuid, + account_class: &str, + currency: &str, + revenue_stream: Option<&str>, + ) -> Result, RepoError> { + let mut condition = Condition::all() + .add(tenant_account::Column::TenantId.eq(tenant_id)) + .add(tenant_account::Column::LegalEntityId.eq(legal_entity_id)) + .add(tenant_account::Column::AccountClass.eq(account_class)) + .add(tenant_account::Column::Currency.eq(currency)); + condition = match revenue_stream { + Some(s) => condition.add(tenant_account::Column::RevenueStream.eq(s)), + None => condition.add(tenant_account::Column::RevenueStream.is_null()), + }; + let row = tenant_account::Entity::find() + .secure() + .scope_with(scope) + .filter(condition) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("find tenant_account by key: {e}")))?; + Ok(row.map(|m| m.account_id)) + } + + /// Insert a chart-of-accounts row if absent (keyed on its coordinate), + /// running on the supplied transaction. Returns the persistent `account_id` + /// (the existing row's when present, else the freshly inserted one) and + /// `true` when a new row was inserted (`false` = already existed, no-op). + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn insert_account_if_absent_txn( + &self, + txn: &DbTx<'_>, + row: AccountRow, + ) -> Result<(Uuid, bool), RepoError> { + let scope = AccessScope::for_tenant(row.tenant_id); + if let Some(existing_id) = self + .find_account_by_key_txn( + txn, + &scope, + row.tenant_id, + row.legal_entity_id, + &row.account_class, + &row.currency, + row.revenue_stream.as_deref(), + ) + .await? + { + return Ok((existing_id, false)); + } + + let new_id = row.account_id; + let am = tenant_account::ActiveModel { + account_id: Set(row.account_id), + tenant_id: Set(row.tenant_id), + legal_entity_id: Set(row.legal_entity_id), + account_class: Set(row.account_class), + currency: Set(row.currency), + revenue_stream: Set(row.revenue_stream), + normal_side: Set(row.normal_side), + may_go_negative: Set(row.may_go_negative), + lifecycle_state: Set(row.lifecycle_state), + }; + secure_insert::(am, &scope, txn) + .await + .map_err(|e| RepoError::Db(format!("insert tenant_account: {e}")))?; + Ok((new_id, true)) + } + + /// Insert a currency-scale registry row if absent (keyed on + /// `(tenant_id, currency)`), running on the supplied transaction. Validates + /// headroom before any write. Returns `true` when a new row was inserted, + /// `false` when one already existed (no-op — the existing scale is left + /// untouched; scale-immutability is `upsert_currency_scale`'s concern). + /// + /// # Errors + /// [`RepoError::ScaleOutOfRange`] when the scale exceeds `i64` headroom, or + /// [`RepoError::Db`] on a storage failure. + pub async fn insert_currency_scale_if_absent_txn( + &self, + txn: &DbTx<'_>, + row: CurrencyScaleRow, + ) -> Result { + if !scale_fits_headroom(row.minor_units, row.plausible_max_major) { + return Err(RepoError::ScaleOutOfRange(row.currency)); + } + let scope = AccessScope::for_tenant(row.tenant_id); + let existing = currency_scale_registry::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(currency_scale_registry::Column::TenantId.eq(row.tenant_id)) + .add(currency_scale_registry::Column::Currency.eq(row.currency.clone())), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("find currency_scale: {e}")))?; + if existing.is_some() { + return Ok(false); + } + + let am = currency_scale_registry::ActiveModel { + tenant_id: Set(row.tenant_id), + currency: Set(row.currency), + minor_units: Set(row.minor_units), + plausible_max_major: Set(row.plausible_max_major), + source: Set(row.source), + }; + currency_scale_registry::Entity::insert(am.clone()) + .secure() + .scope_with_model(&scope, &am) + .map_err(|e| RepoError::Db(format!("insert currency_scale scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert currency_scale: {e}")))?; + Ok(true) + } + + /// Insert a fiscal-calendar config row if absent (keyed on + /// `(tenant_id, legal_entity_id)`), running on the supplied transaction. + /// Returns `true` when a new row was inserted, `false` when one already + /// existed (no-op — additive). + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn upsert_fiscal_calendar_if_absent_txn( + &self, + txn: &DbTx<'_>, + row: FiscalCalendarRow, + ) -> Result { + let scope = AccessScope::for_tenant(row.tenant_id); + let existing = fiscal_calendar::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(fiscal_calendar::Column::TenantId.eq(row.tenant_id)) + .add(fiscal_calendar::Column::LegalEntityId.eq(row.legal_entity_id)), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("find fiscal_calendar: {e}")))?; + if existing.is_some() { + return Ok(false); + } + + let am = fiscal_calendar::ActiveModel { + tenant_id: Set(row.tenant_id), + legal_entity_id: Set(row.legal_entity_id), + fiscal_tz: Set(row.fiscal_tz), + granularity: Set(row.granularity), + fy_start_month: Set(row.fy_start_month), + functional_currency: Set(row.functional_currency), + }; + fiscal_calendar::Entity::insert(am.clone()) + .secure() + .scope_with_model(&scope, &am) + .map_err(|e| RepoError::Db(format!("insert fiscal_calendar scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert fiscal_calendar: {e}")))?; + Ok(true) + } + + /// Insert a fiscal-period row if absent (keyed on + /// `(tenant_id, legal_entity_id, period_id)`), running on the supplied + /// transaction. Returns `true` when a new row was inserted, `false` when + /// one already existed (no-op — additive). + /// + /// # Errors + /// [`RepoError::Db`] on a storage failure. + pub async fn insert_fiscal_period_if_absent_txn( + &self, + txn: &DbTx<'_>, + row: FiscalPeriodRow, + ) -> Result { + let scope = AccessScope::for_tenant(row.tenant_id); + let existing = fiscal_period::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(fiscal_period::Column::TenantId.eq(row.tenant_id)) + .add(fiscal_period::Column::LegalEntityId.eq(row.legal_entity_id)) + .add(fiscal_period::Column::PeriodId.eq(row.period_id.clone())), + ) + .one(txn) + .await + .map_err(|e| RepoError::Db(format!("find fiscal_period: {e}")))?; + if existing.is_some() { + return Ok(false); + } + + let am = fiscal_period::ActiveModel { + tenant_id: Set(row.tenant_id), + legal_entity_id: Set(row.legal_entity_id), + period_id: Set(row.period_id), + fiscal_tz: Set(row.fiscal_tz), + status: Set(row.status), + }; + fiscal_period::Entity::insert(am.clone()) + .secure() + .scope_with_model(&scope, &am) + .map_err(|e| RepoError::Db(format!("insert fiscal_period scope: {e}")))? + .exec(txn) + .await + .map_err(|e| RepoError::Db(format!("insert fiscal_period: {e}")))?; + Ok(true) + } +} + +/// Carries the scale-upsert decision out of the `SERIALIZABLE` body on the +/// commit path: `Locked` writes nothing, so the transaction commits harmlessly. +enum ScaleUpsertResult { + Done, + Locked, +} + +/// In-transaction body of [`ReferenceRepo::upsert_currency_scale`]: re-read the +/// existing scale, probe for postings on a changed scale, then upsert — all in +/// the one serializable snapshot so a concurrent first posting cannot slip +/// between the probe and the write. +async fn upsert_currency_scale_in_txn( + txn: &DbTx<'_>, + row: CurrencyScaleRow, +) -> Result { + let scope = AccessScope::for_tenant(row.tenant_id); + let existing = currency_scale_registry::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(currency_scale_registry::Column::TenantId.eq(row.tenant_id)) + .add(currency_scale_registry::Column::Currency.eq(row.currency.clone())), + ) + .one(txn) + .await + .map_err(scope_to_db)?; + + if let Some(existing) = existing + && existing.minor_units != row.minor_units + { + let posted = journal_line::Entity::find() + .secure() + .scope_with(&scope) + .filter( + Condition::all() + .add(journal_line::Column::TenantId.eq(row.tenant_id)) + .add(journal_line::Column::Currency.eq(row.currency.clone())), + ) + .one(txn) + .await + .map_err(scope_to_db)?; + if posted.is_some() { + return Ok(ScaleUpsertResult::Locked); + } + } + + let am = currency_scale_registry::ActiveModel { + tenant_id: Set(row.tenant_id), + currency: Set(row.currency.clone()), + minor_units: Set(row.minor_units), + plausible_max_major: Set(row.plausible_max_major), + source: Set(row.source.clone()), + }; + let on_conflict = OnConflict::columns([ + currency_scale_registry::Column::TenantId, + currency_scale_registry::Column::Currency, + ]) + .update_columns([ + currency_scale_registry::Column::MinorUnits, + currency_scale_registry::Column::PlausibleMaxMajor, + currency_scale_registry::Column::Source, + ]) + .to_owned(); + currency_scale_registry::Entity::insert(am.clone()) + .secure() + .scope_with_model(&scope, &am) + .map_err(scope_to_db)? + .on_conflict_raw(on_conflict) + .exec(txn) + .await + .map_err(scope_to_db)?; + Ok(ScaleUpsertResult::Done) +} + +fn as_db_err(e: &DbError) -> Option<&sea_orm::DbErr> { + match e { + DbError::Sea(db_err) => Some(db_err), + _ => None, + } +} + +fn scope_to_db(e: ScopeError) -> DbError { + match e { + ScopeError::Db(db_err) => DbError::Sea(db_err), + other => DbError::Other(anyhow::anyhow!("scope: {other}")), + } +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/repo/verified_balance_repo.rs b/gears/bss/ledger/ledger/src/infra/storage/repo/verified_balance_repo.rs new file mode 100644 index 000000000..ed00b41cd --- /dev/null +++ b/gears/bss/ledger/ledger/src/infra/storage/repo/verified_balance_repo.rs @@ -0,0 +1,121 @@ +//! Repository for the cumulative tie-out baseline (`ledger_verified_balance`, +//! VHP-1843). Two operations, both generic over `DBRunner` so they run inside +//! the caller's transaction: `snapshot` (write the current cache as the new +//! baseline, in the period-close txn) and `load_baseline` (read it back for the +//! incremental tie-out, in the daily / recon path). Tenant-scoped via +//! `SecureORM` (SQL-level BOLA). The caches the baseline mirrors are upsert-only +//! (never deleted), so the baseline mirrors them upsert-only too — a grain that +//! has appeared once stays in the baseline. + +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait}; +use toolkit_db::secure::{ + AccessScope, DBRunner, SecureEntityExt, SecureInsertExt, SecureOnConflict, +}; +use uuid::Uuid; + +use crate::domain::model::RepoError; +use crate::infra::storage::entity::verified_balance; + +/// One grain instance's verified balance to snapshot — an ABSOLUTE total (not a +/// delta): the cumulative value of the cache row at close time. +#[derive(Clone, Debug)] +pub struct BaselineRow { + /// One of the `verified_balance::GRAIN_*` discriminators. + pub grain: String, + /// Canonical per-instance key the tie-out fold produces for this grain. + pub grain_key: String, + /// Absolute cumulative balance, minor units. + pub balance_minor: i64, +} + +/// `SeaORM`-backed verified-baseline repository. Stateless: every method takes +/// the caller's `runner` (a txn or a scoped connection). +pub struct VerifiedBalanceRepo; + +impl VerifiedBalanceRepo { + /// Load the cumulative baseline for `tenant` (all grains). Empty before the + /// tenant's first period close — the caller then falls back to the full + /// fold. SQL-level BOLA: a foreign tenant yields no rows. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure. + pub async fn load_baseline( + runner: &R, + scope: &AccessScope, + tenant: Uuid, + ) -> Result, RepoError> { + verified_balance::Entity::find() + .secure() + .scope_with(scope) + .filter(Condition::all().add(verified_balance::Column::TenantId.eq(tenant))) + .all(runner) + .await + .map_err(|e| RepoError::Db(format!("load verified_balance: {e}"))) + } + + /// Snapshot the current cache as the new baseline for `tenant`, verified + /// `through_period` at `watermark_seq`. Each row is upserted to its ABSOLUTE + /// value (the close txn has just proven the cache via a clean full tie-out). + /// Idempotent on close re-entry; rolls back with the close txn on abort. + /// + /// # Errors + /// [`RepoError::Db`] on a scope / storage failure. + pub async fn snapshot( + runner: &R, + scope: &AccessScope, + tenant: Uuid, + through_period: &str, + watermark_seq: i64, + rows: &[BaselineRow], + ) -> Result<(), RepoError> { + let now = Utc::now(); + for row in rows { + let am = verified_balance::ActiveModel { + tenant_id: Set(tenant), + grain: Set(row.grain.clone()), + grain_key: Set(row.grain_key.clone()), + verified_balance_minor: Set(row.balance_minor), + through_period: Set(through_period.to_owned()), + watermark_seq: Set(watermark_seq), + updated_at_utc: Set(now), + }; + // Set absolute (NOT add): the snapshot replaces the prior baseline + // value for this grain with the freshly-verified cumulative total. + let on_conflict = SecureOnConflict::::columns([ + verified_balance::Column::TenantId, + verified_balance::Column::Grain, + verified_balance::Column::GrainKey, + ]) + .value( + verified_balance::Column::VerifiedBalanceMinor, + Expr::value(row.balance_minor), + ) + .and_then(|oc| { + oc.value( + verified_balance::Column::ThroughPeriod, + Expr::value(through_period.to_owned()), + ) + }) + .and_then(|oc| { + oc.value( + verified_balance::Column::WatermarkSeq, + Expr::value(watermark_seq), + ) + }) + .and_then(|oc| oc.value(verified_balance::Column::UpdatedAtUtc, Expr::value(now))) + .map_err(|e| RepoError::Db(format!("verified_balance on_conflict: {e}")))?; + + verified_balance::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| RepoError::Db(format!("verified_balance scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(runner) + .await + .map_err(|e| RepoError::Db(format!("verified_balance upsert: {e}")))?; + } + Ok(()) + } +} diff --git a/gears/bss/ledger/ledger/src/lib.rs b/gears/bss/ledger/ledger/src/lib.rs new file mode 100644 index 000000000..775e8596f --- /dev/null +++ b/gears/bss/ledger/ledger/src/lib.rs @@ -0,0 +1,49 @@ +//! BSS Billing Ledger gear. + +// Tracked lint-debt (CI green without risky refactors of financial logic). +// The workspace lint bar (`pedantic` + many restriction lints, all `deny`) +// flags a set of lints in this crate that are either DOMAIN-INHERENT or purely +// STYLISTIC, none behavioural: +// * `integer_division` — deliberate minor-unit integer math with explicit, +// designed residual handling (banker's rounding / residual-to-last-segment); +// * `unused_async` — publisher methods stay `async` to match the future +// broker-wired signature (the event layer is parked, so they have no +// `.await` yet); +// * `cognitive_complexity` — several posting/recognition/FX functions exceed +// the threshold; refactoring financial logic purely to satisfy the metric +// is deferred (correctness-risk not worth the churn); +// * `non_ascii_literal` — `→` / `‖` / `×` / `≥` symbols in messages & doc +// examples; `redundant_pub_crate`, `doc_markdown`, `too_many_arguments`, +// `needless_pass_by_value`, `redundant_clone` — cosmetic. +// Safety-relevant lints (cast_*, float_cmp, await_holding_*, unwrap/expect, …) +// remain ENFORCED from the workspace table. Pay this down incrementally. +#![allow( + clippy::integer_division, + clippy::unused_async, + clippy::cognitive_complexity, + clippy::non_ascii_literal, + clippy::redundant_pub_crate, + clippy::doc_markdown, + clippy::too_many_arguments, + clippy::needless_pass_by_value, + clippy::redundant_clone, + clippy::let_underscore_must_use +)] + +#[doc(hidden)] +pub mod api; +#[doc(hidden)] +pub mod authz; +#[doc(hidden)] +pub mod config; +#[doc(hidden)] +pub mod domain; +#[doc(hidden)] +pub mod gts; +#[doc(hidden)] +pub mod infra; +#[doc(hidden)] +pub mod module; +/// `OData` filter-field enums for the row-collection list endpoints +/// (`accounts` / `journal-lines` / `balances`). +pub(crate) mod odata; diff --git a/gears/bss/ledger/ledger/src/module.rs b/gears/bss/ledger/ledger/src/module.rs new file mode 100644 index 000000000..fcacbde81 --- /dev/null +++ b/gears/bss/ledger/ledger/src/module.rs @@ -0,0 +1,1562 @@ +//! `BssLedgerGear` — toolkit gear declaration and lifecycle. +//! +//! P0/P1 declared the `db` capability so migrations run at startup and create +//! the `bss` schema + foundation tables. P3 makes `init()` non-trivial: when +//! the `bss-ledger:` entry is present in the `gears:` block it acquires the +//! DB handle and publishes the in-process posting client +//! (`dyn LedgerClientV1`) in `ClientHub`. P6 adds the `stateful` +//! capability and a `lifecycle(entry = "serve")` background loop that runs the +//! daily tie-out, fiscal-period-open, and chain-verifier jobs under a +//! cancellation token. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use arc_swap::ArcSwapOption; +use async_trait::async_trait; +use axum::Router; +use bss_ledger_sdk::api::LedgerClientV1; +use bss_ledger_sdk::{ + BillRunFinishedV1, IssuedInvoiceManifestV1, PspSettlementFeedV1, RateProviderV1, + UnconfiguredRateProviderV1, +}; +use sea_orm_migration::{MigrationTrait, MigratorTrait}; +use tokio_util::sync::CancellationToken; +use toolkit::api::OpenApiRegistry; +use toolkit::config::ConfigError; +use toolkit::contracts::{DatabaseCapability, RestApiCapability}; +use toolkit::{Gear, GearCtx}; +use toolkit_db::{DBProvider, DbError}; +use tracing::{info, warn}; + +use crate::api::local_client::LedgerLocalClient; +use crate::config::BssLedgerConfig; +use crate::domain::ports::metrics::LedgerMetricsPort; +use crate::infra::events::publisher::LedgerEventPublisher; +use crate::infra::metrics::LedgerMetricsMeter; +use crate::infra::storage::migrations::Migrator; + +/// Typed bundle of the per-process state that `init()` builds and the +/// lifecycle reads: `register_rest()` reads `provisioning`; `serve()` reads +/// `db`, `publisher`, and the three tick cadences to drive the background jobs. +pub(crate) struct LedgerRuntime { + /// REST provisioning state (the provisioning route reuses the same + /// in-process client built in `init()`). + pub provisioning: Arc, + /// REST journal-entry / balance state: the same in-process client (reads) + /// plus the `InvoicePostService` write orchestrator (post / reversal / + /// mapping-correction), built once in `init()`. + pub journal: Arc, + /// REST payment state (settle / allocate / list-allocations / + /// read-unallocated): the same in-process client, which gates the PEP and + /// orchestrates the money-in / money-out posts. + pub payments: Arc, + /// REST credit-application state (grant / apply reusable credit): the same + /// in-process client, which gates the PEP and orchestrates the wallet posts. + pub credit: Arc, + /// REST chargeback dispute state (record a dispute phase): the same + /// in-process client, which gates the PEP and orchestrates the dispute posts. + pub disputes: Arc, + /// REST recognition-run state (trigger an ASC 606 S6 release for a period): + /// the same in-process client, which gates the PEP and orchestrates the run. + pub recognition: Arc, + /// REST adjustment state (Slice 3): the concrete credit-note / debit-note + /// orchestrators + the adjustment repo behind `POST /credit-notes`, + /// `POST /debit-notes`, and `GET /invoices/{id}/exposure`. + pub adjustments: Arc, + /// REST dual-control approval-queue state (VHP-1852): the `ApprovalService` + /// lifecycle engine behind the approve / reject / rework / comment surface. + pub approvals: Arc, + /// REST tenant invoice-posting-policy state (VHP-1853): the missing-mapping + /// mode + AR-aging bucket thresholds write / read surface. + pub posting_policy: Arc, + /// REST per-tenant FX revaluation-mode state (VHP-1986): the Mode A/B + /// write / read config surface. + pub fx_revaluation_mode: Arc, + /// The dual-control engine itself (Group G): threaded into the queue-applier + /// sweep so the refund de-quarantine drain gates over the THEN-CURRENT D2 + /// threshold (it never auto-posts an over-threshold released refund). + pub approval: Arc, + /// REST refund state (Slice 3 Group G): the gated `RefundHandler` (+ composite + /// credit-note handler) + the adjustment repo behind `POST /refunds`, + /// `POST /refund-with-credit-note`, and `GET /refunds/{refundId}`. + pub refunds: Arc, + /// REST payer-closure state (VHP-1852 Phase 2): the dual-control engine + the + /// payer lifecycle repo behind `POST /payers/{id}/close`. + pub payers: Arc, + /// REST fiscal-period-closure state (Slice 7 Group C): the in-process client + /// (gated close + `period.closed` emit) + the dual-control engine (reopen) + /// behind `POST /legal-entities/{le}/periods/{period}/closure`. + pub closure: Arc, + /// REST exception-queue state (Slice 7 Phase 2): the scoped queue repo behind + /// `GET /exceptions` + `POST /exceptions/{id}/resolution`. + pub exceptions: Arc, + /// The exception router (Slice 7 Phase 2): held so the `serve()` aged-alarm job + /// can route `STUCK_REFUND_CLEARING` into the durable close-blocking queue. + pub exception_router: Arc, + /// REST reconciliation state (Slice 7 Phase 3): the `ReconciliationFramework` + /// (the `POST /reconciliation-runs` trigger — also driven by the `serve()` ticker + /// via `reconciliation.framework`) + the run read repo (`GET /reconciliation-runs/{id}`). + pub reconciliation: Arc, + /// REST control-feed ingest state (Slice 7 Phase 3): the in-process control store + /// the `POST …/control/*` endpoints push into (shared with the framework + the + /// close gate's pre-close manifest / bill-run reads). + pub control: Arc, + /// REST audit-retrieval state (Group 2C): the scoped audit reader + the + /// cross-tenant elevation gateway, built once in `init()`. + pub audit: Arc, + /// REST FX state (Slice 5): the FX rate store behind `POST /fx/rates` (the + /// secondary seed ingest) + `GET /fx/rate-snapshots/{id}` (immutable snapshot + /// read). + pub fx: Arc, + /// Platform PEP, built in `init()` from the `authz-resolver` `ClientHub` + /// client and cloned into every request as an `Extension` by + /// `register_rest`; also threaded into the in-process client. Authz is + /// security-critical, so a missing client fails init (no no-op fallback). + pub enforcer: Arc, + /// Database provider for the system-context background jobs (cross-tenant + /// raw SQL / unscoped reads — NOT the per-request `SecureORM` scope). + pub db: DBProvider, + /// Event publisher, used out-of-band by the tie-out job to emit invariant + /// alarms on a separate connection. + pub publisher: Arc, + /// Process-global `OTel` metrics handle (`infra::metrics`), built in `init()` + /// and shared by the in-process client, the publisher's alarm mirror, and the + /// queued-allocation sweep (the queue-depth gauge + the apply posts). + pub metrics: Arc, + /// Tie-out tick cadence. + pub tie_out_tick: Duration, + /// How often the daily tie-out folds the full all-time history instead of the + /// incremental (baseline + open-period) path — every `N`th tick (VHP-1843). + pub tie_out_full_every_n: u64, + /// Fiscal-period-open tick cadence. + pub period_open_tick: Duration, + /// Queued-allocation sweep tick cadence (the deferred-apply backstop). + pub queue_applier_tick: Duration, + /// Aged-alarm tick cadence (the §6 `Warn`-severity aged-work scan). + pub aged_alarm_tick: Duration, + /// Recognition-run tick cadence (the Slice 4 §4.3 S6 release backstop). + pub recognition_run_tick: Duration, + /// Chain-verifier tick cadence. + pub verify_tick: Duration, + /// FX rate-provider plugin (Slice 5) resolved from `ClientHub` — the + /// fail-safe `UnconfiguredRateProviderV1` default when no adapter-gear is + /// registered. The `RateSyncJob` pulls `fetch_latest` from it each + /// `fx_rate_sync_tick` into the local rate store. + pub rate_provider: Arc, + /// FX rate store (the `RateSyncJob` upsert target / the lock-time + /// `RateSource` read target). + pub fx_repo: crate::infra::storage::repo::FxRepo, + /// FX rate-sync tick cadence. + pub fx_rate_sync_tick: Duration, + /// FX config (the Mode-B `revaluation_enabled` gate + rate source) for the + /// `RevaluationRunJob`. + pub fx_config: crate::config::FxConfig, + /// Payments config (the per-allocation touched-invoice cap) — threaded into + /// the `QueueApplierJob` so the deferred-apply drain honours the same cap as + /// the inline allocate path. + pub payments_config: crate::config::PaymentsConfig, + /// Unrealized-revaluation tick cadence (Slice 5 Phase 3 Mode-B run). + pub revaluation_run_tick: Duration, + /// Reconciliation-job tick cadence (Slice 7 Phase 3 near-real-time recon pass: + /// invoice-completeness + AR↔derived + Payments↔PSP). + pub recon_tick: Duration, +} + +// NOTE: `event-broker` is intentionally NOT a declared dep — the event layer is +// parked (no `event-broker-sdk` in this repo; `build_event_publisher` builds a +// broker-free no-op/metrics-only publisher). Declaring it would make the gear +// unschedulable (the orchestrator can't satisfy a dep no gear provides). Re-add +// it here once the broker gear exists and `events_enabled` is wired to it. +#[toolkit::gear(name = "bss-ledger", capabilities = [db, rest, stateful], deps = ["types-registry", "authz-resolver", "account-management"], lifecycle(entry = "serve", stop_timeout = "30s"))] +pub struct BssLedgerGear { + /// Typed runtime built inside [`Gear::init`] and consumed by + /// [`RestApiCapability::register_rest`] and [`BssLedgerGear::serve`]. + /// `None` when `init()` has not completed or short-circuited on a + /// default-disabled boot. + runtime: ArcSwapOption, +} + +impl Default for BssLedgerGear { + fn default() -> Self { + Self { + runtime: ArcSwapOption::from(None), + } + } +} + +impl BssLedgerGear { + /// Lifecycle entry (`stateful` capability). Spawns the daily tie-out, + /// fiscal-period-open, and chain-verifier tickers under a child of `cancel`. + /// A tick failure is logged and the loop continues (a transient job error + /// must not kill the gear); a panicking task cancels the others so the + /// runtime sees an abort. + /// + /// No `await_ready`: job readiness must NOT gate request traffic, so the + /// signature omits the `ReadySignal` argument. + /// + /// # Errors + /// Returns `Err` only if a spawned ticker task panics / aborts (surfaced + /// as a join error); cooperative cancel-token shutdown returns `Ok(())`. + /// Spawn the recognition-run ticker (the Slice 4 S6 release backstop): a + /// cancellable loop that drives a `RecognitionRunJob` pass every + /// `recognition_run_tick`. Kept out-of-line (its shape mirrors the peer + /// tickers) so `serve` stays within the line budget. + fn spawn_recognition_ticker( + rt: Arc, + token: CancellationToken, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut iv = tokio::time::interval(rt.recognition_run_tick); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + biased; + () = token.cancelled() => break, + _ = iv.tick() => { + let job = crate::infra::jobs::recognition_run::RecognitionRunJob::new( + rt.db.clone(), + Arc::clone(&rt.publisher), + Arc::clone(&rt.metrics), + ); + if let Err(e) = job.run().await { + tracing::error!(error = %e, "bss-ledger: recognition-run job tick failed"); + } + } + } + } + }) + } + + /// Spawn the FX rate-sync ticker (Slice 5 §4.6): a cancellable loop that + /// drives a `RateSyncJob` pass every `fx_rate_sync_tick`, pulling the + /// configured `RateProviderV1` plugin's latest rates into the local store. + /// Extracted (its shape mirrors the peer tickers) so `serve` stays within the + /// line budget. With the default `UnconfiguredRateProviderV1` the pass is inert + /// (logs at debug + returns, no alarm). + fn spawn_rate_sync_ticker( + rt: Arc, + token: CancellationToken, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut iv = tokio::time::interval(rt.fx_rate_sync_tick); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + biased; + () = token.cancelled() => break, + _ = iv.tick() => { + let job = crate::infra::jobs::rate_sync::RateSyncJob::new( + rt.db.clone(), + Arc::clone(&rt.rate_provider), + rt.fx_repo.clone(), + Arc::clone(&rt.publisher), + ); + if let Err(e) = job.run().await { + tracing::error!(error = %e, "bss-ledger: rate-sync job tick failed"); + } + } + } + } + }) + } + + /// Spawn the unrealized-revaluation ticker (Slice 5 Phase 3, design §4.5): a + /// cancellable loop that drives a `RevaluationRunJob` pass every + /// `revaluation_run_tick` — forward-revalues at period end + reverses the + /// previous period. With Mode-B disabled (`revaluation_enabled = false`) the + /// pass is a no-op. Extracted (its shape mirrors the peer tickers) so `serve` + /// stays within the line budget. + fn spawn_revaluation_ticker( + rt: Arc, + token: CancellationToken, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut iv = tokio::time::interval(rt.revaluation_run_tick); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + biased; + () = token.cancelled() => break, + _ = iv.tick() => { + let job = crate::infra::jobs::revaluation_run::RevaluationRunJob::new( + rt.db.clone(), + Arc::clone(&rt.publisher), + Arc::clone(&rt.metrics), + rt.fx_config.clone(), + ); + if let Err(e) = job.run().await { + tracing::error!(error = %e, "bss-ledger: revaluation-run job tick failed"); + } + } + } + } + }) + } + + /// Spawn the reconciliation ticker (Slice 7 Phase 3, design §4.3): a cancellable + /// loop that drives a `ReconciliationFramework::run()` pass every `recon_tick` — + /// the near-real-time reconciliation sweep (invoice-completeness + AR↔derived + + /// Payments↔PSP over each tenant's current OPEN period). The Payments↔PSP + + /// invoice-completeness checks are inert until their control feeds land. Extracted + /// (its shape mirrors the peer tickers) so `serve` stays within the line budget. + fn spawn_reconciliation_ticker( + rt: Arc, + token: CancellationToken, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut iv = tokio::time::interval(rt.recon_tick); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + biased; + () = token.cancelled() => break, + _ = iv.tick() => { + if let Err(e) = rt.reconciliation.framework.run().await { + tracing::error!(error = %e, "bss-ledger: reconciliation job tick failed"); + } + } + } + } + }) + } + + #[allow( + clippy::redundant_pub_crate, + reason = "gear-private serve entry-point invoked by the toolkit runtime" + )] + #[allow( + clippy::too_many_lines, + reason = "the serve loop declares one ticker + one select arm per background job (tie-out, period-open, queue-applier, aged-alarm, recognition-run, rate-sync, revaluation-run, reconciliation, chain-verify); the per-job wiring is flat by design" + )] + pub(crate) async fn serve(self: Arc, cancel: CancellationToken) -> anyhow::Result<()> { + let Some(rt) = self.runtime.load_full() else { + info!("bss-ledger: serve() with no runtime (unconfigured); idling until cancelled"); + cancel.cancelled().await; + return Ok(()); + }; + + // Shared child token — cancelled by either the runtime (normal + // shutdown via `cancel`) or by `serve()` itself when one ticker dies, + // so both tickers observe the same cancellation deterministically. + let tasks = cancel.child_token(); + + // Tie-out ticker. The first `interval` tick fires immediately — a + // startup tie-out is harmless and idempotent. + let mut tie = { + let rt = Arc::clone(&rt); + let c = tasks.clone(); + tokio::spawn(async move { + let mut iv = tokio::time::interval(rt.tie_out_tick); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // VHP-1843: most ticks take the incremental tie-out (baseline + open + // fold); every Nth tick (and the first after startup) folds the full + // all-time history as a drift backstop. `N <= 1` → always full. + let full_every_n = rt.tie_out_full_every_n; + let mut tick_count: u64 = 0; + loop { + tokio::select! { + biased; + () = c.cancelled() => break, + _ = iv.tick() => { + tick_count = tick_count.wrapping_add(1); + let full = full_every_n <= 1 || tick_count % full_every_n == 1; + let job = crate::infra::jobs::tieout::TieOutJob::new( + rt.db.clone(), + Arc::clone(&rt.publisher), + ); + if let Err(e) = job.run_tick(full).await { + tracing::error!(error = %e, "bss-ledger: tie-out job tick failed"); + } + } + } + } + }) + }; + + // Period-open ticker (same shape). + let mut period = { + let rt = Arc::clone(&rt); + let c = tasks.clone(); + tokio::spawn(async move { + let mut iv = tokio::time::interval(rt.period_open_tick); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + biased; + () = c.cancelled() => break, + _ = iv.tick() => { + let job = crate::infra::jobs::period_open::PeriodOpenJob::new( + rt.db.clone(), + ); + if let Err(e) = job.run().await { + tracing::error!(error = %e, "bss-ledger: period-open job tick failed"); + } + } + } + } + }) + }; + + // Queued-allocation sweep ticker (same shape) — the deferred-apply + // backstop (Group D). Needs the publisher + metrics (the apply posts + // through the engine and the sweep emits the queue-depth gauge), unlike + // the other two jobs. + let mut queue = { + let rt = Arc::clone(&rt); + let c = tasks.clone(); + tokio::spawn(async move { + let mut iv = tokio::time::interval(rt.queue_applier_tick); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + biased; + () = c.cancelled() => break, + _ = iv.tick() => { + let job = crate::infra::jobs::queue_applier::QueueApplierJob::new( + rt.db.clone(), + Arc::clone(&rt.publisher), + Arc::clone(&rt.metrics), + ) + .with_max_invoices_per_allocation( + rt.payments_config.max_invoices_per_allocation, + ) + // Group G: gate the de-quarantine drain over the THEN-CURRENT D2. + .with_approval(Arc::clone(&rt.approval)); + if let Err(e) = job.run().await { + tracing::error!(error = %e, "bss-ledger: queue-applier job tick failed"); + } + } + } + } + }) + }; + + // Aged-alarm ticker (same shape) — the §6 Warn-severity scan for queued + // work / parked unallocated cash that has aged past a threshold. Needs the + // publisher (it emits the aged alarms out-of-band), like the tie-out job; + // no metrics sink (unlike the queue sweep's depth gauge). + let mut aged = { + let rt = Arc::clone(&rt); + let c = tasks.clone(); + tokio::spawn(async move { + let mut iv = tokio::time::interval(rt.aged_alarm_tick); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + biased; + () = c.cancelled() => break, + _ = iv.tick() => { + let job = crate::infra::jobs::aged_alarms::AgedAlarmJob::new( + rt.db.clone(), + Arc::clone(&rt.publisher), + ) + .with_metrics(Arc::clone(&rt.metrics)) + .with_exceptions(Arc::clone(&rt.exception_router)); + if let Err(e) = job.run().await { + tracing::error!(error = %e, "bss-ledger: aged-alarm job tick failed"); + } + // Dual-control maintenance (VHP-1852), on the aged-alarm + // cadence: the DC12 TTL sweep + the Z8-1 stuck-APPROVING + // probe. Best-effort — faults are logged inside, never + // fatal to the tick. + dual_control_maintenance_tick(rt.db.clone(), &rt.metrics).await; + } + } + } + }) + }; + + // Recognition-run ticker (Slice 4 S6 release backstop) — extracted to + // `spawn_recognition_ticker` (needs the publisher + metrics, like the + // queue sweep: the run posts through the engine + emits the §9 metrics). + let mut recognition = Self::spawn_recognition_ticker(Arc::clone(&rt), tasks.clone()); + // FX rate-sync ticker (Slice 5 §4.6) — extracted to `spawn_rate_sync_ticker` + // (pulls the configured `RateProviderV1` plugin's latest rates into the + // local store; inert under the default `UnconfiguredRateProviderV1`). + let mut rate_sync = Self::spawn_rate_sync_ticker(Arc::clone(&rt), tasks.clone()); + // Unrealized-revaluation ticker (Slice 5 Phase 3) — extracted to + // `spawn_revaluation_ticker` (forward-revalues at period end + reverses + // the previous period; a no-op under Mode A / `revaluation_enabled = + // false`). + let mut revaluation = Self::spawn_revaluation_ticker(Arc::clone(&rt), tasks.clone()); + // Reconciliation ticker (Slice 7 Phase 3) — extracted to + // `spawn_reconciliation_ticker` (the near-real-time recon sweep: + // invoice-completeness + AR↔derived + Payments↔PSP per tenant's open period; + // the control-feed checks are inert until their feeds land). + let mut recon = Self::spawn_reconciliation_ticker(Arc::clone(&rt), tasks.clone()); + // Chain-verifier ticker (same shape): re-walks every tenant's + // tamper-evidence chain and freezes + alarms a tenant whose chain no + // longer verifies. + let mut verify = { + let rt = Arc::clone(&rt); + let c = tasks.clone(); + tokio::spawn(async move { + let mut iv = tokio::time::interval(rt.verify_tick); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + biased; + () = c.cancelled() => break, + _ = iv.tick() => { + let job = crate::infra::jobs::verifier::ChainVerifierJob::new( + rt.db.clone(), + Arc::clone(&rt.publisher), + Arc::clone(&rt.metrics), + ); + if let Err(e) = job.run().await { + tracing::error!(error = %e, "bss-ledger: chain-verify job tick failed"); + } + } + } + } + }) + }; + + info!( + tie_out_tick_secs = rt.tie_out_tick.as_secs(), + period_open_tick_secs = rt.period_open_tick.as_secs(), + queue_applier_tick_secs = rt.queue_applier_tick.as_secs(), + aged_alarm_tick_secs = rt.aged_alarm_tick.as_secs(), + recognition_run_tick_secs = rt.recognition_run_tick.as_secs(), + verify_tick_secs = rt.verify_tick.as_secs(), + fx_rate_sync_tick_secs = rt.fx_rate_sync_tick.as_secs(), + revaluation_run_tick_secs = rt.revaluation_run_tick.as_secs(), + recon_tick_secs = rt.recon_tick.as_secs(), + "bss-ledger background ticks started" + ); + + // `select!` on the join handles (not `join!`): a panic in one ticker + // would otherwise stay invisible for up to a full tick. Each arm + // cancels the shared token, awaits the survivors, and maps a join + // error (panic / abort) to `anyhow`. + let serve_result: anyhow::Result<()> = tokio::select! { + res = &mut tie => { + tasks.cancel(); + let period_res = (&mut period).await; + let queue_res = (&mut queue).await; + let aged_res = (&mut aged).await; + let recognition_res = (&mut recognition).await; + let rate_sync_res = (&mut rate_sync).await; + let revaluation_res = (&mut revaluation).await; + let verify_res = (&mut verify).await; + let recon_res = (&mut recon).await; + res.map_err(|e| anyhow::anyhow!("bss-ledger: tie-out task: {e}"))?; + period_res.map_err(|e| anyhow::anyhow!("bss-ledger: period-open task: {e}"))?; + queue_res.map_err(|e| anyhow::anyhow!("bss-ledger: queue-applier task: {e}"))?; + aged_res.map_err(|e| anyhow::anyhow!("bss-ledger: aged-alarm task: {e}"))?; + recognition_res.map_err(|e| anyhow::anyhow!("bss-ledger: recognition-run task: {e}"))?; + rate_sync_res.map_err(|e| anyhow::anyhow!("bss-ledger: rate-sync task: {e}"))?; + revaluation_res.map_err(|e| anyhow::anyhow!("bss-ledger: revaluation-run task: {e}"))?; + verify_res.map_err(|e| anyhow::anyhow!("bss-ledger: chain-verify task: {e}"))?; + recon_res.map_err(|e| anyhow::anyhow!("bss-ledger: reconciliation task: {e}"))?; + Ok(()) + } + res = &mut period => { + tasks.cancel(); + let tie_res = (&mut tie).await; + let queue_res = (&mut queue).await; + let aged_res = (&mut aged).await; + let recognition_res = (&mut recognition).await; + let rate_sync_res = (&mut rate_sync).await; + let revaluation_res = (&mut revaluation).await; + let verify_res = (&mut verify).await; + let recon_res = (&mut recon).await; + res.map_err(|e| anyhow::anyhow!("bss-ledger: period-open task: {e}"))?; + tie_res.map_err(|e| anyhow::anyhow!("bss-ledger: tie-out task: {e}"))?; + queue_res.map_err(|e| anyhow::anyhow!("bss-ledger: queue-applier task: {e}"))?; + aged_res.map_err(|e| anyhow::anyhow!("bss-ledger: aged-alarm task: {e}"))?; + recognition_res.map_err(|e| anyhow::anyhow!("bss-ledger: recognition-run task: {e}"))?; + rate_sync_res.map_err(|e| anyhow::anyhow!("bss-ledger: rate-sync task: {e}"))?; + revaluation_res.map_err(|e| anyhow::anyhow!("bss-ledger: revaluation-run task: {e}"))?; + verify_res.map_err(|e| anyhow::anyhow!("bss-ledger: chain-verify task: {e}"))?; + recon_res.map_err(|e| anyhow::anyhow!("bss-ledger: reconciliation task: {e}"))?; + Ok(()) + } + res = &mut queue => { + tasks.cancel(); + let tie_res = (&mut tie).await; + let period_res = (&mut period).await; + let aged_res = (&mut aged).await; + let recognition_res = (&mut recognition).await; + let rate_sync_res = (&mut rate_sync).await; + let revaluation_res = (&mut revaluation).await; + let verify_res = (&mut verify).await; + let recon_res = (&mut recon).await; + res.map_err(|e| anyhow::anyhow!("bss-ledger: queue-applier task: {e}"))?; + tie_res.map_err(|e| anyhow::anyhow!("bss-ledger: tie-out task: {e}"))?; + period_res.map_err(|e| anyhow::anyhow!("bss-ledger: period-open task: {e}"))?; + aged_res.map_err(|e| anyhow::anyhow!("bss-ledger: aged-alarm task: {e}"))?; + recognition_res.map_err(|e| anyhow::anyhow!("bss-ledger: recognition-run task: {e}"))?; + rate_sync_res.map_err(|e| anyhow::anyhow!("bss-ledger: rate-sync task: {e}"))?; + revaluation_res.map_err(|e| anyhow::anyhow!("bss-ledger: revaluation-run task: {e}"))?; + verify_res.map_err(|e| anyhow::anyhow!("bss-ledger: chain-verify task: {e}"))?; + recon_res.map_err(|e| anyhow::anyhow!("bss-ledger: reconciliation task: {e}"))?; + Ok(()) + } + res = &mut aged => { + tasks.cancel(); + let tie_res = (&mut tie).await; + let period_res = (&mut period).await; + let queue_res = (&mut queue).await; + let recognition_res = (&mut recognition).await; + let rate_sync_res = (&mut rate_sync).await; + let revaluation_res = (&mut revaluation).await; + let verify_res = (&mut verify).await; + let recon_res = (&mut recon).await; + res.map_err(|e| anyhow::anyhow!("bss-ledger: aged-alarm task: {e}"))?; + tie_res.map_err(|e| anyhow::anyhow!("bss-ledger: tie-out task: {e}"))?; + period_res.map_err(|e| anyhow::anyhow!("bss-ledger: period-open task: {e}"))?; + queue_res.map_err(|e| anyhow::anyhow!("bss-ledger: queue-applier task: {e}"))?; + recognition_res.map_err(|e| anyhow::anyhow!("bss-ledger: recognition-run task: {e}"))?; + rate_sync_res.map_err(|e| anyhow::anyhow!("bss-ledger: rate-sync task: {e}"))?; + revaluation_res.map_err(|e| anyhow::anyhow!("bss-ledger: revaluation-run task: {e}"))?; + verify_res.map_err(|e| anyhow::anyhow!("bss-ledger: chain-verify task: {e}"))?; + recon_res.map_err(|e| anyhow::anyhow!("bss-ledger: reconciliation task: {e}"))?; + Ok(()) + } + res = &mut recognition => { + tasks.cancel(); + let tie_res = (&mut tie).await; + let period_res = (&mut period).await; + let queue_res = (&mut queue).await; + let aged_res = (&mut aged).await; + let rate_sync_res = (&mut rate_sync).await; + let revaluation_res = (&mut revaluation).await; + let verify_res = (&mut verify).await; + let recon_res = (&mut recon).await; + res.map_err(|e| anyhow::anyhow!("bss-ledger: recognition-run task: {e}"))?; + tie_res.map_err(|e| anyhow::anyhow!("bss-ledger: tie-out task: {e}"))?; + period_res.map_err(|e| anyhow::anyhow!("bss-ledger: period-open task: {e}"))?; + queue_res.map_err(|e| anyhow::anyhow!("bss-ledger: queue-applier task: {e}"))?; + aged_res.map_err(|e| anyhow::anyhow!("bss-ledger: aged-alarm task: {e}"))?; + rate_sync_res.map_err(|e| anyhow::anyhow!("bss-ledger: rate-sync task: {e}"))?; + revaluation_res.map_err(|e| anyhow::anyhow!("bss-ledger: revaluation-run task: {e}"))?; + verify_res.map_err(|e| anyhow::anyhow!("bss-ledger: chain-verify task: {e}"))?; + recon_res.map_err(|e| anyhow::anyhow!("bss-ledger: reconciliation task: {e}"))?; + Ok(()) + } + res = &mut rate_sync => { + tasks.cancel(); + let tie_res = (&mut tie).await; + let period_res = (&mut period).await; + let queue_res = (&mut queue).await; + let aged_res = (&mut aged).await; + let recognition_res = (&mut recognition).await; + let revaluation_res = (&mut revaluation).await; + let verify_res = (&mut verify).await; + let recon_res = (&mut recon).await; + res.map_err(|e| anyhow::anyhow!("bss-ledger: rate-sync task: {e}"))?; + tie_res.map_err(|e| anyhow::anyhow!("bss-ledger: tie-out task: {e}"))?; + period_res.map_err(|e| anyhow::anyhow!("bss-ledger: period-open task: {e}"))?; + queue_res.map_err(|e| anyhow::anyhow!("bss-ledger: queue-applier task: {e}"))?; + aged_res.map_err(|e| anyhow::anyhow!("bss-ledger: aged-alarm task: {e}"))?; + recognition_res.map_err(|e| anyhow::anyhow!("bss-ledger: recognition-run task: {e}"))?; + revaluation_res.map_err(|e| anyhow::anyhow!("bss-ledger: revaluation-run task: {e}"))?; + verify_res.map_err(|e| anyhow::anyhow!("bss-ledger: chain-verify task: {e}"))?; + recon_res.map_err(|e| anyhow::anyhow!("bss-ledger: reconciliation task: {e}"))?; + Ok(()) + } + res = &mut revaluation => { + tasks.cancel(); + let tie_res = (&mut tie).await; + let period_res = (&mut period).await; + let queue_res = (&mut queue).await; + let aged_res = (&mut aged).await; + let recognition_res = (&mut recognition).await; + let rate_sync_res = (&mut rate_sync).await; + let verify_res = (&mut verify).await; + let recon_res = (&mut recon).await; + res.map_err(|e| anyhow::anyhow!("bss-ledger: revaluation-run task: {e}"))?; + tie_res.map_err(|e| anyhow::anyhow!("bss-ledger: tie-out task: {e}"))?; + period_res.map_err(|e| anyhow::anyhow!("bss-ledger: period-open task: {e}"))?; + queue_res.map_err(|e| anyhow::anyhow!("bss-ledger: queue-applier task: {e}"))?; + aged_res.map_err(|e| anyhow::anyhow!("bss-ledger: aged-alarm task: {e}"))?; + recognition_res.map_err(|e| anyhow::anyhow!("bss-ledger: recognition-run task: {e}"))?; + rate_sync_res.map_err(|e| anyhow::anyhow!("bss-ledger: rate-sync task: {e}"))?; + verify_res.map_err(|e| anyhow::anyhow!("bss-ledger: chain-verify task: {e}"))?; + recon_res.map_err(|e| anyhow::anyhow!("bss-ledger: reconciliation task: {e}"))?; + Ok(()) + } + res = &mut verify => { + tasks.cancel(); + let tie_res = (&mut tie).await; + let period_res = (&mut period).await; + let queue_res = (&mut queue).await; + let aged_res = (&mut aged).await; + let recognition_res = (&mut recognition).await; + let rate_sync_res = (&mut rate_sync).await; + let revaluation_res = (&mut revaluation).await; + let recon_res = (&mut recon).await; + res.map_err(|e| anyhow::anyhow!("bss-ledger: chain-verify task: {e}"))?; + tie_res.map_err(|e| anyhow::anyhow!("bss-ledger: tie-out task: {e}"))?; + period_res.map_err(|e| anyhow::anyhow!("bss-ledger: period-open task: {e}"))?; + queue_res.map_err(|e| anyhow::anyhow!("bss-ledger: queue-applier task: {e}"))?; + aged_res.map_err(|e| anyhow::anyhow!("bss-ledger: aged-alarm task: {e}"))?; + recognition_res.map_err(|e| anyhow::anyhow!("bss-ledger: recognition-run task: {e}"))?; + rate_sync_res.map_err(|e| anyhow::anyhow!("bss-ledger: rate-sync task: {e}"))?; + revaluation_res.map_err(|e| anyhow::anyhow!("bss-ledger: revaluation-run task: {e}"))?; + recon_res.map_err(|e| anyhow::anyhow!("bss-ledger: reconciliation task: {e}"))?; + Ok(()) + } + res = &mut recon => { + tasks.cancel(); + let tie_res = (&mut tie).await; + let period_res = (&mut period).await; + let queue_res = (&mut queue).await; + let aged_res = (&mut aged).await; + let recognition_res = (&mut recognition).await; + let rate_sync_res = (&mut rate_sync).await; + let revaluation_res = (&mut revaluation).await; + let verify_res = (&mut verify).await; + res.map_err(|e| anyhow::anyhow!("bss-ledger: reconciliation task: {e}"))?; + tie_res.map_err(|e| anyhow::anyhow!("bss-ledger: tie-out task: {e}"))?; + period_res.map_err(|e| anyhow::anyhow!("bss-ledger: period-open task: {e}"))?; + queue_res.map_err(|e| anyhow::anyhow!("bss-ledger: queue-applier task: {e}"))?; + aged_res.map_err(|e| anyhow::anyhow!("bss-ledger: aged-alarm task: {e}"))?; + recognition_res.map_err(|e| anyhow::anyhow!("bss-ledger: recognition-run task: {e}"))?; + rate_sync_res.map_err(|e| anyhow::anyhow!("bss-ledger: rate-sync task: {e}"))?; + revaluation_res.map_err(|e| anyhow::anyhow!("bss-ledger: revaluation-run task: {e}"))?; + verify_res.map_err(|e| anyhow::anyhow!("bss-ledger: chain-verify task: {e}"))?; + Ok(()) + } + () = cancel.cancelled() => { + tasks.cancel(); + Ok(()) + } + }; + info!("bss-ledger background ticks cancelled"); + serve_result + } +} + +/// One dual-control maintenance tick (VHP-1852), run on the aged-alarm cadence: +/// the DC12 TTL sweep (flip stale `PENDING`/`NEEDS_REWORK` approvals past their +/// `expires_at` to `EXPIRED`, cross-tenant `allow_all`, complementing the lazy +/// expire-on-read in `create_pending`) plus the Z8-1 stuck-`APPROVING` probe +/// (record the `ledger_dual_control_approving` gauge + warn when crash-stranded +/// latches linger). Best-effort: every fault is logged, never propagated. +async fn dual_control_maintenance_tick( + db: DBProvider, + metrics: &Arc, +) { + let approvals = crate::infra::storage::repo::ApprovalRepo::new(db); + match approvals.expire_due_all(chrono::Utc::now()).await { + Ok(n) if n > 0 => tracing::info!( + expired = n, + "bss-ledger: dual-control TTL sweep expired stale approvals" + ), + Ok(_) => {} + Err(e) => tracing::error!(error = %e, "bss-ledger: dual-control TTL sweep failed"), + } + // A healthy approve clears the APPROVING latch (PENDING→APPROVING→APPROVED) + // within one txn, so a count that stays > 0 across ticks is a crash-stranded + // approve — excluded from the TTL sweep above, still holding the active- + // uniqueness slot, recoverable only by a manual re-approve. + match approvals.count_approving_all().await { + Ok(n) => { + metrics.dual_control_approving(i64::try_from(n).unwrap_or(i64::MAX)); + if n > 0 { + tracing::warn!( + approving = n, + "bss-ledger: approvals in the APPROVING latch (sustained > 0 = a \ + crash-stranded approve needing manual re-approve)" + ); + } + } + Err(e) => { + tracing::error!(error = %e, "bss-ledger: dual-control APPROVING-latch probe failed"); + } + } +} + +#[async_trait] +impl Gear for BssLedgerGear { + /// Publish the in-process `LedgerClientV1` when the gear is + /// configured. Absent from `gears:` → no-op (the module is compiled in + /// but unconfigured); present-but-invalid config aborts init loudly. + #[allow(clippy::too_many_lines)] // composition root: one flat wiring sequence is clearer than helpers + async fn init(&self, ctx: &GearCtx) -> Result<()> { + match ctx.config::() { + // Configured, or present with no `config:` section (defaults only). + Ok(_) | Err(ConfigError::MissingConfigSection { .. }) => {} + Err(ConfigError::GearNotFound { .. }) => { + info!( + "bss-ledger: not present in the `gears:` config block, \ + skipping init() (module compiled in but unconfigured)" + ); + return Ok(()); + } + Err(e) => return Err(e).context("bss-ledger: invalid `bss-ledger` config section"), + } + + // Bind the config after the control-flow match above: the `Ok` and + // `MissingConfigSection` arms both fall through here, so + // `unwrap_or_default()` yields the parsed config or the all-defaults + // config respectively (the `GearNotFound` / parse-error arms already + // returned). Abort init loudly on a present-but-invalid jobs cadence + // (a zero tick would panic `tokio::time::interval` in `serve`). + let cfg: BssLedgerConfig = ctx.config().unwrap_or_default(); + cfg.jobs + .validate() + .map_err(|e| anyhow::anyhow!("bss-ledger: invalid jobs config: {e}"))?; + cfg.recognition + .validate() + .map_err(|e| anyhow::anyhow!("bss-ledger: invalid recognition config: {e}"))?; + cfg.fx + .validate() + .map_err(|e| anyhow::anyhow!("bss-ledger: invalid fx config: {e}"))?; + cfg.recon + .validate() + .map_err(|e| anyhow::anyhow!("bss-ledger: invalid recon config: {e}"))?; + cfg.payments + .validate() + .map_err(|e| anyhow::anyhow!("bss-ledger: invalid payments config: {e}"))?; + + let db = ctx.db_required().context( + "bss-ledger: ctx.db_required() failed; the `db` capability is declared \ + but no DbHandle is available", + )?; + + // OTel metrics handle bound to the process-global meter provider (a + // no-op until the host wires an exporter). Built before the publisher so + // the alarm-counter mirror shares the same instruments as the post path. + let metrics: Arc = Arc::new(LedgerMetricsMeter::from_global()); + + // Build the event publisher with graceful degradation: any absence or + // error of the broker / types-registry / schema-registration yields a + // no-op publisher (events disabled). A financial post must never fail + // because the events surface is misconfigured. The metrics handle backs + // the alarm counter mirror even when the broker is absent. + let publisher = Arc::new( + build_event_publisher(ctx, &db, cfg.events_enabled, Arc::clone(&metrics)).await, + ); + + // Platform PEP. Unlike events (graceful no-op), authz is + // security-critical: a ledger must not run unauthorized, so a missing + // `AuthZResolverClient` fails init loudly. No `with_capabilities` — + // the PDP degrades subtree predicates to a flat `In` (decision A). + let authz_client = ctx + .client_hub() + .get::() + .context( + "bss-ledger: AuthZResolverClient absent from ClientHub; \ + authz-resolver module must be registered", + )?; + let enforcer = Arc::new(authz_resolver_sdk::PolicyEnforcer::new(authz_client)); + + // Register the authz-label stub schemas so RBAC role-defs referencing + // the ledger labels pass target-type validation. Mandatory (hard-fail), + // unlike the graceful event-schema registration in + // `build_event_publisher`. + let registry = ctx + .client_hub() + .get::() + .context( + "bss-ledger: TypesRegistryClient absent from ClientHub; \ + types-registry module must be registered", + )?; + let results = registry + .register(crate::authz::authz_label_type_schemas()) + .await + .context("bss-ledger: register authz label schemas")?; + for r in results { + if let types_registry_sdk::RegisterResult::Err { gts_id, error } = r { + anyhow::bail!( + "bss-ledger: failed to register authz label {}: {error}", + gts_id.as_deref().unwrap_or("?") + ); + } + } + + // Capture clones for the `LedgerRuntime` BEFORE `db` / `publisher` / + // `enforcer` / `metrics` are moved into the client: `db` is moved into + // `LedgerLocalClient::new`, and a clone of `publisher` goes + // to the client while the original is stored for the serve loop; the + // enforcer + metrics clones are stored for `register_rest` / the runtime. + let jobs_db = db.clone(); + let approval_db = db.clone(); + let payer_state_db = db.clone(); + // The dual-control executor's un-gated refund orchestrator (Group D replay) + // — cloned BEFORE `db`/`publisher` are moved into the in-process client. + let refund_db = db.clone(); + let refund_publisher = Arc::clone(&publisher); + // The dual-control executor's un-gated manual-adjustment orchestrator (Group 5 + // / Phase 3 replay) — same db/publisher deps as the refund replay handler, + // cloned BEFORE the client move below. + let manual_db = db.clone(); + let manual_publisher = Arc::clone(&publisher); + // The dual-control executor's period-reopen replay (Slice 7) — its own + // db/publisher clones, captured before the client move below. + let period_close_db = db.clone(); + let period_close_publisher = Arc::clone(&publisher); + // The Group-G GATED refund REST handler (`POST /refunds` + + // `refund-with-credit-note` + `GET /refunds/{id}`) needs its OWN db/publisher + // clones + a repo db clone — captured before the client move below. + let db_for_refunds = db.clone(); + let publisher_for_refunds = Arc::clone(&publisher); + let db_for_refund_repo = db.clone(); + // The dispute read-surface repo (`GET /disputes` list + `GET /disputes/{id}` + // by-id) — a plain scoped read over its own db clone, captured before the + // client move below (mirrors `db_for_refund_repo`). + let db_for_dispute_repo = db.clone(); + // The journal entry-HEADER read-surface repo (R5: `GET /journal-entries` list) + // — a plain scoped read over its own db clone, called DIRECTLY from the handler + // (the by-id `GET /journal-entries/{id}` still goes through the client), + // captured before the client move below (mirrors `db_for_dispute_repo`). + let db_for_journal_repo = db.clone(); + let db_for_posting_policy = db.clone(); + let db_for_posting_policy_rest = db.clone(); + let db_for_fx_revaluation_mode = db.clone(); + let db_for_fx_revaluation_mode_rest = db.clone(); + // The recognition-run read-surface repo (R4: `GET /recognition-runs` list + + // `GET /recognition-runs/{run_id}` by-id) and the payment-settlement read + // repo (R4: `GET /payments/{payment_id}/settlement`) — plain scoped reads + // over their own db clones, captured before the client move below (mirror + // `db_for_dispute_repo`). + let db_for_recognition_repo = db.clone(); + let db_for_payment_repo = db.clone(); + // Slice 7 Phase 2: the exception router (additive close-blocking routing, + // shared across the module-built stub-bearing services + the aged-alarm job) + // and the exception-queue dashboard repo. Captured before the client move. + let db_for_exceptions = db.clone(); + let exception_router = crate::infra::exception::ExceptionRouter::shared(db.clone()); + // The Group-6 / Phase-3 GATED manual-adjustment REST handler + // (`POST /manual-adjustments`) needs its OWN db/publisher clones — a SEPARATE + // instance from the executor's un-gated `approval_manual_handler` (which + // replays an already-approved adjustment and must never re-gate), mirroring the + // refund surface's gated/un-gated split. Captured before the client move below. + let manual_rest_db = db.clone(); + let manual_rest_publisher = Arc::clone(&publisher); + // The Slice-3 GATED credit-note + debit-note REST handlers + // (`POST …/credit-notes`, `POST …/debit-notes`) each need their OWN db/publisher + // clones — SEPARATE instances from the un-gated `credit_note_handler` / + // `debit_note_handler` built below (which back the refund composite's + // `with_credit_note_handler` + the executor's `post_*_approved` replay, neither + // of which gates), mirroring the refund/manual gated/un-gated split. The debit + // note also re-runs the recognition derivation, so its gated instance takes a + // clone of the same validated `cfg.recognition`. Captured before the client move + // below. + let credit_rest_db = db.clone(); + let credit_rest_publisher = Arc::clone(&publisher); + let debit_rest_db = db.clone(); + let debit_rest_publisher = Arc::clone(&publisher); + let jobs_publisher = Arc::clone(&publisher); + let rt_enforcer = Arc::clone(&enforcer); + let rt_metrics = Arc::clone(&metrics); + + // FX rate-provider plugin (Slice 5): resolved from `ClientHub` like the + // other cross-gear clients, but with a fail-safe DEFAULT — the external + // ECB/bank adapter-gear is out of Slice 5 scope, so absent the adapter the + // gear falls back to `UnconfiguredRateProviderV1` (every fetch errors → the + // local rate store stays empty → FX-needing posts block at lock time, + // never a silent wrong rate). Unlike authz, a missing rate adapter is NOT + // fatal to init. The `RateSyncJob` (serve loop) pulls into `fx_repo`, whose + // db clone is captured here before `db` moves into the client below. + let rate_provider: Arc = ctx + .client_hub() + .get::() + .unwrap_or_else(|_| Arc::new(UnconfiguredRateProviderV1)); + let fx_repo = crate::infra::storage::repo::FxRepo::new(db.clone()); + + // Slice 7 Phase 3: the three launch-blocking control feeds (issued-invoice + // manifest, bill-run-finished, PSP settlement). Each resolves from `ClientHub` + // like the FX rate provider, with the IN-PROCESS store as the fail-safe default — + // the `…/control/*` REST endpoints push into it, the `ReconciliationFramework` + + // the close gate read it back. An empty store ⇒ `None` ⇒ the check is inert until + // a feed lands (decision 3); a real external adapter-gear, when registered, + // overrides per port. + let control_feeds = Arc::new(crate::infra::control_feed::InProcessControlFeeds::new()); + let manifest_feed: Arc = ctx + .client_hub() + .get::() + .unwrap_or_else(|_| Arc::clone(&control_feeds) as Arc); + let bill_run_feed: Arc = ctx + .client_hub() + .get::() + .unwrap_or_else(|_| Arc::clone(&control_feeds) as Arc); + let psp_feed: Arc = ctx + .client_hub() + .get::() + .unwrap_or_else(|_| Arc::clone(&control_feeds) as Arc); + // The pre-close completeness / bill-run gate inputs for the in-process client's + // close path (flag-gated by `cfg.recon`); `init()`-built so the close gate reads + // the same feeds the framework + the `…/control/*` ingest endpoints share. + let close_control = crate::infra::period_close::CloseControlFeeds { + manifest_feed: Arc::clone(&manifest_feed), + bill_run_feed: Arc::clone(&bill_run_feed), + manifest_enforcement: cfg.recon.manifest_enforcement, + bill_run_enforcement: cfg.recon.bill_run_enforcement, + // C3: Mode-B FX-revaluation completeness gate rides the existing Mode-B + // enable flag (inert until Mode-B is turned on, the v1 default). + fx_revaluation_enforcement: cfg.fx.revaluation_enabled, + }; + // Captured before `db` / `publisher` / `metrics` move into the client below — + // the `ReconciliationFramework` (the ticker + the `reconciliation-runs` REST + // trigger) is built over its own clones (the jobs pattern); the + // `reconciliation-runs` read repo gets its own db clone (mirrors the other + // read-surface repos). + let recon_db = db.clone(); + let recon_publisher = Arc::clone(&publisher); + let db_for_recon_repo = db.clone(); + + // The invoice-post write orchestrator backing the journal-entry REST + // surface (post / reversal / mapping-correction). It needs repo + posting + // access, so it wraps its own `PostingService` over clones of the same + // db / publisher / metrics the in-process client uses (built before the + // client move below). + let posting_service = Arc::new(crate::infra::invoice_post::InvoicePostService::new( + db.clone(), + Arc::clone(&publisher), + Arc::clone(&metrics), + // Slice 4: the recognition derivation enforces the segment ceiling + // from this config; the same validated `cfg.recognition` the runner + // job reads (validated at the top of `init`). + cfg.recognition.clone(), + // Slice 5: the S1 FX lock resolves over the local rate store using the + // validated `cfg.fx` (provider order + staleness windows). + cfg.fx.clone(), + )); + + // Typed controlled-annotation write port backing the `PATCH …/annotation` + // surface (Group 2B). Wraps a stateless AnnotationService over a clone of + // the same db; it opens its own SERIALIZABLE transaction (upsert + + // secured-audit) and never touches the journal tables. + let annotation_writer: Arc = Arc::new( + crate::infra::annotation::LedgerAnnotationWriter::new(db.clone()) + .with_metrics(Arc::clone(&metrics)), + ); + + // Audit-retrieval state (Group 2C): the scoped reader over a clone of the + // same db, plus the cross-tenant elevation gateway. Built before the `db` + // move into the client below. + let audit = Arc::new(crate::api::rest::audit::ApiState { + reader: crate::infra::audit::retrieval::AuditRetrievalReader::new(db.clone()), + gateway: crate::infra::authz::cross_tenant::CrossTenantGateway::new() + .with_metrics(Arc::clone(&metrics)), + exporter: crate::infra::inquiry::AuditPackExporter::new(db.clone()) + .with_metrics(Arc::clone(&metrics)), + erasure: crate::infra::pii::ErasureService::new().with_metrics(Arc::clone(&metrics)), + db: db.clone(), + }); + + // Slice-3 adjustment orchestrators (Group E). Concrete handlers (not behind + // `LedgerClientV1`): the credit-note handler wraps its own `PostingService` + // over clones of the same db / publisher; the debit-note handler also takes + // the SAME validated `cfg.recognition` the invoice-post / runner read (a + // deferred debit note runs the identical schedule derivation, D4). Built + // BEFORE `db` / `publisher` are moved into the client below. + let credit_note_handler = Arc::new( + crate::infra::adjustment::credit_note_service::CreditNoteHandler::new( + db.clone(), + Arc::clone(&publisher), + Arc::clone(&metrics), + ) + .with_exceptions(Arc::clone(&exception_router)), + ); + let debit_note_handler = Arc::new( + crate::infra::adjustment::debit_note_service::DebitNoteHandler::new( + db.clone(), + Arc::clone(&publisher), + Arc::clone(&metrics), + cfg.recognition.clone(), + ) + .with_exceptions(Arc::clone(&exception_router)), + ); + // The exposure-read repo for `GET …/exposure` (a plain scoped read over its + // own db clone). + let adjustment_repo = crate::infra::storage::repo::AdjustmentRepo::new(db.clone()); + + // AM client + the Types Registry back the provisioning seller-type guard: + // only a tenant whose TYPE owns a billing ledger may be provisioned + // (§4.12). A missing AM client would silently skip the guard, so it + // fails init loudly (like the enforcer). + let am_client = ctx + .client_hub() + .get::() + .context( + "bss-ledger: AccountManagementClient absent from ClientHub; \ + account-management module must be registered", + )?; + let seller_guard = Arc::new(crate::infra::seller_guard::SellerGuard::new( + Arc::new(crate::infra::seller_guard::AmTenantTypeReader::new( + am_client, + )), + cfg.seller_tenant_types.clone(), + )); + + // Slice 5 Phase 3: the Mode-B revaluation runner (the REST trigger's + // handle) — built BEFORE `db`/`publisher` are moved into the local client. + let revaluation_run = Arc::new( + crate::infra::fx::revaluation_run::UnrealizedRevaluationRun::new( + db.clone(), + Arc::clone(&publisher), + cfg.fx.clone(), + ) + .with_metrics(Arc::clone(&metrics)), + ); + + let client: Arc = Arc::new(LedgerLocalClient::new( + db, + publisher, + enforcer, + seller_guard, + metrics, + // Slice 5: the validated FX config for the in-process client's S2 settle lock. + cfg.fx.clone(), + // Slice 3: the validated payments config (per-allocation touched-invoice cap). + cfg.payments.clone(), + // Slice 7 Phase 3: the gated close consults the manifest / bill-run feeds. + close_control, + )); + ctx.client_hub() + .register::(Arc::clone(&client)); + + // Dual-control (VHP-1852): the approval lifecycle engine + its executor, + // which replays an approved mutation through the same client / posting + // surfaces the inline path uses (idempotent execute-then-mark). + let approval_poster: Arc = + posting_service.clone(); + let payer_state_repo = crate::infra::storage::repo::PayerStateRepo::new(payer_state_db); + // The executor replays an approved refund through an UN-GATED `RefundHandler` + // (no `approval` attached) via `post_refund_approved` — the threshold was + // already crossed at gate time, so the replay must never re-gate. Group G + // wires a SEPARATE gated handler (`.with_approval(...)`) onto the refund REST + // surface for the inline preparer path. + // The secured-audit sink for the `unknown_final` disposition (Group F / + // Slice 6 seam). NO-OP until Slice 6 (VHP-1858) merges — it logs the + // would-be `secured_audit_record` + bumps a metric, persisting nothing + // durable; the real `SecuredAuditStore` binds here at merge with no + // call-site change. Also feeds `with_metrics` so the disposition's §9 + // counter (`ledger_refund_unknown_final_total`) emits. + let secured_audit_sink: Arc = + Arc::new(crate::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new()); + let approval_refund_handler = Arc::new( + crate::infra::adjustment::refund_service::RefundHandler::new( + refund_db, + refund_publisher, + ) + // A `RefundWithCreditNote` composite replay needs a wired + // `CreditNoteHandler` (the orchestrator fails "composite requires a wired + // CreditNoteHandler" otherwise). The composite posts via `apply_in_txn`, + // which never gates, so the un-gated `credit_note_handler` is correct here. + .with_credit_note_handler(Arc::clone(&credit_note_handler)) + .with_audit_sink(Arc::clone(&secured_audit_sink)) + .with_metrics(Arc::clone(&rt_metrics)) + .with_exceptions(Arc::clone(&exception_router)), + ); + // The executor replays an approved manual adjustment through an UN-GATED + // `ManualAdjustmentHandler` (no `approval` attached) via + // `post_manual_adjustment_approved` — the threshold was already crossed at gate + // time, so the replay must never re-gate (mirrors `approval_refund_handler`). + // Group 6 wires a SEPARATE gated handler (`.with_approval(...)`) onto the + // manual-adjustments REST surface for the inline preparer path. + let approval_manual_handler = Arc::new( + crate::infra::adjustment::manual_adjustment_service::ManualAdjustmentHandler::new( + manual_db, + manual_publisher, + Arc::clone(&secured_audit_sink), + ), + ); + // Slice 7: the executor replays an approved `PeriodReopen` through this + // PeriodCloseService::reopen (CLOSED→REOPENED + secured `period-reopen` + // audit). Its own db/publisher clones + the shared no-op secured-audit sink + // (durable at Slice 6 with no call-site change). + let period_close_for_executor = crate::infra::period_close::PeriodCloseService::new( + period_close_db, + period_close_publisher, + Arc::clone(&secured_audit_sink), + ); + let approval_executor: Arc = + Arc::new( + crate::infra::approval::executor::LedgerApprovalExecutor::new( + Arc::clone(&client), + approval_poster, + payer_state_repo.clone(), + Arc::clone(&approval_refund_handler), + Arc::clone(&approval_manual_handler), + // The note replays re-enter through `post_*_approved`, which skips + // the gate REGARDLESS of a wired `approval` — so the un-gated REST + // builder handlers serve the replay too (no separate un-gated note + // instance needed). The threshold was already crossed at gate time; + // the `*_approved` entry must never re-gate. + Arc::clone(&credit_note_handler), + Arc::clone(&debit_note_handler), + period_close_for_executor, + ), + ); + let approval_service = Arc::new(crate::infra::approval::service::ApprovalService::new( + approval_db, + approval_executor, + Arc::clone(&rt_metrics), + cfg.fx.clone(), + )); + // The Group-6 / Phase-3 GATED manual-adjustment REST orchestrator (its own + // PostingService over the dedicated db/publisher clones) wired with the + // dual-control engine (over-D2 → 409) + the no-op secured-audit sink (the + // write-off capture, Slice-6 seam). SEPARATE instance from the executor's + // UN-gated `approval_manual_handler` (which replays an already-approved + // adjustment via `post_manual_adjustment_approved` and must never re-gate), + // mirroring the refund surface's `refund_handler` vs `approval_refund_handler`. + let manual_handler = Arc::new( + crate::infra::adjustment::manual_adjustment_service::ManualAdjustmentHandler::new( + manual_rest_db, + manual_rest_publisher, + Arc::clone(&secured_audit_sink), + ) + .with_approval(Arc::clone(&approval_service)), + ); + // The Slice-3 GATED credit-note + debit-note REST orchestrators (each its own + // PostingService over the dedicated db/publisher clones) wired with the + // dual-control engine (over-threshold → 409). SEPARATE instances from the + // un-gated `credit_note_handler` / `debit_note_handler` above (which back the + // refund composite + the executor replay, neither of which gates) — mirroring the + // refund/manual gated-vs-un-gated split. The executor still replays already- + // approved notes through the un-gated handlers via `post_*_approved` (skips the + // gate), so these gated instances are inline-path only. + let credit_note_handler_gated = Arc::new( + crate::infra::adjustment::credit_note_service::CreditNoteHandler::new( + credit_rest_db, + credit_rest_publisher, + Arc::clone(&rt_metrics), + ) + .with_approval(Arc::clone(&approval_service)) + .with_exceptions(Arc::clone(&exception_router)), + ); + let debit_note_handler_gated = Arc::new( + crate::infra::adjustment::debit_note_service::DebitNoteHandler::new( + debit_rest_db, + debit_rest_publisher, + Arc::clone(&rt_metrics), + cfg.recognition.clone(), + ) + .with_approval(Arc::clone(&approval_service)) + .with_exceptions(Arc::clone(&exception_router)), + ); + let approvals = Arc::new(crate::api::rest::approvals::ApiState { + service: Arc::clone(&approval_service), + }); + let payers = Arc::new(crate::api::rest::payers::ApiState { + approval: Arc::clone(&approval_service), + payer_state: payer_state_repo, + }); + // Slice 7 Group C: the period-closure surface reuses the in-process client + // (the gated close + `period.closed` emit) for `close` and the dual-control + // engine for `reopen` (always 409 DUAL_CONTROL_REQUIRED → approve → executor). + let closure = Arc::new(crate::api::rest::closure::ApiState { + client: Arc::clone(&client), + approval: Arc::clone(&approval_service), + }); + // Slice 7 Phase 2: the exception-queue dashboard surface (GET /exceptions + + // POST /exceptions/{id}/resolution) over its own scoped repo clone. + let exceptions = Arc::new(crate::api::rest::exceptions::ApiState { + repo: crate::infra::storage::repo::ExceptionQueueRepo::new(db_for_exceptions), + }); + + // Build and publish the runtime (the provisioning + journal routes + // reuse the same in-process client — no second instance; the serve loop + // reuses the captured db/publisher clones for the background jobs). + let provisioning = Arc::new(crate::api::rest::provisioning::ApiState { + client: Arc::clone(&client), + }); + let journal = Arc::new(crate::api::rest::journal_entries::ApiState { + client: Arc::clone(&client), + posting: posting_service, + approval: Some(Arc::clone(&approval_service)), + annotation: annotation_writer, + // The R5 journal entry-HEADER read-surface repo (`GET /journal-entries` + // list): a plain scoped read over its own db clone, called directly from + // the handler (mirrors the dispute repo). + journal_repo: Some(crate::infra::storage::repo::JournalRepo::new( + db_for_journal_repo, + )), + posting_policy: Some(crate::infra::storage::repo::PostingPolicyRepo::new( + db_for_posting_policy, + )), + }); + let posting_policy = Arc::new(crate::api::rest::posting_policy::ApiState { + posting_policy: crate::infra::storage::repo::PostingPolicyRepo::new( + db_for_posting_policy_rest, + ), + }); + let payments = Arc::new(crate::api::rest::payments::ApiState { + client: Arc::clone(&client), + // The R4 settlement read-surface repo (`GET /payments/{id}/settlement`): + // a plain scoped read over its own db clone (mirrors the dispute repo). + payment_repo: crate::infra::storage::repo::PaymentRepo::new(db_for_payment_repo), + }); + let credit = Arc::new(crate::api::rest::credit::ApiState { + client: Arc::clone(&client), + approval: Some(Arc::clone(&approval_service)), + }); + let disputes = Arc::new(crate::api::rest::disputes::ApiState { + client: Arc::clone(&client), + approval: Some(Arc::clone(&approval_service)), + // The dispute read-surface repo (R3): the `GET /disputes` list + + // `GET /disputes/{id}` by-id read source (a plain scoped read over its + // own db clone, mirroring the refund surface's `refund_repo`). + dispute_repo: crate::infra::storage::repo::DisputeRepo::new(db_for_dispute_repo), + }); + // The recognition gate is taken behind the `RecognitionApprovalGate` trait + // (the unit-test seam); coerce the concrete service into + // the trait object at this binding site. + let recognition_gate: Arc = + approval_service.clone(); + let recognition = Arc::new(crate::api::rest::recognition::ApiState { + client: Arc::clone(&client), + approval: Some(recognition_gate), + // The R4 recognition-run read-surface repo (`GET /recognition-runs` list + // + `GET /recognition-runs/{run_id}` by-id): a plain scoped read over its + // own db clone (mirrors the dispute repo). + recognition_repo: Some(crate::infra::storage::repo::RecognitionRepo::new( + db_for_recognition_repo, + )), + }); + // Slice-3 adjustment REST state: the GATED handlers built above + the + // exposure-read repo. The credit / debit / manual note handlers are all the + // GATED instances (dual-control over threshold → 409); the executor replays an + // already-approved note through SEPARATE un-gated handlers via `post_*_approved` + // (which skips the gate), mirroring the refund surface's gated/un-gated split. + let adjustments = Arc::new(crate::api::rest::adjustments::ApiState { + credit: credit_note_handler_gated, + debit: debit_note_handler_gated, + manual: manual_handler, + exposure_repo: adjustment_repo, + }); + // Slice-3 Phase-2 Group-G refund REST state: a GATED `RefundHandler` (its own + // PostingService over the same db/publisher) wired with the dual-control + // engine (over-D2 → 409), the composite credit-note handler (the atomic + // `refund-with-credit-note`), the no-op secured-audit sink (the `unknown_final` + // disposition, Slice-6 seam), and the metrics meter. SEPARATE instance from + // the executor's UN-gated `approval_refund_handler` (which replays an + // already-approved refund and must never re-gate). The exposure repo is reused + // for the `GET /refunds/{id}` read (a plain scoped read). + let refund_handler = Arc::new( + crate::infra::adjustment::refund_service::RefundHandler::new( + db_for_refunds, + Arc::clone(&publisher_for_refunds), + ) + .with_approval(Arc::clone(&approval_service)) + .with_credit_note_handler(Arc::clone(&credit_note_handler)) + .with_audit_sink(Arc::clone(&secured_audit_sink)) + .with_metrics(Arc::clone(&rt_metrics)) + .with_exceptions(Arc::clone(&exception_router)), + ); + let refunds = Arc::new(crate::api::rest::refunds::ApiState { + refunds: refund_handler, + refund_repo: crate::infra::storage::repo::AdjustmentRepo::new(db_for_refund_repo), + }); + // Slice-5 FX REST state: its own clone of the FX repo (the runtime's + // `fx_repo` below is consumed by the RateSyncJob; `FxRepo` is `Clone`). + let fx = Arc::new(crate::api::rest::fx::ApiState { + fx_repo: fx_repo.clone(), + revaluation_run: Arc::clone(&revaluation_run), + fx_revaluation_mode: crate::infra::storage::repo::FxRevaluationModeRepo::new( + db_for_fx_revaluation_mode, + ), + fleet_revaluation_enabled: cfg.fx.revaluation_enabled, + }); + // VHP-1986: the per-tenant FX revaluation-mode config REST surface (its own + // repo clone; mirrors the posting-policy config surface). + let fx_revaluation_mode = Arc::new(crate::api::rest::fx_revaluation_mode::ApiState { + fx_revaluation_mode: crate::infra::storage::repo::FxRevaluationModeRepo::new( + db_for_fx_revaluation_mode_rest, + ), + }); + // Slice 7 Phase 3: the reconciliation framework (AR↔derived / Payments↔PSP / + // invoice-completeness) over its own db/publisher clones + the shared metrics + + // exception router + the resolved control feeds + the recon config. Driven by the + // `ReconciliationJob` ticker (serve loop, via `reconciliation.framework`) + the + // `reconciliation-runs` REST trigger. + let reconciliation_framework = + Arc::new(crate::infra::reconciliation::ReconciliationFramework::new( + recon_db, + recon_publisher, + Arc::clone(&rt_metrics), + Arc::clone(&exception_router), + Arc::clone(&manifest_feed), + Arc::clone(&psp_feed), + cfg.recon.clone(), + )); + let reconciliation = Arc::new(crate::api::rest::reconciliation::ApiState { + framework: reconciliation_framework, + run_repo: crate::infra::storage::repo::ReconciliationRunRepo::new(db_for_recon_repo), + }); + // The control-feed ingest surface pushes into the SAME in-process store the + // framework + close gate read back. + let control = Arc::new(crate::api::rest::control::ApiState { + feeds: Arc::clone(&control_feeds), + }); + self.runtime.store(Some(Arc::new(LedgerRuntime { + provisioning, + journal, + payments, + credit, + disputes, + recognition, + adjustments, + approvals, + posting_policy, + fx_revaluation_mode, + approval: Arc::clone(&approval_service), + refunds, + payers, + closure, + exceptions, + exception_router, + reconciliation, + control, + audit, + fx, + enforcer: rt_enforcer, + db: jobs_db, + publisher: jobs_publisher, + metrics: rt_metrics, + tie_out_tick: cfg.jobs.tie_out_interval(), + tie_out_full_every_n: cfg.jobs.tieout_full_every_n, + period_open_tick: cfg.jobs.period_open_interval(), + queue_applier_tick: cfg.jobs.queue_applier_interval(), + aged_alarm_tick: cfg.jobs.aged_alarm_interval(), + recognition_run_tick: cfg.recognition.recognition_run_interval(), + verify_tick: cfg.jobs.verify_interval(), + rate_provider, + fx_repo, + fx_rate_sync_tick: cfg.fx.rate_sync_interval(), + fx_config: cfg.fx.clone(), + payments_config: cfg.payments.clone(), + revaluation_run_tick: cfg.fx.revaluation_run_interval(), + recon_tick: cfg.recon.recon_tick_interval(), + }))); + + info!("bss-ledger: published LedgerClientV1 in ClientHub"); + Ok(()) + } +} + +/// Build the [`LedgerEventPublisher`] at `init()`. +/// +/// TODO(broker): the event broker (`event-broker-sdk`) is not yet available in +/// gears-rust. Until it lands this returns a **broker-free** publisher — it +/// mirrors the invariant-alarm counter into metrics and logs would-be events, +/// but publishes nothing. When the broker arrives, restore here: obtain +/// `hub.get::()`, register the event-type +/// schemas (`crate::infra::events::schemas::register_event_schemas`), build one +/// `AsyncProducer` per event type via `broker.producer_builder()...build_async()`, +/// and return `LedgerEventPublisher::new(, db, metrics)`. +async fn build_event_publisher( + _ctx: &GearCtx, + _db: &DBProvider, + events_enabled: bool, + metrics: Arc, +) -> LedgerEventPublisher { + if !events_enabled { + warn!("bss-ledger: events disabled (events_enabled=false); no-op publisher"); + return LedgerEventPublisher::noop(); + } + // Broker parked (no event-broker-sdk in gears-rust yet): a metrics-only + // publisher keeps the invariant-alarm counter live; every event publish is a + // logged no-op (see `LedgerEventPublisher`). No broker dependency. + LedgerEventPublisher::with_metrics(metrics) +} + +/// `DatabaseCapability` impl. Returns the migration list so `toolkit` +/// runs migrations at platform startup before any ledger code reads the DB. +impl DatabaseCapability for BssLedgerGear { + fn migrations(&self) -> Vec> { + Migrator::migrations() + } +} + +/// `RestApiCapability` impl. Mounts the provisioning route when `init()` has +/// populated the runtime; on a default-disabled boot it falls back to an +/// empty `/v1` mount. +impl RestApiCapability for BssLedgerGear { + fn register_rest( + &self, + _ctx: &GearCtx, + router: Router, + openapi: &dyn OpenApiRegistry, + ) -> Result { + if let Some(rt) = self.runtime.load_full() { + Ok(router + .merge(crate::api::rest::provisioning::router( + Arc::clone(&rt.provisioning), + openapi, + )) + .merge(crate::api::rest::journal_entries::router( + Arc::clone(&rt.journal), + openapi, + )) + .merge(crate::api::rest::payments::router( + Arc::clone(&rt.payments), + openapi, + )) + .merge(crate::api::rest::credit::router( + Arc::clone(&rt.credit), + openapi, + )) + .merge(crate::api::rest::disputes::router( + Arc::clone(&rt.disputes), + openapi, + )) + .merge(crate::api::rest::recognition::router( + Arc::clone(&rt.recognition), + openapi, + )) + .merge(crate::api::rest::adjustments::router( + Arc::clone(&rt.adjustments), + openapi, + )) + .merge(crate::api::rest::refunds::router( + Arc::clone(&rt.refunds), + openapi, + )) + .merge(crate::api::rest::approvals::router( + Arc::clone(&rt.approvals), + openapi, + )) + .merge(crate::api::rest::posting_policy::router( + Arc::clone(&rt.posting_policy), + openapi, + )) + .merge(crate::api::rest::fx_revaluation_mode::router( + Arc::clone(&rt.fx_revaluation_mode), + openapi, + )) + .merge(crate::api::rest::payers::router( + Arc::clone(&rt.payers), + openapi, + )) + .merge(crate::api::rest::closure::router( + Arc::clone(&rt.closure), + openapi, + )) + .merge(crate::api::rest::exceptions::router( + Arc::clone(&rt.exceptions), + openapi, + )) + .merge(crate::api::rest::audit::router( + Arc::clone(&rt.audit), + openapi, + )) + .merge(crate::api::rest::fx::router(Arc::clone(&rt.fx), openapi)) + .merge(crate::api::rest::reconciliation::router( + Arc::clone(&rt.reconciliation), + openapi, + )) + .merge(crate::api::rest::control::router( + Arc::clone(&rt.control), + openapi, + )) + // Per-request PEP for the handlers (RMS layers the value, not + // the `Arc`; `PolicyEnforcer: Clone`). + .layer(axum::Extension((*rt.enforcer).clone())) + .layer(axum::middleware::from_fn( + toolkit::api::canonical_error_middleware, + ))) + } else { + Ok(router.nest("/bss-ledger/v1", Router::new())) + } + } +} diff --git a/gears/bss/ledger/ledger/src/odata.rs b/gears/bss/ledger/ledger/src/odata.rs new file mode 100644 index 000000000..1fad9c31f --- /dev/null +++ b/gears/bss/ledger/ledger/src/odata.rs @@ -0,0 +1,469 @@ +//! `OData` filter-field definitions for the ledger's row-collection list +//! endpoints (`GET …/accounts`, `GET …/journal-lines`, `GET …/balances`). +//! +//! Each enum declares the wire-named fields valid in a `$filter` / `$orderby` +//! clause on its endpoint. They feed both the `OpenAPI` +//! `with_odata_filter::()` helper (which advertises the per-field operators) +//! and the `paginate_odata` call in the repo layer (via the column mappers in +//! [`crate::infra::storage::odata_mapping`]). This is the canonical platform +//! list pattern (RBAC/AM/RG): the `$filter` is **additive over** the SecureORM +//! tenant scope, never a replacement (BOLA preserved). +//! +//! Each enum carries a default keyset-order column as a recognised variant +//! (`account_id` / `line_id`) so the repo's default-order injection resolves +//! via [`FilterField::from_name`] — without that variant a bare list (no +//! `$orderby`, no cursor) would error "Unknown orderby field" the way the RBAC +//! C1 fix documents. + +use toolkit_odata::filter::{FieldKind, FilterField}; + +/// Filter field enum for `GET /bss-ledger/v1/accounts`. +/// +/// The chart of accounts is keyed by `account_id`; the listable dims are the +/// account coordinate (`account_class`, `currency`, `revenue_stream`) and the +/// `lifecycle_state`. `account_id` is the default keyset-order column +/// (`account_id ASC`, hits the `tenant_account` PK). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum AccountInfoFilterField { + /// Persistent account id — the default keyset-order column (`account_id + /// ASC`). Must be a recognised field so the repo's default-order injection + /// resolves via [`FilterField::from_name`]. + AccountId, + AccountClass, + Currency, + RevenueStream, + LifecycleState, +} + +impl FilterField for AccountInfoFilterField { + const FIELDS: &'static [Self] = &[ + Self::AccountId, + Self::AccountClass, + Self::Currency, + Self::RevenueStream, + Self::LifecycleState, + ]; + + fn name(&self) -> &'static str { + match self { + Self::AccountId => "account_id", + Self::AccountClass => "account_class", + Self::Currency => "currency", + Self::RevenueStream => "revenue_stream", + Self::LifecycleState => "lifecycle_state", + } + } + + fn kind(&self) -> FieldKind { + match self { + Self::AccountId => FieldKind::Uuid, + // `account_class` / `lifecycle_state` are stored as their plain + // string literals (not a SMALLINT-encoded enum), so the wire shape + // matches storage and they stay ordinary `String` columns — no + // `map_value` / `is_orderable` override needed. + Self::AccountClass | Self::Currency | Self::RevenueStream | Self::LifecycleState => { + FieldKind::String + } + } + } +} + +/// Filter field enum for `GET /bss-ledger/v1/journal-lines`. +/// +/// The listable dims are the posted line dims a caller reconciles on: +/// `payer_tenant_id`, `account_class`, `period_id`, and `invoice_id` (the AR +/// line's business-document ref — a real `journal_line` column). `line_id` is +/// the default keyset-order column (`line_id ASC`, hits the `journal_line` PK), +/// matching the foundation's prior `ORDER BY line_id`. +/// +/// Migration note: the legacy `LineFilter` also accepted `source_business_id`, +/// which is an **entry-header** dim (not on the line) and was resolved to +/// matching entry ids first. That arm is dropped here — the line carries its +/// own `invoice_id`, which is the per-line equivalent a `$filter` can target +/// directly. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum JournalLineFilterField { + /// Line id — the default keyset-order column (`line_id ASC`). Must be a + /// recognised field so the repo's default-order injection resolves. + LineId, + PayerTenantId, + AccountClass, + PeriodId, + InvoiceId, +} + +impl FilterField for JournalLineFilterField { + const FIELDS: &'static [Self] = &[ + Self::LineId, + Self::PayerTenantId, + Self::AccountClass, + Self::PeriodId, + Self::InvoiceId, + ]; + + fn name(&self) -> &'static str { + match self { + Self::LineId => "line_id", + Self::PayerTenantId => "payer_tenant_id", + Self::AccountClass => "account_class", + Self::PeriodId => "period_id", + Self::InvoiceId => "invoice_id", + } + } + + fn kind(&self) -> FieldKind { + match self { + Self::LineId | Self::PayerTenantId => FieldKind::Uuid, + Self::AccountClass | Self::PeriodId | Self::InvoiceId => FieldKind::String, + } + } +} + +/// Filter field enum for `GET /bss-ledger/v1/journal-entries` (the entry-HEADER +/// list, read-surface R5). The listable dims are the header-only ones a caller +/// cross-cuts on — which is exactly why this is a separate collection over +/// `journal_entry` and NOT a new `journal_line` filter: `source_doc_type` / +/// `source_business_id` are columns on the entry HEADER, never on the line (the +/// line only carries `entry_id`), so "list all `MANUAL_ADJUSTMENT` entries" or +/// "all `REFUND` / `CREDIT_NOTE` entries" can only be served from the header +/// table. `entry_id` is the default keyset-order column (`entry_id ASC`, the +/// `journal_entry` PK's keyset leg), mirroring `recognition_run`'s `run_id` on a +/// composite-PK table. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum JournalEntryFilterField { + /// Entry id — the default keyset-order column (`entry_id ASC`). Must be a + /// recognised field so the repo's default-order injection resolves via + /// [`FilterField::from_name`]. + EntryId, + SourceDocType, + SourceBusinessId, + PeriodId, +} + +impl FilterField for JournalEntryFilterField { + const FIELDS: &'static [Self] = &[ + Self::EntryId, + Self::SourceDocType, + Self::SourceBusinessId, + Self::PeriodId, + ]; + + fn name(&self) -> &'static str { + match self { + Self::EntryId => "entry_id", + Self::SourceDocType => "source_doc_type", + Self::SourceBusinessId => "source_business_id", + Self::PeriodId => "period_id", + } + } + + fn kind(&self) -> FieldKind { + match self { + // `entry_id` is the `Uuid` keyset leg; `source_doc_type` / + // `source_business_id` / `period_id` are plain `varchar` dims. + Self::EntryId => FieldKind::Uuid, + Self::SourceDocType | Self::SourceBusinessId | Self::PeriodId => FieldKind::String, + } + } +} + +/// Filter field enum for `GET /bss-ledger/v1/balances`. +/// +/// The account-balance cache lists by `account_class` / `currency`. `account_id` +/// is the default keyset-order column (`account_id ASC`, hits the +/// `account_balance` PK), matching the foundation's prior `ORDER BY account_id`. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum BalanceFilterField { + /// Account id — the default keyset-order column (`account_id ASC`). Must be + /// a recognised field so the repo's default-order injection resolves. + AccountId, + AccountClass, + Currency, +} + +impl FilterField for BalanceFilterField { + const FIELDS: &'static [Self] = &[Self::AccountId, Self::AccountClass, Self::Currency]; + + fn name(&self) -> &'static str { + match self { + Self::AccountId => "account_id", + Self::AccountClass => "account_class", + Self::Currency => "currency", + } + } + + fn kind(&self) -> FieldKind { + match self { + Self::AccountId => FieldKind::Uuid, + Self::AccountClass | Self::Currency => FieldKind::String, + } + } +} + +/// Filter field enum for `GET /bss-ledger/v1/refunds` (the refund-record list, +/// design §4.4 / read-surface). The `refund` table's surrogate PK is +/// `(tenant_id, refund_id)`; `refund_id` is the default keyset-order column +/// (`refund_id ASC`). NB: `refund` carries NO `payer_tenant_id` column, so the +/// filterable dims are the origin / lifecycle ones below (all `varchar`). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum RefundFilterField { + /// Refund id — the default keyset-order column (`refund_id ASC`). Must be a + /// recognised field so the repo's default-order injection resolves. + RefundId, + PaymentId, + PspRefundId, + Phase, + Pattern, + ClearingState, + InvoiceId, +} + +impl FilterField for RefundFilterField { + const FIELDS: &'static [Self] = &[ + Self::RefundId, + Self::PaymentId, + Self::PspRefundId, + Self::Phase, + Self::Pattern, + Self::ClearingState, + Self::InvoiceId, + ]; + + fn name(&self) -> &'static str { + match self { + Self::RefundId => "refund_id", + Self::PaymentId => "payment_id", + Self::PspRefundId => "psp_refund_id", + Self::Phase => "phase", + Self::Pattern => "pattern", + Self::ClearingState => "clearing_state", + Self::InvoiceId => "invoice_id", + } + } + + fn kind(&self) -> FieldKind { + // Every refund filter dim is a varchar column (refund_id / payment_id / + // psp_refund_id / phase / pattern / clearing_state / invoice_id). + FieldKind::String + } +} + +/// Filter field enum for `GET /bss-ledger/v1/credit-notes` (the credit-note record +/// list, read-surface §5). The `credit_note` table's PK is `(tenant_id, +/// credit_note_id)`; `credit_note_id` is the default keyset-order column +/// (`credit_note_id ASC`). The filterable dims are the origin / classification +/// ones below (all `varchar` — `credit_note_id` / `origin_invoice_id` / +/// `revenue_stream` / `reason_code`). NB: `credit_note` carries NO +/// `payer_tenant_id` / `entry_id` / `goodwill` column, so none of those are +/// filterable (the design §5 field list was aspirational — the entity is the +/// source of truth). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum CreditNoteFilterField { + /// Credit-note id — the default keyset-order column (`credit_note_id ASC`). + /// Must be a recognised field so the repo's default-order injection resolves. + CreditNoteId, + OriginInvoiceId, + RevenueStream, + ReasonCode, +} + +impl FilterField for CreditNoteFilterField { + const FIELDS: &'static [Self] = &[ + Self::CreditNoteId, + Self::OriginInvoiceId, + Self::RevenueStream, + Self::ReasonCode, + ]; + + fn name(&self) -> &'static str { + match self { + Self::CreditNoteId => "credit_note_id", + Self::OriginInvoiceId => "origin_invoice_id", + Self::RevenueStream => "revenue_stream", + Self::ReasonCode => "reason_code", + } + } + + fn kind(&self) -> FieldKind { + // Every credit-note filter dim is a varchar column (credit_note_id / + // origin_invoice_id / revenue_stream / reason_code). + FieldKind::String + } +} + +/// Filter field enum for `GET /bss-ledger/v1/debit-notes` (the debit-note record +/// list, read-surface §5). The `debit_note` table's PK is `(tenant_id, +/// debit_note_id)`; `debit_note_id` is the default keyset-order column +/// (`debit_note_id ASC`). The `debit_note` table is leaner than `credit_note` +/// (NO `revenue_stream` / `reason_code` columns), so only the id + origin invoice +/// are filterable (both `varchar`). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum DebitNoteFilterField { + /// Debit-note id — the default keyset-order column (`debit_note_id ASC`). + /// Must be a recognised field so the repo's default-order injection resolves. + DebitNoteId, + OriginInvoiceId, +} + +impl FilterField for DebitNoteFilterField { + const FIELDS: &'static [Self] = &[Self::DebitNoteId, Self::OriginInvoiceId]; + + fn name(&self) -> &'static str { + match self { + Self::DebitNoteId => "debit_note_id", + Self::OriginInvoiceId => "origin_invoice_id", + } + } + + fn kind(&self) -> FieldKind { + // Both debit-note filter dims are varchar columns (debit_note_id / + // origin_invoice_id). + FieldKind::String + } +} + +/// Filter field enum for `GET /bss-ledger/v1/disputes` (the chargeback dispute +/// current-state list, read-surface R3). The `ledger_dispute` table's PK is +/// `(tenant_id, dispute_id)`; `dispute_id` is the default keyset-order column +/// (`dispute_id ASC`). The filterable dims are the dispute's origin / lifecycle +/// identity below (all `varchar` — `dispute_id` / `payment_id` / `last_phase` / +/// `variant`). NB: `ledger_dispute` carries NO `payer_tenant_id` / `created_at` +/// column, so neither is filterable; the numeric `cycle` / amount columns are not +/// listed as filter dims (a caller seeks by the string identity, mirroring the +/// refund/note surfaces). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum DisputeFilterField { + /// Dispute id — the default keyset-order column (`dispute_id ASC`). Must be a + /// recognised field so the repo's default-order injection resolves via + /// [`FilterField::from_name`]. + DisputeId, + PaymentId, + LastPhase, + Variant, +} + +impl FilterField for DisputeFilterField { + const FIELDS: &'static [Self] = &[ + Self::DisputeId, + Self::PaymentId, + Self::LastPhase, + Self::Variant, + ]; + + fn name(&self) -> &'static str { + match self { + Self::DisputeId => "dispute_id", + Self::PaymentId => "payment_id", + Self::LastPhase => "last_phase", + Self::Variant => "variant", + } + } + + fn kind(&self) -> FieldKind { + // Every dispute filter dim is a varchar column (dispute_id / payment_id / + // last_phase / variant). + FieldKind::String + } +} + +/// Filter field enum for `GET /bss-ledger/v1/recognition-runs` (the ASC 606 +/// recognition-run list, read-surface R4). The `recognition_run` table's PK is +/// `(tenant_id, period_id, run_id)`; `run_id` is the default keyset-order column +/// (`run_id ASC`). The filterable dims are the run's identity / lifecycle below: +/// `run_id` (the surrogate run id, a `Uuid`), `period_id` (the fiscal period the +/// run released, a `YYYYMM` `varchar`), and `status` (`RUNNING` / `DONE` / +/// `FAILED`, a `varchar`). The numeric `started_at_utc` is not a filter dim (a +/// caller seeks by the id / period / status identity, mirroring the dispute / +/// refund surfaces). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum RecognitionRunFilterField { + /// Run id — the default keyset-order column (`run_id ASC`). Must be a + /// recognised field so the repo's default-order injection resolves via + /// [`FilterField::from_name`]. + RunId, + PeriodId, + Status, +} + +impl FilterField for RecognitionRunFilterField { + const FIELDS: &'static [Self] = &[Self::RunId, Self::PeriodId, Self::Status]; + + fn name(&self) -> &'static str { + match self { + Self::RunId => "run_id", + Self::PeriodId => "period_id", + Self::Status => "status", + } + } + + fn kind(&self) -> FieldKind { + match self { + // `run_id` is the surrogate `Uuid` PK leg; `period_id` / `status` are + // plain `varchar` dims. + Self::RunId => FieldKind::Uuid, + Self::PeriodId | Self::Status => FieldKind::String, + } + } +} + +/// Filter field enum for `GET /bss-ledger/v1/exceptions` (the Revenue Assurance +/// exception-queue dashboard / list, Slice 7 Phase 2, design §4.6 / §5). The +/// `ledger_exception_queue` table's PK is `(tenant_id, exception_id)`; +/// `exception_id` is the default keyset-order column (`exception_id ASC`). The +/// filterable dims are the queue triage ones the dashboard cross-cuts on: +/// `exception_type` (the wire `type`, e.g. `RECON_MISMATCH`), `status` +/// (`OPEN` / `ACK` / `RESOLVED` / `APPROVED_EXCEPTION`), `business_ref` (the +/// offending business key), and `period_id` (the fiscal period). The `opened_at` +/// timestamp is not a filter dim (a caller seeks by the type / status / ref +/// identity, mirroring the dispute / refund surfaces). NB: `period_id` is +/// nullable (a non-period-scoped exception has none). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum ExceptionFilterField { + /// Exception id — the default keyset-order column (`exception_id ASC`). Must + /// be a recognised field so the repo's default-order injection resolves via + /// [`FilterField::from_name`]. + ExceptionId, + /// The exception type, wire-named `type` (`type` is a Rust keyword, so the + /// variant is `ExceptionType`, but its `$filter` field name is `type`). + ExceptionType, + Status, + BusinessRef, + PeriodId, +} + +impl FilterField for ExceptionFilterField { + const FIELDS: &'static [Self] = &[ + Self::ExceptionId, + Self::ExceptionType, + Self::Status, + Self::BusinessRef, + Self::PeriodId, + ]; + + fn name(&self) -> &'static str { + match self { + Self::ExceptionId => "exception_id", + // The wire field is `type` (the existing query param), even though the + // backing column is `exception_type`. + Self::ExceptionType => "type", + Self::Status => "status", + Self::BusinessRef => "business_ref", + Self::PeriodId => "period_id", + } + } + + fn kind(&self) -> FieldKind { + match self { + // `exception_id` is the `Uuid` keyset leg; the rest are plain `varchar` + // dims (`type` / `status` / `business_ref` / `period_id`). + Self::ExceptionId => FieldKind::Uuid, + Self::ExceptionType | Self::Status | Self::BusinessRef | Self::PeriodId => { + FieldKind::String + } + } + } +} + +#[cfg(test)] +#[path = "odata_tests.rs"] +mod odata_tests; diff --git a/gears/bss/ledger/ledger/src/odata_tests.rs b/gears/bss/ledger/ledger/src/odata_tests.rs new file mode 100644 index 000000000..a67eb2177 --- /dev/null +++ b/gears/bss/ledger/ledger/src/odata_tests.rs @@ -0,0 +1,335 @@ +//! Unit tests for the ledger's `OData` filter-field enums: wire names, field +//! kinds, and (critically) that each enum's default keyset-order column +//! resolves via [`FilterField::from_name`] so the repo's default-order +//! injection cannot error "Unknown orderby field". + +use super::{ + AccountInfoFilterField, BalanceFilterField, CreditNoteFilterField, DebitNoteFilterField, + DisputeFilterField, JournalEntryFilterField, JournalLineFilterField, RecognitionRunFilterField, + RefundFilterField, +}; +use toolkit_odata::filter::{FieldKind, FilterField}; + +#[test] +fn account_fields_expose_expected_wire_names_and_kinds() { + assert_eq!(AccountInfoFilterField::AccountId.name(), "account_id"); + assert_eq!(AccountInfoFilterField::AccountClass.name(), "account_class"); + assert_eq!(AccountInfoFilterField::Currency.name(), "currency"); + assert_eq!( + AccountInfoFilterField::RevenueStream.name(), + "revenue_stream" + ); + assert_eq!( + AccountInfoFilterField::LifecycleState.name(), + "lifecycle_state" + ); + assert_eq!(AccountInfoFilterField::AccountId.kind(), FieldKind::Uuid); + assert_eq!( + AccountInfoFilterField::AccountClass.kind(), + FieldKind::String + ); +} + +#[test] +fn line_fields_expose_expected_wire_names_and_kinds() { + assert_eq!(JournalLineFilterField::LineId.name(), "line_id"); + assert_eq!( + JournalLineFilterField::PayerTenantId.name(), + "payer_tenant_id" + ); + assert_eq!(JournalLineFilterField::AccountClass.name(), "account_class"); + assert_eq!(JournalLineFilterField::PeriodId.name(), "period_id"); + assert_eq!(JournalLineFilterField::InvoiceId.name(), "invoice_id"); + assert_eq!(JournalLineFilterField::LineId.kind(), FieldKind::Uuid); + assert_eq!( + JournalLineFilterField::PayerTenantId.kind(), + FieldKind::Uuid + ); + assert_eq!(JournalLineFilterField::PeriodId.kind(), FieldKind::String); +} + +#[test] +fn balance_fields_expose_expected_wire_names_and_kinds() { + assert_eq!(BalanceFilterField::AccountId.name(), "account_id"); + assert_eq!(BalanceFilterField::AccountClass.name(), "account_class"); + assert_eq!(BalanceFilterField::Currency.name(), "currency"); + assert_eq!(BalanceFilterField::AccountId.kind(), FieldKind::Uuid); + assert_eq!(BalanceFilterField::Currency.kind(), FieldKind::String); +} + +/// The repo injects a default `( ASC)` order on a bare list. That key +/// MUST resolve back to a variant via `from_name`, else `paginate_odata` errors +/// "Unknown orderby field" — the RBAC C1 regression. Guard each enum's default +/// keyset column. +#[test] +fn default_order_columns_resolve_via_from_name() { + assert_eq!( + AccountInfoFilterField::from_name("account_id"), + Some(AccountInfoFilterField::AccountId) + ); + assert_eq!( + JournalLineFilterField::from_name("line_id"), + Some(JournalLineFilterField::LineId) + ); + assert_eq!( + BalanceFilterField::from_name("account_id"), + Some(BalanceFilterField::AccountId) + ); +} + +#[test] +fn unknown_field_does_not_resolve() { + assert_eq!(AccountInfoFilterField::from_name("nope"), None); + assert_eq!( + JournalLineFilterField::from_name("source_business_id"), + None + ); + assert_eq!(BalanceFilterField::from_name("balance_minor"), None); +} + +// ── New read-surface filter enums (refund / notes / dispute / recognition-run / +// journal-entry header). Each test pins EVERY variant's wire name + kind, asserts +// `FIELDS` lists every variant, and round-trips `from_name(name())` so the repo's +// default-order injection (and any `$orderby` on a listed dim) resolves. ───────── + +#[test] +fn refund_fields_round_trips() { + assert_eq!(RefundFilterField::RefundId.name(), "refund_id"); + assert_eq!(RefundFilterField::PaymentId.name(), "payment_id"); + assert_eq!(RefundFilterField::PspRefundId.name(), "psp_refund_id"); + assert_eq!(RefundFilterField::Phase.name(), "phase"); + assert_eq!(RefundFilterField::Pattern.name(), "pattern"); + assert_eq!(RefundFilterField::ClearingState.name(), "clearing_state"); + assert_eq!(RefundFilterField::InvoiceId.name(), "invoice_id"); + + // Every refund filter dim is a plain `varchar` column. + for f in RefundFilterField::FIELDS { + assert_eq!(f.kind(), FieldKind::String, "{f:?} must be String"); + } + + assert_eq!( + RefundFilterField::FIELDS, + &[ + RefundFilterField::RefundId, + RefundFilterField::PaymentId, + RefundFilterField::PspRefundId, + RefundFilterField::Phase, + RefundFilterField::Pattern, + RefundFilterField::ClearingState, + RefundFilterField::InvoiceId, + ], + "FIELDS must list every variant" + ); + + // The default keyset-order column resolves, and so does every listed dim. + assert_eq!( + RefundFilterField::from_name("refund_id"), + Some(RefundFilterField::RefundId) + ); + for f in RefundFilterField::FIELDS { + assert_eq!( + RefundFilterField::from_name(f.name()), + Some(*f), + "{f:?} must round-trip via from_name(name())" + ); + } +} + +#[test] +fn credit_note_fields_round_trips() { + assert_eq!(CreditNoteFilterField::CreditNoteId.name(), "credit_note_id"); + assert_eq!( + CreditNoteFilterField::OriginInvoiceId.name(), + "origin_invoice_id" + ); + assert_eq!( + CreditNoteFilterField::RevenueStream.name(), + "revenue_stream" + ); + assert_eq!(CreditNoteFilterField::ReasonCode.name(), "reason_code"); + + // Every credit-note filter dim is a plain `varchar` column. + for f in CreditNoteFilterField::FIELDS { + assert_eq!(f.kind(), FieldKind::String, "{f:?} must be String"); + } + + assert_eq!( + CreditNoteFilterField::FIELDS, + &[ + CreditNoteFilterField::CreditNoteId, + CreditNoteFilterField::OriginInvoiceId, + CreditNoteFilterField::RevenueStream, + CreditNoteFilterField::ReasonCode, + ], + "FIELDS must list every variant" + ); + + assert_eq!( + CreditNoteFilterField::from_name("credit_note_id"), + Some(CreditNoteFilterField::CreditNoteId) + ); + for f in CreditNoteFilterField::FIELDS { + assert_eq!( + CreditNoteFilterField::from_name(f.name()), + Some(*f), + "{f:?} must round-trip via from_name(name())" + ); + } +} + +#[test] +fn debit_note_fields_round_trips() { + assert_eq!(DebitNoteFilterField::DebitNoteId.name(), "debit_note_id"); + assert_eq!( + DebitNoteFilterField::OriginInvoiceId.name(), + "origin_invoice_id" + ); + + // Both debit-note filter dims are plain `varchar` columns. + for f in DebitNoteFilterField::FIELDS { + assert_eq!(f.kind(), FieldKind::String, "{f:?} must be String"); + } + + assert_eq!( + DebitNoteFilterField::FIELDS, + &[ + DebitNoteFilterField::DebitNoteId, + DebitNoteFilterField::OriginInvoiceId, + ], + "FIELDS must list every variant" + ); + + assert_eq!( + DebitNoteFilterField::from_name("debit_note_id"), + Some(DebitNoteFilterField::DebitNoteId) + ); + for f in DebitNoteFilterField::FIELDS { + assert_eq!( + DebitNoteFilterField::from_name(f.name()), + Some(*f), + "{f:?} must round-trip via from_name(name())" + ); + } +} + +#[test] +fn dispute_fields_round_trips() { + assert_eq!(DisputeFilterField::DisputeId.name(), "dispute_id"); + assert_eq!(DisputeFilterField::PaymentId.name(), "payment_id"); + assert_eq!(DisputeFilterField::LastPhase.name(), "last_phase"); + assert_eq!(DisputeFilterField::Variant.name(), "variant"); + + // Every dispute filter dim is a plain `varchar` column. + for f in DisputeFilterField::FIELDS { + assert_eq!(f.kind(), FieldKind::String, "{f:?} must be String"); + } + + assert_eq!( + DisputeFilterField::FIELDS, + &[ + DisputeFilterField::DisputeId, + DisputeFilterField::PaymentId, + DisputeFilterField::LastPhase, + DisputeFilterField::Variant, + ], + "FIELDS must list every variant" + ); + + assert_eq!( + DisputeFilterField::from_name("dispute_id"), + Some(DisputeFilterField::DisputeId) + ); + for f in DisputeFilterField::FIELDS { + assert_eq!( + DisputeFilterField::from_name(f.name()), + Some(*f), + "{f:?} must round-trip via from_name(name())" + ); + } +} + +#[test] +fn recognition_run_fields_round_trips() { + assert_eq!(RecognitionRunFilterField::RunId.name(), "run_id"); + assert_eq!(RecognitionRunFilterField::PeriodId.name(), "period_id"); + assert_eq!(RecognitionRunFilterField::Status.name(), "status"); + + // `run_id` is the surrogate `Uuid` PK leg; `period_id` / `status` are `varchar`. + assert_eq!(RecognitionRunFilterField::RunId.kind(), FieldKind::Uuid); + assert_eq!( + RecognitionRunFilterField::PeriodId.kind(), + FieldKind::String + ); + assert_eq!(RecognitionRunFilterField::Status.kind(), FieldKind::String); + + assert_eq!( + RecognitionRunFilterField::FIELDS, + &[ + RecognitionRunFilterField::RunId, + RecognitionRunFilterField::PeriodId, + RecognitionRunFilterField::Status, + ], + "FIELDS must list every variant" + ); + + assert_eq!( + RecognitionRunFilterField::from_name("run_id"), + Some(RecognitionRunFilterField::RunId) + ); + for f in RecognitionRunFilterField::FIELDS { + assert_eq!( + RecognitionRunFilterField::from_name(f.name()), + Some(*f), + "{f:?} must round-trip via from_name(name())" + ); + } +} + +#[test] +fn journal_entry_fields_round_trips() { + assert_eq!(JournalEntryFilterField::EntryId.name(), "entry_id"); + assert_eq!( + JournalEntryFilterField::SourceDocType.name(), + "source_doc_type" + ); + assert_eq!( + JournalEntryFilterField::SourceBusinessId.name(), + "source_business_id" + ); + assert_eq!(JournalEntryFilterField::PeriodId.name(), "period_id"); + + // `entry_id` is the `Uuid` keyset leg; the header dims are `varchar`. + assert_eq!(JournalEntryFilterField::EntryId.kind(), FieldKind::Uuid); + assert_eq!( + JournalEntryFilterField::SourceDocType.kind(), + FieldKind::String + ); + assert_eq!( + JournalEntryFilterField::SourceBusinessId.kind(), + FieldKind::String + ); + assert_eq!(JournalEntryFilterField::PeriodId.kind(), FieldKind::String); + + assert_eq!( + JournalEntryFilterField::FIELDS, + &[ + JournalEntryFilterField::EntryId, + JournalEntryFilterField::SourceDocType, + JournalEntryFilterField::SourceBusinessId, + JournalEntryFilterField::PeriodId, + ], + "FIELDS must list every variant" + ); + + assert_eq!( + JournalEntryFilterField::from_name("entry_id"), + Some(JournalEntryFilterField::EntryId) + ); + for f in JournalEntryFilterField::FIELDS { + assert_eq!( + JournalEntryFilterField::from_name(f.name()), + Some(*f), + "{f:?} must round-trip via from_name(name())" + ); + } +} diff --git a/gears/bss/ledger/ledger/tests/metrics_emit.rs b/gears/bss/ledger/ledger/tests/metrics_emit.rs new file mode 100644 index 000000000..ec891063e --- /dev/null +++ b/gears/bss/ledger/ledger/tests/metrics_emit.rs @@ -0,0 +1,125 @@ +//! §9 feature-metric emission tests (Slice 6 Phase 4 Group 4C). +//! +//! A NON-ignored, NO-Docker integration test that drives the OTel metrics +//! adapter (`LedgerMetricsMeter`) through the in-memory `MetricsHarness` and +//! asserts each §9 instrument records the right metric + labels. This proves the +//! metric LAYER independently of the services that will later emit through it +//! (most of which are wired only via documented seams in this group). +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use bss_ledger::domain::ports::metrics::LedgerMetricsPort; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use uuid::Uuid; + +const TENANT: Uuid = Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222); + +#[test] +fn tamper_verify_run_counts_runs_and_failures() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.tamper_verify_run(TENANT, false); + m.tamper_verify_run(TENANT, true); + m.tamper_verify_run(TENANT, false); + h.force_flush(); + assert_eq!( + h.counter_value("ledger_tamper_verify_runs_total", &[]), + 3, + "every run counts under the runs counter" + ); + assert_eq!( + h.counter_value("ledger_tamper_verify_failures_total", &[]), + 1, + "only the break counts under the failures counter" + ); +} + +#[test] +fn chain_length_gauge_carries_tenant() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.chain_length(TENANT, 7); + h.force_flush(); + assert_eq!( + h.gauge_value( + "ledger_tamper_chain_length", + &[("tenant", &TENANT.to_string())] + ), + 7 + ); +} + +#[test] +fn scope_freeze_active_gauge_carries_tenant() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.scope_freeze_active(TENANT, 1); + h.force_flush(); + assert_eq!( + h.gauge_value( + "ledger_scope_freeze_active", + &[("tenant", &TENANT.to_string())] + ), + 1 + ); +} + +#[test] +fn cross_tenant_access_counter_carries_reason_code() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.cross_tenant_access("DISPUTE"); + m.cross_tenant_access("DISPUTE"); + h.force_flush(); + assert_eq!( + h.counter_value( + "ledger_cross_tenant_access_total", + &[("reason_code", "DISPUTE")] + ), + 2 + ); +} + +#[test] +fn reidentification_and_erasure_counters_increment() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.reidentification(); + m.erasure_applied(); + m.erasure_applied(); + h.force_flush(); + assert_eq!(h.counter_value("ledger_reidentification_total", &[]), 1); + assert_eq!(h.counter_value("ledger_erasure_applied_total", &[]), 2); +} + +#[test] +fn metadata_change_counter_carries_attribute() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.metadata_change("memo"); + h.force_flush(); + assert_eq!( + h.counter_value("ledger_metadata_change_total", &[("attribute", "memo")]), + 1 + ); +} + +#[test] +fn audit_pack_export_duration_records_a_sample() { + let h = MetricsHarness::new(); + let m = h.metrics(); + m.audit_pack_export_duration(0.5); + h.force_flush(); + assert_eq!( + h.histogram_count("ledger_audit_pack_export_duration_seconds", &[]), + 1 + ); +} diff --git a/gears/bss/ledger/ledger/tests/module_test.rs b/gears/bss/ledger/ledger/tests/module_test.rs new file mode 100644 index 000000000..69aa3abfe --- /dev/null +++ b/gears/bss/ledger/ledger/tests/module_test.rs @@ -0,0 +1,10 @@ +//! The gear builds, exposes a migrator, and reports the `db` capability. + +#[test] +fn gear_exposes_migrator_via_db_capability() { + use toolkit::contracts::DatabaseCapability; + // The gear constructs and its `db` capability exposes the registered + // migration chain (non-empty; later phases push more onto it). + let gear = bss_ledger::module::BssLedgerGear::default(); + assert!(!gear.migrations().is_empty()); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_allocate_fx.rs b/gears/bss/ledger/ledger/tests/postgres_allocate_fx.rs new file mode 100644 index 000000000..8ccc5cdad --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_allocate_fx.rs @@ -0,0 +1,450 @@ +//! Postgres-only integration (Slice 5, Phase 2 F1): **realized FX on a +//! cross-currency allocation close**, driven through the REAL payment +//! orchestrators (`InvoicePostService` + `SettlementService` + `AllocationService`) +//! against the foundation engine — the design's **worked example C** oracle. +//! +//! A USD-**functional** seller (functional currency on its `fiscal_calendar`, +//! S5-F3) bills in **EUR**: +//! 1. post an EUR invoice @ 1.10 → AR carried 120.00 EUR / **132.00 USD**; +//! 2. the rate moves to 1.08; +//! 3. settle 120.00 EUR @ 1.08 → the unallocated pool carries 120.00 EUR / +//! **129.60 USD**; +//! 4. allocate the pool onto the invoice (a full close) — the DR UNALLOCATED leg +//! relieves 129.60 USD, the CR AR leg 132.00 USD, so the functional column is +//! short 2.40 on the debit side → a **DR FX_GAIN_LOSS 2.40 USD** realized +//! **loss**, and BOTH grains close to functional zero. +//! +//! Asserts the net realized loss (2.40), the functional-only FX line shape +//! (`amount_minor = 0`, `functional_amount_minor = 240`, side DR), that the +//! allocate entry's functional column balances under the dual-column commit +//! trigger, and that both the AR invoice and the pool close to `(0, 0)` in BOTH +//! columns. No new rate is locked on the allocate entry (relief is at the carried +//! rate) → its lines carry no `rate_snapshot_ref`. +//! +//! Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::fx::rate_locker::RateLocker; +use bss_ledger::infra::fx::rate_source::RateSource; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::payment::allocate::{AllocateRequest, AllocationOutcome, AllocationService}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{FxRepo, NewFxRate, ReferenceRepo}; +use bss_ledger_sdk::AccountClass; +use chrono::{Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// A chart account in `currency` (EUR for the transaction grains, USD for the +/// functional FX_GAIN_LOSS account). `stream` is `Some` only for the per-stream +/// REVENUE class. +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: &str, + currency: &str, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: currency.to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Build a `RateLocker` over the local FX store for the S1/S2 lock points. +fn rate_locker(provider: &DBProvider, fx_config: &FxConfig) -> RateLocker { + RateLocker::new( + RateSource::new(FxRepo::new(provider.clone()), fx_config.clone()), + FxRepo::new(provider.clone()), + ) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_currency_allocate_realizes_fx_and_closes_both_grains() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let provider = + DBProvider::::new(connect_db(&repo_url, ConnectOpts::default()).await.unwrap()); + + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let ar = Uuid::now_v7(); + let revenue = Uuid::now_v7(); + let cash = Uuid::now_v7(); + let unallocated = Uuid::now_v7(); + let fx_gl = Uuid::now_v7(); + let now = Utc::now(); + let period_id = format!("{:04}{:02}", now.year(), now.month()); + + let reference = ReferenceRepo::new(provider.clone()); + // Both the transaction (EUR) and functional (USD) scales must be registered. + for ccy in ["EUR", "USD"] { + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: ccy.to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + } + // S5-F3: the functional-currency source (USD) — what ACTIVATES the FX locks. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_calendar + (tenant_id, legal_entity_id, fiscal_tz, granularity, fy_start_month, functional_currency) + VALUES ('{tenant}','{tenant}','UTC','MONTH',1,'USD')" + ))) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{tenant}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + // The EUR transaction chart (AR / REVENUE / CASH_CLEARING / UNALLOCATED) + the + // USD functional FX_GAIN_LOSS account the realized-FX poster binds (F1: it MUST + // be provisioned in the functional currency). + for row in [ + account(tenant, ar, AccountClass::Ar, "DR", "EUR", None), + account( + tenant, + revenue, + AccountClass::Revenue, + "CR", + "EUR", + Some("subscription"), + ), + account(tenant, cash, AccountClass::CashClearing, "DR", "EUR", None), + account( + tenant, + unallocated, + AccountClass::Unallocated, + "CR", + "EUR", + None, + ), + account(tenant, fx_gl, AccountClass::FxGainLoss, "DR", "USD", None), + ] { + reference.insert_account(row).await.unwrap(); + } + + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(tenant); + + // ── 1. EUR invoice @ 1.10 → AR carried 120.00 EUR / 132.00 USD ────────────── + FxRepo::new(provider.clone()) + .upsert_rate(&NewFxRate { + tenant_id: tenant, + base_currency: "EUR".to_owned(), + quote_currency: "USD".to_owned(), + provider: "ecb".to_owned(), + rate_micro: 1_100_000, + as_of: now, + fallback_order: 0, + }) + .await + .unwrap(); + + let fx_config = FxConfig { + provider_order: vec!["ecb".to_owned()], + ..FxConfig::default() + }; + let invoice_svc = InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + RecognitionConfig::default(), + fx_config.clone(), + ); + // 120.00 EUR ex-tax, no tax ⇒ DR AR 12000 / CR REVENUE 12000 (functional @1.10 + // = 13200 each). + let inv = PostedInvoice { + invoice_id: "INV-FXC-1".to_owned(), + payer_tenant_id: payer, + resource_tenant_id: None, + seller_tenant_id: tenant, + effective_at: now.date_naive(), + due_date: Some(naive(2026, 12, 1)), + period_id: period_id.clone(), + items: vec![InvoiceItem { + amount_minor_ex_tax: 12_000, + deferred_minor: 0, + currency: "EUR".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }], + tax: vec![], + posted_by_actor_id: tenant, + correlation_id: tenant, + }; + invoice_svc + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("cross-currency invoice must post"); + + // AR grain carried both columns: 120.00 EUR / 132.00 USD. + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-FXC-1'" + )).await, + Some(12_000), + "AR carried transaction = 120.00 EUR" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-FXC-1'" + )).await, + Some(13_200), + "AR carried functional = 132.00 USD (120.00 EUR * 1.10)" + ); + + // ── 2. The rate moves to 1.08 (overwrites the local store) ────────────────── + FxRepo::new(provider.clone()) + .upsert_rate(&NewFxRate { + tenant_id: tenant, + base_currency: "EUR".to_owned(), + quote_currency: "USD".to_owned(), + provider: "ecb".to_owned(), + rate_micro: 1_080_000, + as_of: now, + fallback_order: 0, + }) + .await + .unwrap(); + + // ── 3. Settle 120.00 EUR @ 1.08 → pool carries 120.00 EUR / 129.60 USD ────── + let settle_svc = SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .with_fx(rate_locker(&provider, &fx_config)); + settle_svc + .settle( + &ctx, + &scope, + SettlementInput { + tenant_id: tenant, + payer_tenant_id: payer, + payment_id: "PAY-FXC-1".to_owned(), + gross_minor: 12_000, + fee_minor: 0, + currency: "EUR".to_owned(), + effective_at: None, + }, + ) + .await + .expect("cross-currency settle must post"); + + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT balance_minor FROM bss.ledger_unallocated_balance WHERE tenant_id='{tenant}' AND payer_tenant_id='{payer}'" + )).await, + Some(12_000), + "pool carried transaction = 120.00 EUR" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_unallocated_balance WHERE tenant_id='{tenant}' AND payer_tenant_id='{payer}'" + )).await, + Some(12_960), + "pool carried functional = 129.60 USD (120.00 EUR * 1.08)" + ); + + // ── 4. Allocate the pool onto the invoice — the cross-currency close ───────── + // AllocationService takes NO `.with_fx()`: realized FX is carried-driven (it + // reads the grains' carried functional), not rate-locked. + let allocate_svc = AllocationService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ); + let outcome = allocate_svc + .allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: tenant, + payer_tenant_id: payer, + payment_id: "PAY-FXC-1".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 12_000, + currency: "EUR".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }, + ) + .await + .expect("cross-currency allocate must post (functional column balances)"); + let alloc_entry = match outcome { + AllocationOutcome::Applied(a) => { + assert!(!a.posting.replayed, "first allocate is fresh"); + a.posting.entry_id + } + AllocationOutcome::Queued(q) => panic!("expected an inline allocate, got Queued: {q:?}"), + }; + let alloc_lines = format!( + "FROM bss.ledger_journal_line WHERE tenant_id='{tenant}' AND entry_id='{alloc_entry}'" + ); + + // The allocate entry has THREE lines: DR UNALLOCATED + CR AR + the FX line. + assert_eq!( + scalar_i64(&raw, &format!("SELECT count(*) {alloc_lines}")).await, + Some(3), + "allocate entry = DR UNALLOCATED + CR AR + the appended FX_GAIN_LOSS line" + ); + + // The net realized FX line: a functional-only DR FX_GAIN_LOSS of 2.40 USD loss + // (amount_minor = 0, functional_amount_minor = 240, currency = USD). + assert_eq!( + scalar_i64( + &raw, + &format!("SELECT functional_amount_minor {alloc_lines} AND account_id='{fx_gl}'") + ) + .await, + Some(240), + "DR FX_GAIN_LOSS = 2.40 USD realized loss (132.00 - 129.60)" + ); + assert_eq!( + scalar_i64( + &raw, + &format!("SELECT amount_minor {alloc_lines} AND account_id='{fx_gl}'") + ) + .await, + Some(0), + "the FX line is functional-only (transaction amount_minor = 0)" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(*) {alloc_lines} AND account_id='{fx_gl}' AND side='DR' AND functional_currency='USD'" + )).await, + Some(1), + "a realized LOSS is a DEBIT to FX_GAIN_LOSS in the functional currency" + ); + + // The allocate entry's functional column balances (DR == CR) — the dual-column + // commit trigger accepted the post ONLY because of this. + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT COALESCE(SUM(CASE WHEN side='DR' THEN functional_amount_minor ELSE -functional_amount_minor END),0)::bigint {alloc_lines}" + )).await, + Some(0), + "allocate entry functional column balances (DR == CR)" + ); + // No new base rate is locked on an allocation close (relief at the carried + // rate, §4.3) — the allocate lines carry no rate_snapshot_ref. + assert_eq!( + scalar_i64( + &raw, + &format!("SELECT count(*) {alloc_lines} AND rate_snapshot_ref IS NOT NULL") + ) + .await, + Some(0), + "an allocation close locks no new rate (carried-rate relief)" + ); + + // ── 5. Both grains close to ZERO in BOTH columns ──────────────────────────── + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-FXC-1'" + )).await, + Some(0), + "AR transaction balance closed to 0" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-FXC-1'" + )).await, + Some(0), + "AR functional balance closed to 0" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT balance_minor FROM bss.ledger_unallocated_balance WHERE tenant_id='{tenant}' AND payer_tenant_id='{payer}'" + )).await, + Some(0), + "pool transaction balance closed to 0" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_unallocated_balance WHERE tenant_id='{tenant}' AND payer_tenant_id='{payer}'" + )).await, + Some(0), + "pool functional balance closed to 0" + ); + + // The FX_GAIN_LOSS account carries the realized loss in the functional column + // (a P&L grain: transaction balance 0, functional = the 2.40 USD loss). + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_account_balance WHERE tenant_id='{tenant}' AND account_id='{fx_gl}'" + )).await, + Some(240), + "FX_GAIN_LOSS functional balance = the 2.40 USD realized loss" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_audit.rs b/gears/bss/ledger/ledger/tests/postgres_audit.rs new file mode 100644 index 000000000..b1a840b4b --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_audit.rs @@ -0,0 +1,541 @@ +//! Postgres-only end-to-end for the secured audit store (Slice 6 Phase 2 Group +//! 2A). Boots a container and migrates, then drives `SecuredAuditStore::append` +//! inside a transaction and asserts the sealed-record + chain invariants: a +//! first append is sealed (row_hash/prev_hash non-NULL) with the tenant genesis +//! prev_hash and writes the tip; a second append links onto the first +//! (`prev_hash == first.row_hash`); a raw UPDATE/DELETE on the record is +//! rejected (append-only); a clean re-walk recomputes the seal exactly, and a +//! tampered row (trigger disabled + UPDATE) breaks the recompute. +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use bss_ledger::domain::audit_chain::{AuditHashInput, audit_genesis_prev_hash, audit_row_hash}; +use bss_ledger::infra::audit::event_type::AuditEventType; +use bss_ledger::infra::audit::store::SecuredAuditStore; +use bss_ledger::infra::storage::migrations::Migrator; +use chrono::{DateTime, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use serde_json::json; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::{AccessScope, TxConfig}; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Hex string of a single-column, single-row SELECT, `None` when absent/NULL. +async fn scalar_hex(conn: &DatabaseConnection, sql: &str) -> Option { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.and_then(|r| r.try_get_by_index::>(0).unwrap()) +} + +/// Hex string of a 32-byte hash for embedding in a Postgres text comparison. +fn hex32(bytes: &[u8; 32]) -> String { + let mut s = String::with_capacity(64); + for b in bytes { + use std::fmt::Write as _; + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Boot, migrate, return the migrate connection + the bss-search-path provider. +async fn setup(container_url: &str) -> (DatabaseConnection, DBProvider) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + (raw, DBProvider::::new(tdb)) +} + +/// Append one record in its own transaction, returning the generated audit id. +#[allow(clippy::too_many_arguments)] +async fn append( + provider: &DBProvider, + tenant: Uuid, + event_type: AuditEventType, + actor_ref: Option, + reason_code: Option, + before_after: serde_json::Value, + correlation_id: Option, + retain_until: Option>, +) -> Uuid { + let scope = AccessScope::for_tenant(tenant); + provider + .transaction(move |txn| { + Box::pin(async move { + SecuredAuditStore::new() + .append( + txn, + &scope, + tenant, + event_type, + actor_ref.as_deref(), + reason_code.as_deref(), + &before_after, + correlation_id, + retain_until, + ) + .await + }) + }) + .await + .expect("append must succeed") +} + +/// A first append is sealed (row_hash/prev_hash non-NULL), its prev_hash is the +/// tenant genesis seed, and the tip points at it. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn appends_first_record_sealed_with_genesis_prev() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider) = setup(&url).await; + let tenant = Uuid::now_v7(); + + let audit_id = append( + &provider, + tenant, + AuditEventType::MetadataChange, + Some("actor-1".to_owned()), + Some("rc-1".to_owned()), + json!({"before": 1, "after": 2}), + Some(Uuid::now_v7()), + None, + ) + .await; + + let row_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.secured_audit_record WHERE audit_id='{audit_id}'" + ), + ) + .await; + assert!(row_hash.is_some(), "row_hash must be sealed (non-NULL)"); + let row_hash = row_hash.unwrap(); + + let prev_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(prev_hash,'hex') FROM bss.secured_audit_record WHERE audit_id='{audit_id}'" + ), + ) + .await + .expect("prev_hash must be set"); + assert_eq!( + prev_hash, + hex32(&audit_genesis_prev_hash(tenant)), + "first record prev_hash must be the tenant genesis seed" + ); + + let tip_row_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(last_row_hash,'hex') FROM bss.audit_chain_state WHERE tenant_id='{tenant}'" + ), + ) + .await + .expect("audit_chain_state tip must exist"); + assert_eq!( + tip_row_hash, row_hash, + "tip last_row_hash must equal row_hash" + ); + + let tip_points: i64 = raw + .query_one(pg(format!( + "SELECT COUNT(*) FROM bss.audit_chain_state \ + WHERE tenant_id='{tenant}' AND last_audit_id='{audit_id}' AND last_seq=1" + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()); + assert_eq!( + tip_points, 1, + "tip must reference the sealed audit id at seq 1" + ); +} + +/// A second append links onto the first: its prev_hash equals the first's +/// row_hash, and the tip advances to seq 2. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn links_second_record_to_first() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider) = setup(&url).await; + let tenant = Uuid::now_v7(); + + let first = append( + &provider, + tenant, + AuditEventType::ConfigChange, + None, + None, + json!({}), + None, + None, + ) + .await; + let second = append( + &provider, + tenant, + AuditEventType::Erasure, + Some("actor-2".to_owned()), + None, + json!({"x": 1}), + None, + None, + ) + .await; + + let first_row_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.secured_audit_record WHERE audit_id='{first}'" + ), + ) + .await + .expect("first row_hash"); + let second_prev_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(prev_hash,'hex') FROM bss.secured_audit_record WHERE audit_id='{second}'" + ), + ) + .await + .expect("second prev_hash"); + assert_eq!( + second_prev_hash, first_row_hash, + "second record prev_hash must equal first record row_hash" + ); + + let tip_seq: i64 = raw + .query_one(pg(format!( + "SELECT last_seq FROM bss.audit_chain_state WHERE tenant_id='{tenant}'" + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()); + assert_eq!(tip_seq, 2, "tip must advance to seq 2"); +} + +/// The append-only trigger rejects a raw UPDATE and DELETE on a sealed record. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn append_only_trigger_rejects_update_and_delete() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider) = setup(&url).await; + let tenant = Uuid::now_v7(); + + let audit_id = append( + &provider, + tenant, + AuditEventType::FreezeSetClear, + None, + None, + json!({}), + None, + None, + ) + .await; + + let upd = raw + .execute(pg(format!( + "UPDATE bss.secured_audit_record SET reason_code='X' WHERE audit_id='{audit_id}'" + ))) + .await; + let upd_err = upd.expect_err("an UPDATE must be rejected").to_string(); + assert!( + upd_err.contains("append-only"), + "UPDATE error must mention append-only, got: {upd_err}" + ); + + let del = raw + .execute(pg(format!( + "DELETE FROM bss.secured_audit_record WHERE audit_id='{audit_id}'" + ))) + .await; + let del_err = del.expect_err("a DELETE must be rejected").to_string(); + assert!( + del_err.contains("append-only"), + "DELETE error must mention append-only, got: {del_err}" + ); +} + +/// A clean re-walk recomputes each record's row_hash exactly; a tampered row +/// (trigger disabled + row_hash overwritten) breaks the recompute. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn rewalk_detects_tamper() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider) = setup(&url).await; + let tenant = Uuid::now_v7(); + + // A sealed 2-link chain. + let first = append( + &provider, + tenant, + AuditEventType::MetadataChange, + Some("a-1".to_owned()), + None, + json!({"k": 1}), + None, + None, + ) + .await; + let _second = append( + &provider, + tenant, + AuditEventType::MetadataChange, + Some("a-2".to_owned()), + None, + json!({"k": 2}), + None, + None, + ) + .await; + + // Clean re-walk: recompute the FIRST record's row_hash from its stored + // columns (genesis prev) and assert it matches the sealed value. + let stored_first_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.secured_audit_record WHERE audit_id='{first}'" + ), + ) + .await + .expect("first row_hash"); + // Microseconds since the Unix epoch. For current-era timestamps the value + // (~1.8e15) is well within f64's exact-integer range (< 2^53), so the + // epoch-seconds product and the bigint cast are lossless — the recompute + // matches the seal exactly. + let row = raw + .query_one(pg(format!( + "SELECT event_type, actor_ref, reason_code, before_after::text, \ + correlation_id::text, (EXTRACT(epoch FROM at_utc) * 1000000)::bigint \ + FROM bss.secured_audit_record WHERE audit_id='{first}'" + ))) + .await + .unwrap() + .expect("first record row"); + let event_type: String = row.try_get_by_index(0).unwrap(); + let actor_ref: Option = row.try_get_by_index(1).unwrap(); + let reason_code: Option = row.try_get_by_index(2).unwrap(); + let before_after_text: String = row.try_get_by_index(3).unwrap(); + let correlation_text: Option = row.try_get_by_index(4).unwrap(); + let at_micros: i64 = row.try_get_by_index(5).unwrap(); + + let before_after: serde_json::Value = serde_json::from_str(&before_after_text).unwrap(); + let correlation_id = correlation_text.map(|s| Uuid::parse_str(&s).unwrap()); + let at_utc = DateTime::::from_timestamp_micros(at_micros).unwrap(); + + let recomputed = audit_row_hash( + &AuditHashInput { + audit_id: first, + tenant_id: tenant, + event_type: &event_type, + actor_ref: actor_ref.as_deref(), + reason_code: reason_code.as_deref(), + correlation_id, + at_utc, + before_after: &before_after, + }, + &audit_genesis_prev_hash(tenant), + ) + .expect("recompute audit row_hash"); + assert_eq!( + hex32(&recomputed), + stored_first_hash, + "clean re-walk must recompute the sealed row_hash exactly" + ); + + // Tamper the first record's row_hash out-of-band (the append-only trigger + // forbids any UPDATE, so disable it for the tamper and re-enable after). + raw.execute(pg( + "ALTER TABLE bss.secured_audit_record DISABLE TRIGGER trg_secured_audit_append_only", + )) + .await + .unwrap(); + raw.execute(pg(format!( + "UPDATE bss.secured_audit_record \ + SET row_hash = decode('deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef','hex') \ + WHERE audit_id='{first}'" + ))) + .await + .unwrap(); + raw.execute(pg( + "ALTER TABLE bss.secured_audit_record ENABLE TRIGGER trg_secured_audit_append_only", + )) + .await + .unwrap(); + + let tampered_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.secured_audit_record WHERE audit_id='{first}'" + ), + ) + .await + .expect("tampered row_hash"); + assert_ne!( + hex32(&recomputed), + tampered_hash, + "a tampered row_hash must NOT match the recompute" + ); +} + +/// Collect a single text column over many rows into a `Vec`. +async fn fetch_hex_set(conn: &DatabaseConnection, sql: &str) -> Vec { + conn.query_all(pg(sql.to_owned())) + .await + .unwrap() + .into_iter() + .map(|r| r.try_get_by_index::(0).unwrap()) + .collect() +} + +/// Z4-5b: concurrent appends to one tenant's audit chain form a SINGLE linear +/// chain (no fork) — the same SSI invariant the journal chain relies on +/// (mirrors `postgres_chain.rs::concurrent_posts_form_linear_chain`). The audit +/// `append` does a lockless tip read-then-advance, so without SERIALIZABLE two +/// racing appends could seal onto the same prev_hash and fork; here every +/// committed record links onto a distinct parent. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_appends_form_linear_audit_chain() { + const N: usize = 8; + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider) = setup(&url).await; + let tenant = Uuid::now_v7(); + + // Race N appends onto the per-tenant audit tip. A SERIALIZABLE loser aborts + // (SSI 40001, retryable) and retries from a fresh tip — a bounded manual loop + // here stands in for the posting service's `transaction_with_retry`. + let mut handles = Vec::with_capacity(N); + for i in 0..N { + let p = provider.clone(); + handles.push(tokio::spawn(async move { + for _ in 0..50 { + let scope = AccessScope::for_tenant(tenant); + let ba = json!({ "i": i }); + let res: Result = p + .transaction_with_config(TxConfig::serializable(), move |txn| { + Box::pin(async move { + SecuredAuditStore::new() + .append( + txn, + &scope, + tenant, + AuditEventType::ConfigChange, + None, + None, + &ba, + None, + None, + ) + .await + }) + }) + .await; + if res.is_ok() { + return; + } + } + panic!("append {i} did not converge under contention"); + })); + } + for h in handles { + h.await.unwrap(); + } + + // Every append is sealed and the row_hashes are all distinct. + let row_hashes = fetch_hex_set( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.secured_audit_record \ + WHERE tenant_id='{tenant}' ORDER BY at_utc" + ), + ) + .await; + assert_eq!(row_hashes.len(), N, "every append must be sealed"); + let mut distinct = row_hashes.clone(); + distinct.sort(); + distinct.dedup(); + assert_eq!(distinct.len(), N, "all row_hash must be distinct"); + + // No two records share a prev_hash (a fork would re-use a prev link); exactly + // one links to genesis, and every other prev_hash matches some row_hash. + let prev_hashes = fetch_hex_set( + &raw, + &format!( + "SELECT encode(prev_hash,'hex') FROM bss.secured_audit_record \ + WHERE tenant_id='{tenant}'" + ), + ) + .await; + let mut distinct_prev = prev_hashes.clone(); + distinct_prev.sort(); + distinct_prev.dedup(); + assert_eq!( + distinct_prev.len(), + N, + "no two records may share a prev_hash (single linear chain)" + ); + let genesis = hex32(&audit_genesis_prev_hash(tenant)); + assert_eq!( + prev_hashes.iter().filter(|h| **h == genesis).count(), + 1, + "exactly one record links to the genesis seed" + ); + for prev in &prev_hashes { + if *prev == genesis { + continue; + } + assert!( + row_hashes.contains(prev), + "every non-genesis prev_hash must match another record's row_hash" + ); + } + + // The tip advanced exactly N times. + let tip_seq: i64 = raw + .query_one(pg(format!( + "SELECT last_seq FROM bss.audit_chain_state WHERE tenant_id='{tenant}'" + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()); + assert_eq!( + tip_seq, + i64::try_from(N).unwrap(), + "the audit tip must advance once per committed append" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_balance_caches.rs b/gears/bss/ledger/ledger/tests/postgres_balance_caches.rs new file mode 100644 index 000000000..8f5ab597f --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_balance_caches.rs @@ -0,0 +1,94 @@ +//! Postgres-only integration tests for the balance-cache tables. +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. +//! +//! Asserts the conditional no-negative CHECK rejects a negative `AR` +//! `account_balance` row and allows a negative `tax_subbalance` (no +//! aggregate guard on tax sub-balances). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown +)] + +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use uuid::Uuid; + +use bss_ledger::infra::storage::migrations::Migrator; + +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + DatabaseConnection, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let db = Database::connect(&url).await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + (container, db) +} + +fn exec(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn account_balance_rejects_negative_ar() { + let (_c, db) = boot().await; + let tenant_id = Uuid::new_v4(); + let account_id = Uuid::new_v4(); + + let err = db + .execute(exec(format!( + "INSERT INTO bss.ledger_account_balance + (tenant_id, account_id, currency, account_class, normal_side, balance_minor) + VALUES ('{tenant_id}', '{account_id}', 'USD', 'AR', 'DR', -5)" + ))) + .await + .expect_err("negative AR balance must be rejected by CHECK"); + assert!( + err.to_string().contains("chk_account_balance_no_negative"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn account_balance_allows_negative_revenue() { + let (_c, db) = boot().await; + let tenant_id = Uuid::new_v4(); + let account_id = Uuid::new_v4(); + + // REVENUE is not in the no-negative class set; a credit-normal balance + // may legitimately be negative on the cache row's signed convention. + db.execute(exec(format!( + "INSERT INTO bss.ledger_account_balance + (tenant_id, account_id, currency, account_class, normal_side, balance_minor) + VALUES ('{tenant_id}', '{account_id}', 'USD', 'REVENUE', 'CR', -100)" + ))) + .await + .expect("negative REVENUE balance must be allowed"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn tax_subbalance_allows_negative() { + let (_c, db) = boot().await; + let tenant_id = Uuid::new_v4(); + let account_id = Uuid::new_v4(); + + db.execute(exec(format!( + "INSERT INTO bss.ledger_tax_subbalance + (tenant_id, account_id, tax_jurisdiction, tax_filing_period, balance_minor) + VALUES ('{tenant_id}', '{account_id}', 'US-CA', '2026Q2', -42)" + ))) + .await + .expect("tax_subbalance has no aggregate no-negative guard"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_bola.rs b/gears/bss/ledger/ledger/tests/postgres_bola.rs new file mode 100644 index 000000000..7ed3d72f0 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_bola.rs @@ -0,0 +1,118 @@ +//! Postgres-only regression test for SQL-level cross-tenant BOLA on reads. +//! +//! Proves the degraded flat-`In` PDP scope is compiled into the SecureORM WHERE +//! clause: a tenant-A scope reading tenant-B's chart of accounts yields ZERO +//! rows — independent of the caller-supplied `tenant_id` filter — and a by-id +//! read of a B-owned account returns `None`. This closes the gap where the +//! highest-value isolation guarantee was only covered at e2e (live cluster), +//! never at the Rust layer. Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_bola -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown +)] + +use bss_ledger::domain::model::AccountRow; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::ODataQuery; +use sea_orm::Database; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +/// A minimal OPEN `AR`/`DR`/`USD` chart-of-accounts row for `tenant`. +fn account_row(tenant: Uuid) -> AccountRow { + AccountRow { + account_id: Uuid::new_v4(), + tenant_id: tenant, + legal_entity_id: tenant, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_reference_reads_are_sql_scoped_to_empty() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let db = Database::connect(&url).await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + + // Repo connection runs with search_path=bss (as the gear does in prod) so + // its unqualified entity queries resolve into the bss schema. + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + let repo = ReferenceRepo::new(provider); + + // Two tenants, each seeded with exactly one account. + let tenant_a = Uuid::new_v4(); + let tenant_b = Uuid::new_v4(); + let a_account = account_row(tenant_a); + let b_account = account_row(tenant_b); + let a_id = a_account.account_id; + let b_id = b_account.account_id; + repo.insert_account(a_account).await.expect("seed A"); + repo.insert_account(b_account).await.expect("seed B"); + + let scope_a = AccessScope::for_tenant(tenant_a); + + // Positive control: A's own scope sees A's own account — so an empty result + // below means "scoped out", not "the store is simply empty". + let own = repo + .list_accounts(&scope_a, tenant_a, &ODataQuery::default()) + .await + .expect("list own"); + assert_eq!(own.items.len(), 1, "A's scope must see A's own account"); + assert_eq!(own.items[0].account_id, a_id); + + // BOLA: A's scope asking for B's accounts yields ZERO. B genuinely HAS an + // account (a populated outsider), so this proves the scope predicate + // overrides the caller-supplied `tenant_id = B` filter at the SQL layer. + let cross = repo + .list_accounts(&scope_a, tenant_b, &ODataQuery::default()) + .await + .expect("list cross"); + assert!( + cross.items.is_empty(), + "A's scope must NOT see B's accounts (SQL-level BOLA); got {} row(s)", + cross.items.len() + ); + + // BOLA on a by-id read: A's scope cannot resolve a B-owned account by id. + let b_via_a = repo.find_account(&scope_a, b_id).await.expect("find cross"); + assert!( + b_via_a.is_none(), + "A's scope must not resolve a B-owned account id" + ); + // Positive control: A's scope resolves its own account by id. + let a_via_a = repo.find_account(&scope_a, a_id).await.expect("find own"); + assert_eq!(a_via_a.map(|r| r.account_id), Some(a_id)); + + // Symmetric: B's scope cannot see A's accounts either. + let scope_b = AccessScope::for_tenant(tenant_b); + let cross_b = repo + .list_accounts(&scope_b, tenant_a, &ODataQuery::default()) + .await + .expect("list cross b"); + assert!( + cross_b.items.is_empty(), + "B's scope must NOT see A's accounts" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_chain.rs b/gears/bss/ledger/ledger/tests/postgres_chain.rs new file mode 100644 index 000000000..b167afce0 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_chain.rs @@ -0,0 +1,975 @@ +//! Postgres-only end-to-end: the in-transaction tamper-evidence chain seal. +//! Boots a container, migrates, seeds reference data, then drives the full +//! post sequence and asserts the chain columns: a first post seals with the +//! tenant genesis `prev_hash` and writes the `chain_state` tip; a second post +//! links onto the first; N concurrent posts form a single linear chain. The +//! append-only trigger negatives (a re-seal, a business-column UPDATE, and a +//! DELETE) are exercised with raw SQL against a posted-and-sealed entry. +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use bss_ledger::domain::chain::genesis_prev_hash; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::jobs::verifier::ChainVerifierJob; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Hex string of a single-column, single-row SELECT (e.g. `encode(col,'hex')`), +/// `None` when the row is absent or the value is NULL. +async fn scalar_hex(conn: &DatabaseConnection, sql: &str) -> Option { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.and_then(|r| r.try_get_by_index::>(0).unwrap()) +} + +/// Hex string of a 32-byte hash for embedding in a Postgres text comparison. +fn hex32(bytes: &[u8; 32]) -> String { + let mut s = String::with_capacity(64); + for b in bytes { + use std::fmt::Write as _; + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Count rows matching a bss-qualified predicate. +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +struct Fixture { + tenant: Uuid, + ar_account: Uuid, + cash_account: Uuid, + legal_entity: Uuid, + period_id: String, +} + +/// Boot, migrate, seed USD@2 + OPEN period + AR/CASH accounts; return the +/// migrate connection, the service, the provider, and the fixture ids. +/// (Mirrors `postgres_posting.rs::setup`.) +async fn setup( + container_url: &str, +) -> ( + DatabaseConnection, + PostingService, + DBProvider, + Fixture, +) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = "202606".to_owned(); + let ar_account = Uuid::now_v7(); + let cash_account = Uuid::now_v7(); + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{legal_entity}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + + reference + .insert_account(AccountRow { + account_id: ar_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + reference + .insert_account(AccountRow { + account_id: cash_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "CASH_CLEARING".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "CR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + let service = PostingService::new( + provider.clone(), + std::sync::Arc::new(bss_ledger::infra::events::publisher::LedgerEventPublisher::noop()), + ); + ( + raw, + service, + provider, + Fixture { + tenant, + ar_account, + cash_account, + legal_entity, + period_id, + }, + ) +} + +/// Build a balanced entry for `fixture` with `business_id`: DR AR / CR CASH, +/// each `amount`. (Mirrors `postgres_posting.rs::balanced_entry`, no swap.) +fn balanced_entry(f: &Fixture, business_id: &str, amount: i64) -> (NewEntry, Vec) { + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: f.tenant, + legal_entity_id: f.legal_entity, + period_id: f.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: business_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: f.tenant, + correlation_id: f.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + line(f, f.ar_account, AccountClass::Ar, Side::Debit, amount), + line( + f, + f.cash_account, + AccountClass::CashClearing, + Side::Credit, + amount, + ), + ]; + (entry, lines) +} + +fn line(f: &Fixture, account: Uuid, class: AccountClass, side: Side, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + ar_status: None, + payer_tenant_id: f.tenant, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + } +} + +/// First post seals with the tenant genesis `prev_hash`, a non-NULL `row_hash`, +/// a NULL `prev_entry_id`, and writes a `chain_state` tip pointing at it. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn seals_first_post_with_genesis_prev() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + let (entry, lines) = balanced_entry(&f, "biz-1", 1000); + let entry_id = entry.entry_id; + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("post must succeed"); + + // row_hash is sealed (non-NULL). + let row_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.ledger_journal_entry WHERE entry_id='{entry_id}'" + ), + ) + .await; + assert!(row_hash.is_some(), "row_hash must be sealed (non-NULL)"); + let row_hash = row_hash.unwrap(); + + // prev_hash == genesis_prev_hash(tenant). + let prev_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(prev_hash,'hex') FROM bss.ledger_journal_entry WHERE entry_id='{entry_id}'" + ), + ) + .await + .expect("prev_hash must be set"); + assert_eq!( + prev_hash, + hex32(&genesis_prev_hash(f.tenant)), + "first post prev_hash must be the tenant genesis seed" + ); + + // prev_entry_id IS NULL at genesis. + let prev_entry_nulls = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE entry_id='{entry_id}' AND prev_entry_id IS NULL" + ), + ) + .await; + assert_eq!(prev_entry_nulls, 1, "genesis prev_entry_id must be NULL"); + + // chain_state tip's last_row_hash equals this entry's row_hash; the tip's + // last_entry_id (a uuid) is asserted separately by the count below. + let tip_row_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(last_row_hash,'hex') FROM bss.chain_state WHERE tenant_id='{}'", + f.tenant + ), + ) + .await + .expect("chain_state tip must exist"); + assert_eq!( + tip_row_hash, row_hash, + "tip last_row_hash must equal row_hash" + ); + + let tip_points_at_entry = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.chain_state \ + WHERE tenant_id='{}' AND last_entry_id='{entry_id}'", + f.tenant + ), + ) + .await; + assert_eq!( + tip_points_at_entry, 1, + "chain_state tip must reference the sealed entry id" + ); +} + +/// A second post for the same tenant links onto the first: its `prev_hash` is +/// the first entry's `row_hash`, and its prev pointers name the first entry. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn links_second_post_to_first() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + let (e1, l1) = balanced_entry(&f, "biz-1", 1000); + let first_id = e1.entry_id; + service + .post(&ctx, &scope, e1, l1, None) + .await + .expect("post 1"); + + let (e2, l2) = balanced_entry(&f, "biz-2", 2000); + let second_id = e2.entry_id; + service + .post(&ctx, &scope, e2, l2, None) + .await + .expect("post 2"); + + let first_row_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.ledger_journal_entry WHERE entry_id='{first_id}'" + ), + ) + .await + .expect("first row_hash"); + let second_prev_hash = scalar_hex( + &raw, + &format!( + "SELECT encode(prev_hash,'hex') FROM bss.ledger_journal_entry WHERE entry_id='{second_id}'" + ), + ) + .await + .expect("second prev_hash"); + assert_eq!( + second_prev_hash, first_row_hash, + "second entry prev_hash must equal first entry row_hash" + ); + + // prev_entry_id and prev_period_id of the second point at the first. + let links = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE entry_id='{second_id}' AND prev_entry_id='{first_id}' \ + AND prev_period_id='{}'", + f.period_id + ), + ) + .await; + assert_eq!( + links, 1, + "second entry prev pointers must name the first entry" + ); +} + +/// N concurrent posts for one tenant form a single linear chain: all `row_hash` +/// distinct, no two share a `prev_hash`, and every non-genesis `prev_hash` +/// matches exactly one other entry's `row_hash`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_posts_form_linear_chain() { + const N: usize = 8; + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + + // Concurrent burst: races the in-txn seal on the per-tenant `chain_state` + // hot row. Under the bounded SERIALIZABLE retry budget some posts may lose + // the race (chain_state + shared balance-row contention) and roll back fully + // — acceptable: a rolled-back post never enters the chain (no orphan, no + // gap). The invariant under test is that whatever commits never forks. + let mut handles = Vec::with_capacity(N); + for i in 0..N { + let svc = service.clone(); + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + let amount = 1000 + i64::try_from(i).unwrap(); + let (entry, lines) = balanced_entry(&f, &format!("biz-conc-{i}"), amount); + handles.push(tokio::spawn(async move { + svc.post(&ctx, &scope, entry, lines, None).await + })); + } + for h in handles { + // Ignore individual contention losses; the settle pass below lands them. + let _ = h.await.unwrap(); + } + + // Settle: re-post each business key sequentially. Idempotent on the business + // key — a post that committed in the burst replays (same payload hash → the + // prior entry), a rolled-back one posts fresh — so all N keys end with + // exactly one committed, sealed entry. This also proves the chain is + // gap-free under concurrency. + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + for i in 0..N { + let amount = 1000 + i64::try_from(i).unwrap(); + let (entry, lines) = balanced_entry(&f, &format!("biz-conc-{i}"), amount); + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("sequential settle post must succeed"); + } + + // All sealed entries for the tenant. + let row_hashes = fetch_hex_set( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND row_hash IS NOT NULL ORDER BY created_seq", + f.tenant + ), + ) + .await; + assert_eq!(row_hashes.len(), N, "every post must be sealed"); + + // row_hash all distinct. + let mut distinct = row_hashes.clone(); + distinct.sort(); + distinct.dedup(); + assert_eq!(distinct.len(), N, "all row_hash must be distinct"); + + // prev_hash of all entries (genesis included). + let prev_hashes = fetch_hex_set( + &raw, + &format!( + "SELECT encode(prev_hash,'hex') FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' ORDER BY created_seq", + f.tenant + ), + ) + .await; + assert_eq!(prev_hashes.len(), N, "every entry has a prev_hash"); + + // No two entries share a prev_hash (a fork would re-use a prev link). + let mut distinct_prev = prev_hashes.clone(); + distinct_prev.sort(); + distinct_prev.dedup(); + assert_eq!( + distinct_prev.len(), + N, + "no two entries may share a prev_hash (single linear chain)" + ); + + // Exactly one genesis link; every other prev_hash matches some row_hash. + let genesis = hex32(&genesis_prev_hash(f.tenant)); + let genesis_count = prev_hashes.iter().filter(|h| **h == genesis).count(); + assert_eq!(genesis_count, 1, "exactly one entry links to genesis"); + for prev in &prev_hashes { + if *prev == genesis { + continue; + } + assert!( + row_hashes.contains(prev), + "every non-genesis prev_hash must match another entry's row_hash" + ); + } + + // SERIALIZABLE chain-tip contract: the structural checks above + // prove no fork; now prove the Verifier AGREES the chain verifies clean + // after a concurrent burst (no break → no freeze). This ties the lockless + // read-then-advance to the real verify path — if a future change downgrades + // the post below SERIALIZABLE and a fork slips through, the Verifier here + // would detect the break and freeze, failing this assertion. + ChainVerifierJob::new( + provider.clone(), + std::sync::Arc::new(LedgerEventPublisher::noop()), + std::sync::Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + ) + .run() + .await + .expect("verify run"); + + let active_freezes = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.scope_freeze \ + WHERE tenant_id='{}' AND cleared_at IS NULL", + f.tenant + ), + ) + .await; + assert_eq!( + active_freezes, 0, + "concurrent seals must leave a clean, verifiable chain (no fork → no freeze)" + ); +} + +/// Z3-1: a tip ROLLBACK orphans the sealed rows above it. `chain_state` is a +/// MUTABLE tip cache (no append-only guard), so a writer who redirects it to an +/// older entry hides every newer sealed row — the walk-from-tip still verifies +/// clean. The Verifier must reconcile the walked count against the total sealed +/// rows and freeze the tenant on the surplus. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn tip_rollback_orphaning_sealed_rows_freezes_tenant() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // genesis <- entry1 <- entry2; the tip points at entry2. + let (e1, l1) = balanced_entry(&f, "biz-rollback-1", 1000); + let e1_id = e1.entry_id; + service + .post(&ctx, &scope, e1, l1, None) + .await + .expect("post 1"); + let (e2, l2) = balanced_entry(&f, "biz-rollback-2", 2000); + service + .post(&ctx, &scope, e2, l2, None) + .await + .expect("post 2"); + + // Roll the visible tip back to entry1: entry2 is now a sealed row NEWER than + // the tip — orphaned, never reached by the tip-to-genesis walk. + raw.execute(pg(format!( + "UPDATE bss.chain_state SET last_entry_id='{e1_id}', last_period_id='{}' \ + WHERE tenant_id='{}'", + f.period_id, f.tenant + ))) + .await + .unwrap(); + + // The walk-from-tip alone (entry1 -> genesis) is clean; only the count + // reconciliation can catch the orphaned entry2. + ChainVerifierJob::new( + provider.clone(), + std::sync::Arc::new(LedgerEventPublisher::noop()), + std::sync::Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + ) + .run() + .await + .expect("verify run"); + + let active_freezes = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.scope_freeze \ + WHERE tenant_id='{}' AND cleared_at IS NULL", + f.tenant + ), + ) + .await; + assert_eq!( + active_freezes, 1, + "a tip rollback that orphans sealed rows must freeze the tenant" + ); +} + +/// Z3-2 (§5.2): when the Verifier freezes a tenant it must also write a +/// `freeze-set-clear` secured-audit record in the SAME transaction — a +/// tamper-evident record of who froze the tenant and why. Before the fix the +/// freeze touched only `scope_freeze` (a mutable, non-chained row). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn verifier_freeze_writes_freeze_set_clear_audit_record() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // Two posts, then roll the tip back so the Verifier detects an orphan and + // freezes (reuses the Z3-1 path — any freeze must write the audit record). + let (e1, l1) = balanced_entry(&f, "biz-fsc-1", 1000); + let e1_id = e1.entry_id; + service + .post(&ctx, &scope, e1, l1, None) + .await + .expect("post 1"); + let (e2, l2) = balanced_entry(&f, "biz-fsc-2", 2000); + service + .post(&ctx, &scope, e2, l2, None) + .await + .expect("post 2"); + raw.execute(pg(format!( + "UPDATE bss.chain_state SET last_entry_id='{e1_id}', last_period_id='{}' \ + WHERE tenant_id='{}'", + f.period_id, f.tenant + ))) + .await + .unwrap(); + + ChainVerifierJob::new( + provider.clone(), + std::sync::Arc::new(LedgerEventPublisher::noop()), + std::sync::Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + ) + .run() + .await + .expect("verify run"); + + let frozen = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.scope_freeze \ + WHERE tenant_id='{}' AND cleared_at IS NULL", + f.tenant + ), + ) + .await; + assert_eq!(frozen, 1, "the tenant must be frozen (precondition)"); + + let audit_records = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='freeze-set-clear'", + f.tenant + ), + ) + .await; + assert_eq!( + audit_records, 1, + "a freeze must write exactly one freeze-set-clear secured-audit record (§5.2)" + ); +} + +/// Collect a single text column over many rows into a `Vec`. +async fn fetch_hex_set(conn: &DatabaseConnection, sql: &str) -> Vec { + let rows = conn.query_all(pg(sql.to_owned())).await.unwrap(); + rows.into_iter() + .map(|r| r.try_get_by_index::(0).unwrap()) + .collect() +} + +/// The append-only trigger negatives (Group B B3) against a posted-and-sealed +/// entry: a re-seal, a business-column UPDATE, and a DELETE must each raise an +/// `append-only` exception. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn append_only_trigger_rejects_reseal_update_and_delete() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + let (entry, lines) = balanced_entry(&f, "biz-seal", 1000); + let entry_id = entry.entry_id; + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("post must succeed and seal"); + + // (a) Re-seal: a second from-non-NULL UPDATE of row_hash is rejected + // (the row is already sealed). + let reseal = raw + .execute(pg(format!( + "UPDATE bss.ledger_journal_entry SET row_hash='\\x00' WHERE entry_id='{entry_id}'" + ))) + .await; + let reseal_err = reseal.expect_err("a re-seal must be rejected").to_string(); + assert!( + reseal_err.contains("append-only"), + "re-seal error must mention append-only, got: {reseal_err}" + ); + + // (b) Business-column UPDATE is rejected (only chain columns may change). + let biz = raw + .execute(pg(format!( + "UPDATE bss.ledger_journal_entry SET origin='X' WHERE entry_id='{entry_id}'" + ))) + .await; + let biz_err = biz + .expect_err("a business-column UPDATE must be rejected") + .to_string(); + assert!( + biz_err.contains("append-only"), + "business UPDATE error must mention append-only, got: {biz_err}" + ); + + // (c) DELETE is rejected. + let del = raw + .execute(pg(format!( + "DELETE FROM bss.ledger_journal_entry WHERE entry_id='{entry_id}'" + ))) + .await; + let del_err = del.expect_err("a DELETE must be rejected").to_string(); + assert!( + del_err.contains("append-only"), + "DELETE error must mention append-only, got: {del_err}" + ); +} + +/// An ACTIVE tenant-wide freeze (`scope_freeze`) blocks a fresh post with +/// `TamperVerificationFailed`; clearing the freeze lets posting resume. Drives +/// the `TamperFreezeGuard` fail-fast gate end-to-end against Postgres. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn frozen_scope_blocks_posting() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // The integrity verifier froze this tenant tenant-wide (period_id='ALL'). + raw.execute(pg(format!( + "INSERT INTO bss.scope_freeze (tenant_id, scope, period_id, reason, set_by) \ + VALUES ('{}','tenant','ALL','test','Verifier')", + f.tenant + ))) + .await + .unwrap(); + + // A fresh post into the frozen scope is rejected before any write. + let (entry, lines) = balanced_entry(&f, "biz-frozen", 1000); + let err = service + .post(&ctx, &scope, entry, lines, None) + .await + .expect_err("post into a frozen scope must be rejected"); + assert!( + matches!(err, DomainError::TamperVerificationFailed(_)), + "frozen post must fail with TamperVerificationFailed, got: {err:?}" + ); + + // Clearing the freeze lets the same tenant post again (refs already seeded). + raw.execute(pg(format!( + "UPDATE bss.scope_freeze SET cleared_at = now(), cleared_by = 'Operator' \ + WHERE tenant_id = '{}' AND scope = 'tenant' AND period_id = 'ALL'", + f.tenant + ))) + .await + .unwrap(); + + let (entry, lines) = balanced_entry(&f, "biz-thawed", 1000); + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("post must succeed once the freeze is cleared"); +} + +/// True negative: the Verifier re-walks a clean (untampered) sealed chain and +/// does NOT freeze. Guards against a recompute that disagrees with the seal +/// encoding (e.g. a field round-trip mismatch) — which would false-positive and +/// freeze every healthy tenant, yet still "pass" the tampered-chain test. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn verifier_passes_clean_chain() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // A sealed 3-link chain, untouched. + for i in 0..3_i64 { + let (e, l) = balanced_entry(&f, &format!("biz-clean-{i}"), 1000 + i); + service + .post(&ctx, &scope, e, l, None) + .await + .expect("clean post"); + } + + ChainVerifierJob::new( + provider.clone(), + std::sync::Arc::new(LedgerEventPublisher::noop()), + std::sync::Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + ) + .run() + .await + .expect("verify run"); + + let active_freezes = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.scope_freeze \ + WHERE tenant_id='{}' AND cleared_at IS NULL", + f.tenant + ), + ) + .await; + assert_eq!( + active_freezes, 0, + "the Verifier must NOT freeze a clean chain (no false positive)" + ); + + // Posting continues to work after a clean verify. + let (e, l) = balanced_entry(&f, "biz-clean-after", 5000); + service + .post(&ctx, &scope, e, l, None) + .await + .expect("post must still succeed after a clean verify"); +} + +/// The daily chain Verifier re-walks a sealed 2-link chain, detects a `row_hash` +/// that was tampered out-of-band (the append-only trigger temporarily +/// disabled), freezes the tenant tenant-wide, and thereby blocks any further +/// post for that tenant with `TamperVerificationFailed`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn verifier_freezes_tampered_chain() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // Post two balanced entries — a sealed 2-link chain. + let (e1, l1) = balanced_entry(&f, "biz-1", 1000); + let first_id = e1.entry_id; + service + .post(&ctx, &scope, e1, l1, None) + .await + .expect("post 1"); + let (e2, l2) = balanced_entry(&f, "biz-2", 2000); + service + .post(&ctx, &scope, e2, l2, None) + .await + .expect("post 2"); + + // Tamper the FIRST entry's row_hash out-of-band. The append-only trigger + // forbids any post-seal UPDATE, so disable it for the tamper and re-enable + // it after (a real attacker with direct DB access; the chain re-walk is what + // catches it). `deadbeef` x8 = 64 hex chars = a 32-byte hash. + raw.execute(pg( + "ALTER TABLE bss.ledger_journal_entry DISABLE TRIGGER trg_journal_entry_append_guard", + )) + .await + .unwrap(); + raw.execute(pg(format!( + "UPDATE bss.ledger_journal_entry \ + SET row_hash = decode('deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef','hex') \ + WHERE entry_id = '{first_id}'" + ))) + .await + .unwrap(); + raw.execute(pg( + "ALTER TABLE bss.ledger_journal_entry ENABLE TRIGGER trg_journal_entry_append_guard", + )) + .await + .unwrap(); + + // Run the Verifier: a detected tamper is reported via a freeze + alarm, not + // an Err, so `run()` returns Ok. The noop publisher needs no broker. + ChainVerifierJob::new( + provider.clone(), + std::sync::Arc::new(LedgerEventPublisher::noop()), + std::sync::Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + ) + .run() + .await + .expect("verify run"); + + // An ACTIVE tenant-wide freeze now exists for the tenant. + let active_freezes = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.scope_freeze \ + WHERE tenant_id='{}' AND cleared_at IS NULL", + f.tenant + ), + ) + .await; + assert_eq!( + active_freezes, 1, + "the Verifier must freeze the tampered tenant tenant-wide" + ); + + // A subsequent post for that tenant is rejected by the freeze guard. + let (entry, lines) = balanced_entry(&f, "biz-after-tamper", 1000); + let err = service + .post(&ctx, &scope, entry, lines, None) + .await + .expect_err("post after a tamper freeze must be rejected"); + assert!( + matches!(err, DomainError::TamperVerificationFailed(_)), + "post after tamper must fail with TamperVerificationFailed, got: {err:?}" + ); +} + +/// Z2-1: pin the exact column set of `ledger_journal_entry`. The append-only +/// seal trigger (migration 000012) guards business columns by an EXPLICIT +/// blacklist — every business column is enumerated in its `ROW(NEW…) IS DISTINCT +/// FROM ROW(OLD…)` check. That is complete today, but a future migration that +/// adds a column and forgets to extend the trigger would make the new column +/// silently mutable during the one permitted seal UPDATE — a tamper-evidence +/// bypass on financial/identity data. This test breaks the moment a column is +/// added or removed, forcing a conscious "guard it in the trigger, or it's a new +/// chain column" decision. The set is exactly the 16 trigger-guarded business +/// columns + the 4 chain columns (`row_hash`, `prev_hash`, `prev_entry_id`, +/// `prev_period_id`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn journal_entry_column_set_is_pinned_for_trigger_drift() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, _service, _provider, _f) = setup(&url).await; + + let columns = fetch_hex_set( + &raw, + "SELECT column_name FROM information_schema.columns \ + WHERE table_schema='bss' AND table_name='ledger_journal_entry' \ + ORDER BY column_name", + ) + .await; + + // Sorted; mirrors the trigger's guarded set (000012) + the 4 chain columns. + let expected = [ + "correlation_id", + "created_seq", + "effective_at", + "entry_currency", + "entry_id", + "legal_entity_id", + "origin", + "period_id", + "posted_at_utc", + "posted_by_actor_id", + "prev_entry_id", + "prev_hash", + "prev_period_id", + "reverses_entry_id", + "reverses_period_id", + "rounding_evidence", + "row_hash", + "source_business_id", + "source_doc_type", + "tenant_id", + ]; + assert_eq!( + columns, expected, + "ledger_journal_entry columns changed — extend the append-only seal \ + trigger (migration 000012) to guard the new column, or treat it as a \ + new chain column, then update this pinned set" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_chargeback_concurrency.rs b/gears/bss/ledger/ledger/tests/postgres_chargeback_concurrency.rs new file mode 100644 index 000000000..363e8a9c8 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_chargeback_concurrency.rs @@ -0,0 +1,499 @@ +//! Postgres-only **concurrency** test for the chargeback slice (Group D): two +//! concurrent `ChargebackService::record_phase` posts on the SAME dispute +//! serialize under `SERIALIZABLE` (caller-retry, decision O). Ignored by default; +//! run with +//! `cargo test -p bss-ledger --test postgres_chargeback_concurrency -- --ignored`. +//! +//! Covers: a racing pair recording the SAME `lost` phase (same +//! `dispute_id:cycle:phase`) on one opened `CASH_HOLD` dispute must land EXACTLY +//! ONE ledger effect — the `(tenant, CHARGEBACK, business_id)` dedup admits one +//! winner; the loser replays the winner's finalized entry. The forfeiture posts +//! once and `clawed_back_minor` is NEVER double-counted (no double clawback). +//! Mirrors `postgres_payment_concurrency.rs`'s `retry_on_serialization` + +//! `tokio::join!` shape (and `postgres_payments::allocate_replay_makes_no_ +//! duplicate_rows`, the same-key racing-claim pattern). +//! +//! Self-contained: re-declares the small `boot` / `setup_seller` / `settle` +//! helpers it needs (mirrors `postgres_payment_concurrency.rs`), so it does not +//! depend on `postgres_chargebacks.rs`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::chargeback::{DisputePhase, FundsAtOpen}; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::chargeback::{ + ChargebackOutcome, ChargebackRequest, ChargebackService, +}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::PaymentRepo; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::{Datelike, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot a container, migrate on a raw connection, and return a `bss`-search-path +/// `DBProvider`. Mirrors `postgres_payment_concurrency::boot`. +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller for the chargeback concurrency test (the cash-hold dispute +/// classes + the settle pool). +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + dispute_hold: Uuid, + dispute_loss: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision a seller: USD@2 scale, an OPEN fiscal period for the current month, +/// and the cash-hold dispute chart accounts (CASH_CLEARING debit, UNALLOCATED +/// credit, DISPUTE_HOLD debit, DISPUTE_LOSS_EXPENSE debit). Mirrors +/// `postgres_payment_concurrency::setup_seller`. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + dispute_hold: Uuid::now_v7(), + dispute_loss: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + Uuid::now_v7(), + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.dispute_hold, + AccountClass::DisputeHold, + Side::Debit, + ), + account( + s.tenant, + s.dispute_loss, + AccountClass::DisputeLossExpense, + Side::Debit, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn chargeback_svc(provider: &DBProvider) -> ChargebackService { + ChargebackService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +/// Settle `gross` (fee 0) for `payment_id` — funds CASH_CLEARING + seeds the +/// settlement counter (`settled_minor = gross`) the clawback cap nets against. +async fn settle(provider: &DBProvider, s: &Seller, payment_id: &str, gross: i64) { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle must succeed"); +} + +/// Client-side retry of a service call on a projector-level serialization +/// conflict (40001 stringified into `DomainError::Internal("…serialize…")`). +/// Decision O defers recompute-on-retry to the CALLER, so the test models what +/// the SDK/client does: re-run the WHOLE operation until it commits or hits a +/// real business rejection. A genuine deadlock (40P01) is NOT a serialization +/// conflict and propagates — failing the test loudly (the deadlock-freedom +/// guarantee). Copied verbatim from `postgres_payment_concurrency.rs`. +async fn retry_on_serialization(mut op: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for _ in 0..20 { + match op().await { + Err(DomainError::Internal(m)) if m.contains("serialize") => {} + other => return other, + } + } + op().await +} + +/// Read a chart account's cached `balance_minor` (or `None` if never posted to). +async fn account_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + account: Uuid, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Chargeback #D-1: two concurrent records of the SAME `lost` phase on ONE +/// opened `CASH_HOLD` dispute (same `dispute_id:cycle:phase`, so the dedup key +/// COLLIDES) must land EXACTLY ONE ledger effect. Under `SERIALIZABLE` the two +/// posts serialize at the dispute row + the clawback counter; the +/// `(tenant, CHARGEBACK, business_id)` dedup admits one winner and the loser +/// replays the winner's finalized entry (the client retries a projector-level +/// 40001 via `retry_on_serialization`). The forfeiture posts once +/// (DISPUTE_LOSS_EXPENSE = 1000, DISPUTE_HOLD = 0) and `clawed_back_minor` is +/// NEVER double-counted (it lands at EXACTLY the disputed 1000 — no double +/// clawback). Mirrors `postgres_payments::allocate_replay_makes_no_duplicate_ +/// rows`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_lost_on_one_dispute_claws_back_once() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle 1000 (funds CASH_CLEARING + the settlement counter) and open a + // cash-hold dispute (moves the 1000 into DISPUTE_HOLD). + settle(&provider, &s, "PAY-CC-1", 1000).await; + chargeback_svc(&provider) + .record_phase( + &ctx, + &scope, + ChargebackRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CC-1".to_owned(), + dispute_id: "DSP-CC-1".to_owned(), + invoice_id: None, + cycle: 1, + phase: DisputePhase::Opened, + funds_at_open: FundsAtOpen::Withheld, + disputed_amount_minor: 1000, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("opened cash-hold"); + + // The SAME `lost` phase on both racing requests ⇒ the dedup key collides; at + // most one may post the forfeiture, the other replays it. + let request = || ChargebackRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CC-1".to_owned(), + dispute_id: "DSP-CC-1".to_owned(), + invoice_id: None, + cycle: 1, + phase: DisputePhase::Lost, + funds_at_open: FundsAtOpen::Withheld, + disputed_amount_minor: 1000, + currency: "USD".to_owned(), + effective_at: None, + }; + + let svc_a = chargeback_svc(&provider); + let svc_b = chargeback_svc(&provider); + let scope_a = scope.clone(); + let scope_b = scope.clone(); + let ctx_a = SecurityContext::anonymous(); + let ctx_b = SecurityContext::anonymous(); + let (ra, rb) = tokio::join!( + async move { retry_on_serialization(|| svc_a.record_phase(&ctx_a, &scope_a, request())).await }, + async move { retry_on_serialization(|| svc_b.record_phase(&ctx_b, &scope_b, request())).await }, + ); + + // Both sides resolved cleanly (a deadlock would have propagated out of + // `retry_on_serialization`); at least one made progress. A `lost` is an inline + // post, so each Ok is a `Recorded` (one fresh, the other a replay). + for r in [&ra, &rb] { + match r { + Ok(ChargebackOutcome::Recorded(_)) => {} + Ok(ChargebackOutcome::Queued(q)) => { + panic!("a lost on an opened dispute posts inline, got Queued: {q:?}") + } + Err(e) => panic!("a racing lost must succeed (post or replay), got: {e:?}"), + } + } + let replays = i32::from(matches!(&ra, Ok(ChargebackOutcome::Recorded(r)) if r.replayed)) + + i32::from(matches!(&rb, Ok(ChargebackOutcome::Recorded(r)) if r.replayed)); + assert!( + replays >= 1, + "exactly one side wins; the other replays the finalized entry: {ra:?} / {rb:?}" + ); + + // INVARIANT: the forfeiture posted EXACTLY once — DISPUTE_LOSS_EXPENSE = 1000, + // DISPUTE_HOLD emptied — and `clawed_back_minor` was not double-counted. + assert_eq!( + account_balance(&raw, &s, s.dispute_loss).await, + Some(1000), + "the forfeiture booked exactly once into DISPUTE_LOSS_EXPENSE" + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(0), + "the hold is emptied exactly once" + ); + + let row = PaymentRepo::new(provider.clone()) + .read_settlement(&scope, s.tenant, "PAY-CC-1") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.clawed_back_minor, 1000, + "clawed_back_minor reflects exactly one clawback (1000), never doubled" + ); + assert!( + row.refunded_minor + row.clawed_back_minor <= row.settled_minor, + "the total money-out cap held: refunded ({}) + clawed ({}) <= settled ({})", + row.refunded_minor, + row.clawed_back_minor, + row.settled_minor + ); + + // The dispute resolved to LOST (advanced once). + let phase = raw + .query_one(pg(format!( + "SELECT last_phase FROM bss.ledger_dispute \ + WHERE tenant_id='{}' AND dispute_id='DSP-CC-1'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()); + assert_eq!( + phase, + Some("LOST".to_owned()), + "the dispute advanced to LOST" + ); +} + +/// Chargeback #D-2 (the won/lost outcome race — regression for the missing +/// `(last_phase, cycle)` predicate on `dispute_advance`): a `won` and a `lost` on +/// the SAME opened cycle carry DISTINCT dedup keys (`…:won` vs `…:lost`), so BOTH +/// clear the dedup gate AND the out-of-txn transition guard and reach the sidecar. +/// Exactly ONE may resolve the dispute; the loser MUST be rejected as a clean +/// `InvalidDisputeTransition` — never commit a second outcome entry over the +/// first, never surface as a 500. Under `SERIALIZABLE` one commits; the other +/// finds the row no longer `OPENED` at this cycle (its in-txn UPDATE matches 0 +/// rows, or, after a caller retry on a projector 40001, the out-of-txn guard sees +/// the resolved row) and is rejected. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_won_and_lost_resolves_one_outcome() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-CC-2", 1000).await; + chargeback_svc(&provider) + .record_phase( + &ctx, + &scope, + ChargebackRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CC-2".to_owned(), + dispute_id: "DSP-CC-2".to_owned(), + invoice_id: None, + cycle: 1, + phase: DisputePhase::Opened, + funds_at_open: FundsAtOpen::Withheld, + disputed_amount_minor: 1000, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("opened cash-hold"); + + // Distinct dedup keys (won vs lost) — neither is deduped against the other. + let won_req = || ChargebackRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CC-2".to_owned(), + dispute_id: "DSP-CC-2".to_owned(), + invoice_id: None, + cycle: 1, + phase: DisputePhase::Won, + funds_at_open: FundsAtOpen::Withheld, + disputed_amount_minor: 1000, + currency: "USD".to_owned(), + effective_at: None, + }; + let lost_req = || ChargebackRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CC-2".to_owned(), + dispute_id: "DSP-CC-2".to_owned(), + invoice_id: None, + cycle: 1, + phase: DisputePhase::Lost, + funds_at_open: FundsAtOpen::Withheld, + disputed_amount_minor: 1000, + currency: "USD".to_owned(), + effective_at: None, + }; + + let svc_a = chargeback_svc(&provider); + let svc_b = chargeback_svc(&provider); + let scope_a = scope.clone(); + let scope_b = scope.clone(); + let ctx_a = SecurityContext::anonymous(); + let ctx_b = SecurityContext::anonymous(); + let (won, lost) = tokio::join!( + async move { retry_on_serialization(|| svc_a.record_phase(&ctx_a, &scope_a, won_req())).await }, + async move { retry_on_serialization(|| svc_b.record_phase(&ctx_b, &scope_b, lost_req())).await }, + ); + + // EXACTLY ONE outcome resolved; the other is a clean invalid-transition + // rejection (NOT a second commit, NOT an Internal/500). + let oks = i32::from(won.is_ok()) + i32::from(lost.is_ok()); + assert_eq!( + oks, 1, + "exactly one outcome may win: won={won:?} lost={lost:?}" + ); + for r in [&won, &lost] { + if let Err(e) = r { + assert!( + matches!(e, DomainError::InvalidDisputeTransition(_)), + "the losing outcome must be a clean InvalidDisputeTransition, got {e:?}" + ); + } + } + + // The dispute sits at EXACTLY ONE terminal phase, matching the winner; the + // hold is emptied once either way (won releases it, lost forfeits it). + let winner_phase = if won.is_ok() { "WON" } else { "LOST" }; + let phase = raw + .query_one(pg(format!( + "SELECT last_phase FROM bss.ledger_dispute \ + WHERE tenant_id='{}' AND dispute_id='DSP-CC-2'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()); + assert_eq!( + phase.as_deref(), + Some(winner_phase), + "the dispute resolved to the winning outcome exactly once" + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(0), + "DISPUTE_HOLD drained by the single applied outcome" + ); + // The clawback counter reflects the winner: 0 on a won, the full 1000 on a lost. + let want_clawed = if won.is_ok() { 0 } else { 1000 }; + let row = PaymentRepo::new(provider.clone()) + .read_settlement(&scope, s.tenant, "PAY-CC-2") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.clawed_back_minor, want_clawed, + "clawed_back reflects the single winning outcome (won=>0, lost=>1000)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_chargeback_fx.rs b/gears/bss/ledger/ledger/tests/postgres_chargeback_fx.rs new file mode 100644 index 000000000..6ca9c7654 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_chargeback_fx.rs @@ -0,0 +1,545 @@ +//! Postgres-only integration (Slice 5, Phase 2 F3): **functional carry-forward on +//! a cross-currency chargeback close**, driven through the REAL orchestrators +//! (`SettlementService` / `InvoicePostService` / `ChargebackService`) against the +//! foundation engine. +//! +//! A chargeback reclassifies a position WITHOUT locking a new rate, so the +//! functional cost basis carries forward and the entry's functional column nets to +//! ZERO — there is **no realized FX_GAIN_LOSS line** (realized FX is recognised at +//! the cash in/out points: settle S2 / refund S3). These tests prove the +//! carry-forward keeps each closing grain's functional column in lockstep with +//! `balance_minor` for both dispute variants: +//! +//! - `cash_hold_dispute_carries_functional_forward_no_fx`: a USD-functional seller +//! settles 120 EUR @ 1.08 (CASH_CLEARING carries 129.60 USD); a CASH_HOLD dispute +//! moves it to DISPUTE_HOLD at `opened` (CASH→0, HOLD→129.60) and back at `won` +//! (HOLD→0, CASH→129.60). The 129.60 USD cost basis round-trips; no FX line. +//! - `ar_reclass_lost_writes_off_at_carried_basis_no_fx`: an EUR invoice posts AR +//! @ 1.10 (132.00 USD); an AR_RECLASS dispute reclasses it `opened`, then a `lost` +//! write-off clears AR to (0, 0) and books DISPUTE_LOSS_EXPENSE at the 132.00 USD +//! carried cost basis; no FX line. +//! +//! Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::chargeback::{DisputePhase, FundsAtOpen}; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::fx::rate_locker::RateLocker; +use bss_ledger::infra::fx::rate_source::RateSource; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::payment::chargeback::{ + ChargebackOutcome, ChargebackRequest, ChargebackService, +}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{FxRepo, NewFxRate, ReferenceRepo}; +use bss_ledger_sdk::AccountClass; +use chrono::{Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: &str, + currency: &str, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: currency.to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +fn rate_locker(provider: &DBProvider, fx_config: &FxConfig) -> RateLocker { + RateLocker::new( + RateSource::new(FxRepo::new(provider.clone()), fx_config.clone()), + FxRepo::new(provider.clone()), + ) +} + +/// The chart account ids a cross-currency dispute seller needs. +struct Chart { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + dispute_hold: Uuid, + dispute_loss: Uuid, + ar: Uuid, + revenue: Uuid, + fx_gl: Uuid, +} + +/// Boot a container + provision a USD-functional / EUR-billing seller: USD+EUR +/// scales, the functional-currency source (S5-F3), an OPEN current-month period, +/// the EUR dispute/payment chart, and the USD FX_GAIN_LOSS account (provisioned so +/// the "no FX line" assertions are positive — the account EXISTS but stays +/// untouched, not merely absent). Seeds the EUR→USD rate at `rate_micro`. +async fn setup( + rate_micro: i64, +) -> ( + testcontainers_modules::testcontainers::ContainerAsync, + DatabaseConnection, + DBProvider, + Chart, + String, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let provider = + DBProvider::::new(connect_db(&repo_url, ConnectOpts::default()).await.unwrap()); + + let c = Chart { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + dispute_hold: Uuid::now_v7(), + dispute_loss: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue: Uuid::now_v7(), + fx_gl: Uuid::now_v7(), + }; + let now = Utc::now(); + let period_id = format!("{:04}{:02}", now.year(), now.month()); + + let reference = ReferenceRepo::new(provider.clone()); + for ccy in ["EUR", "USD"] { + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: c.tenant, + currency: ccy.to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + } + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_calendar + (tenant_id, legal_entity_id, fiscal_tz, granularity, fy_start_month, functional_currency) + VALUES ('{}','{}','UTC','MONTH',1,'USD')", + c.tenant, c.tenant + ))) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{period_id}','UTC','OPEN')", + c.tenant, c.tenant + ))) + .await + .unwrap(); + for row in [ + account( + c.tenant, + c.cash, + AccountClass::CashClearing, + "DR", + "EUR", + None, + ), + account( + c.tenant, + c.unallocated, + AccountClass::Unallocated, + "CR", + "EUR", + None, + ), + account( + c.tenant, + c.dispute_hold, + AccountClass::DisputeHold, + "DR", + "EUR", + None, + ), + account( + c.tenant, + c.dispute_loss, + AccountClass::DisputeLossExpense, + "DR", + "EUR", + None, + ), + account(c.tenant, c.ar, AccountClass::Ar, "DR", "EUR", None), + account( + c.tenant, + c.revenue, + AccountClass::Revenue, + "CR", + "EUR", + Some("subscription"), + ), + account( + c.tenant, + c.fx_gl, + AccountClass::FxGainLoss, + "DR", + "USD", + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + FxRepo::new(provider.clone()) + .upsert_rate(&NewFxRate { + tenant_id: c.tenant, + base_currency: "EUR".to_owned(), + quote_currency: "USD".to_owned(), + provider: "ecb".to_owned(), + rate_micro, + as_of: now, + fallback_order: 0, + }) + .await + .unwrap(); + + (container, raw, provider, c, period_id) +} + +fn fx_config() -> FxConfig { + FxConfig { + provider_order: vec!["ecb".to_owned()], + ..FxConfig::default() + } +} + +/// Functional column net (DR − CR) over one entry — must be 0 for a carry-forward. +async fn entry_functional_net(raw: &DatabaseConnection, tenant: Uuid, entry: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT COALESCE(SUM(CASE WHEN side='DR' THEN functional_amount_minor \ + ELSE -functional_amount_minor END),0)::bigint \ + FROM bss.ledger_journal_line WHERE tenant_id='{tenant}' AND entry_id='{entry}'" + ), + ) + .await +} + +async fn acct_balance( + raw: &DatabaseConnection, + tenant: Uuid, + account: Uuid, +) -> (Option, Option) { + let bal = scalar_i64(raw, &format!( + "SELECT balance_minor FROM bss.ledger_account_balance WHERE tenant_id='{tenant}' AND account_id='{account}'" + )).await; + let func = scalar_i64(raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_account_balance WHERE tenant_id='{tenant}' AND account_id='{account}'" + )).await; + (bal, func) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cash_hold_dispute_carries_functional_forward_no_fx() { + // EUR→USD @ 1.08 at settle. + let (_c, raw, provider, chart, _period) = setup(1_080_000).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(chart.tenant); + let cfg = fx_config(); + + // Settle 120.00 EUR @ 1.08 ⇒ CASH_CLEARING carries 120.00 EUR / 129.60 USD. + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .with_fx(rate_locker(&provider, &cfg)) + .settle( + &ctx, + &scope, + SettlementInput { + tenant_id: chart.tenant, + payer_tenant_id: chart.payer, + payment_id: "PAY-CB-1".to_owned(), + gross_minor: 12_000, + fee_minor: 0, + currency: "EUR".to_owned(), + effective_at: None, + }, + ) + .await + .expect("cross-currency settle must post"); + assert_eq!( + acct_balance(&raw, chart.tenant, chart.cash).await, + (Some(12_000), Some(12_960)), + "CASH_CLEARING carries 120.00 EUR / 129.60 USD after settle" + ); + + let cb = ChargebackService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ); + let request = |phase: DisputePhase| ChargebackRequest { + tenant_id: chart.tenant, + payer_tenant_id: chart.payer, + payment_id: "PAY-CB-1".to_owned(), + dispute_id: "D-1".to_owned(), + invoice_id: None, + cycle: 1, + phase, + funds_at_open: FundsAtOpen::Withheld, // ⇒ CASH_HOLD + disputed_amount_minor: 12_000, + currency: "EUR".to_owned(), + effective_at: None, + }; + + // opened: DR DISPUTE_HOLD 12000 / CR CASH_CLEARING 12000 — the 129.60 USD cost + // basis carries forward from CASH_CLEARING (→ 0) into DISPUTE_HOLD. + let opened = match cb + .record_phase(&ctx, &scope, request(DisputePhase::Opened)) + .await + { + Ok(ChargebackOutcome::Recorded(p)) => p.entry_id, + other => panic!("opened must post inline: {other:?}"), + }; + assert_eq!( + entry_functional_net(&raw, chart.tenant, opened).await, + Some(0), + "opened entry functional column balances (carry-forward, no FX)" + ); + assert_eq!( + acct_balance(&raw, chart.tenant, chart.cash).await, + (Some(0), Some(0)), + "CASH_CLEARING closes to (0, 0) — the held cash AND its cost basis left" + ); + assert_eq!( + acct_balance(&raw, chart.tenant, chart.dispute_hold).await, + (Some(12_000), Some(12_960)), + "DISPUTE_HOLD carries the 120.00 EUR / 129.60 USD cost basis forward" + ); + + // won: DR CASH_CLEARING 12000 / CR DISPUTE_HOLD 12000 — the cost basis returns. + let won = match cb + .record_phase(&ctx, &scope, request(DisputePhase::Won)) + .await + { + Ok(ChargebackOutcome::Recorded(p)) => p.entry_id, + other => panic!("won must post inline: {other:?}"), + }; + assert_eq!( + entry_functional_net(&raw, chart.tenant, won).await, + Some(0), + "won entry functional column balances (carry-forward, no FX)" + ); + assert_eq!( + acct_balance(&raw, chart.tenant, chart.dispute_hold).await, + (Some(0), Some(0)), + "DISPUTE_HOLD closes to (0, 0) on won" + ); + assert_eq!( + acct_balance(&raw, chart.tenant, chart.cash).await, + (Some(12_000), Some(12_960)), + "CASH_CLEARING regains 120.00 EUR / 129.60 USD — cost basis round-tripped" + ); + + // NO realized FX line was ever posted (the FX_GAIN_LOSS account exists but is + // untouched) — a chargeback realizes no FX (carry-forward). + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_line WHERE tenant_id='{}' AND account_id='{}'", + chart.tenant, chart.fx_gl + )).await, + Some(0), + "a chargeback posts NO FX_GAIN_LOSS line (carry-forward, not realized FX)" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn ar_reclass_lost_writes_off_at_carried_basis_no_fx() { + // EUR→USD @ 1.10 at invoice post. + let (_c, raw, provider, chart, period_id) = setup(1_100_000).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(chart.tenant); + let cfg = fx_config(); + + // Post an EUR invoice @ 1.10 ⇒ AR carries 120.00 EUR / 132.00 USD. + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + RecognitionConfig::default(), + cfg.clone(), + ) + .post_invoice( + &ctx, + &scope, + &PostedInvoice { + invoice_id: "INV-CB-1".to_owned(), + payer_tenant_id: chart.payer, + resource_tenant_id: None, + seller_tenant_id: chart.tenant, + effective_at: Utc::now().date_naive(), + due_date: Some(naive(2026, 12, 1)), + period_id, + items: vec![InvoiceItem { + amount_minor_ex_tax: 12_000, + deferred_minor: 0, + currency: "EUR".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }], + tax: vec![], + posted_by_actor_id: chart.tenant, + correlation_id: chart.tenant, + }, + true, + ) + .await + .expect("cross-currency invoice must post"); + + let ar_carried = || async { + let bal = scalar_i64(&raw, &format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{}' AND invoice_id='INV-CB-1'", + chart.tenant + )).await; + let func = scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{}' AND invoice_id='INV-CB-1'", + chart.tenant + )).await; + (bal, func) + }; + assert_eq!( + ar_carried().await, + (Some(12_000), Some(13_200)), + "AR carries 120.00 EUR / 132.00 USD" + ); + + let cb = ChargebackService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ); + let request = |phase: DisputePhase| ChargebackRequest { + tenant_id: chart.tenant, + payer_tenant_id: chart.payer, + payment_id: "PAY-CB-2".to_owned(), + dispute_id: "D-2".to_owned(), + invoice_id: Some("INV-CB-1".to_owned()), + cycle: 1, + phase, + funds_at_open: FundsAtOpen::NotMoved, // ⇒ AR_RECLASS + disputed_amount_minor: 12_000, + currency: "EUR".to_owned(), + effective_at: None, + }; + + // opened: DR AR DISPUTED / CR AR ACTIVE — same grain, nets ZERO on both columns + // (reclass); AR carried (12000, 13200) unchanged, disputed sub-balance → 12000. + let opened = match cb + .record_phase(&ctx, &scope, request(DisputePhase::Opened)) + .await + { + Ok(ChargebackOutcome::Recorded(p)) => p.entry_id, + other => panic!("opened must post inline: {other:?}"), + }; + assert_eq!( + entry_functional_net(&raw, chart.tenant, opened).await, + Some(0), + "opened reclass nets functional to 0" + ); + assert_eq!( + ar_carried().await, + (Some(12_000), Some(13_200)), + "AR carried unchanged by a reclass" + ); + + // lost: DR DISPUTE_LOSS_EXPENSE / CR AR DISPUTED — write off AR to (0, 0); the + // loss is booked at the 132.00 USD carried cost basis (carry-forward, no FX). + let lost = match cb + .record_phase(&ctx, &scope, request(DisputePhase::Lost)) + .await + { + Ok(ChargebackOutcome::Recorded(p)) => p.entry_id, + other => panic!("lost must post inline: {other:?}"), + }; + assert_eq!( + entry_functional_net(&raw, chart.tenant, lost).await, + Some(0), + "lost write-off functional balances" + ); + assert_eq!( + ar_carried().await, + (Some(0), Some(0)), + "AR written off to (0, 0) in both columns" + ); + assert_eq!( + acct_balance(&raw, chart.tenant, chart.dispute_loss).await, + (Some(12_000), Some(13_200)), + "DISPUTE_LOSS_EXPENSE booked at the 132.00 USD carried cost basis" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_line WHERE tenant_id='{}' AND account_id='{}'", + chart.tenant, chart.fx_gl + )).await, + Some(0), + "a write-off realizes no FX (carry-forward at the carried basis)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_chargebacks.rs b/gears/bss/ledger/ledger/tests/postgres_chargebacks.rs new file mode 100644 index 000000000..110746157 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_chargebacks.rs @@ -0,0 +1,1885 @@ +//! Postgres-only service-level tests for the chargeback (dispute) flow (Phase 4, +//! Group D): `ChargebackService::record_phase` drives the dispute state machine +//! (`opened → {won, lost}`) over the foundation engine, in both variants +//! (`CASH_HOLD` / `AR_RECLASS`). Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_chargebacks -- --ignored`. +//! +//! Mirrors `postgres_payment_returns.rs` (boot, `setup_seller`, a `settle` +//! helper) + `postgres_payments.rs` (`seed_ar_invoice`, the over-cap +//! fund-the-pool idiom). Each case asserts on the `ledger_dispute` row +//! (`variant` / `last_phase`), the chart `account_balance` / +//! `ar_invoice_balance.disputed_minor`, and `payment_settlement.clawed_back_minor`: +//! +//! 1. opened cash-hold (withheld): DISPUTE_HOLD = disputed, CASH_CLEARING net 0; +//! dispute variant=CASH_HOLD, last_phase=OPENED. +//! 2. opened AR-reclass (not_moved): `balance_minor` unchanged, `disputed_minor` +//! = disputed. +//! 3. won cash-hold: DISPUTE_HOLD 0, CASH_CLEARING restored; last_phase=WON. +//! 4. won AR-reclass: `disputed_minor` 0. +//! 5. lost cash-hold: DISPUTE_LOSS_EXPENSE = disputed, DISPUTE_HOLD 0, +//! `clawed_back_minor` = disputed. +//! 6. lost AR-reclass (write-off, Model N): the lone `CR AR DISPUTED` writes the +//! receivable off — `disputed_minor → 0` AND `balance_minor` dropped by +//! disputed; DISPUTE_LOSS_EXPENSE = disputed; NO cash leg, so CASH_CLEARING is +//! UNTOUCHED (the settle left it intact) and `clawed_back_minor` stays 0. +//! 7. lost AR-reclass write-off on an UNSETTLED payment: no settle ⇒ +//! CASH_CLEARING never funded; the write-off still books a REAL loss +//! (DISPUTE_LOSS_EXPENSE = disputed, not netted to zero), posts no cash leg, and +//! the unsettled payment has no settlement counter row (nothing clawed back). +//! 8. transition guard: opened on an already-OPENED dispute ⇒ +//! `InvalidDisputeTransition`. +//! 9. idempotent replay: the same opened twice ⇒ second is `replayed`, ledger +//! effect applied once. +//! 10. cycle re-entrancy: opened → won → opened(cycle 2) succeeds. +//! 11. fee-bearing cash-hold won (Model N): settle gross 100 / fee 3 ⇒ +//! CASH_CLEARING net 97; opened parks 97 (CASH_CLEARING → 0, dropped by 97 +//! not 100); won restores CASH_CLEARING to 97. +//! 12. fee-bearing cash-hold lost (Model N): same net-97 open; lost ⇒ +//! DISPUTE_LOSS_EXPENSE = 97, CASH_CLEARING untouched, `clawed_back_minor` = 97 +//! (the net, not the gross 100). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::chargeback::{DisputePhase, FundsAtOpen}; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::payment::settlement_return::SettlementReturnInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::chargeback::{ + ChargebackOutcome, ChargebackRequest, ChargebackService, +}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::payment::settlement_return::SettlementReturnService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{PaymentRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot a container, migrate on a raw connection, and return a `bss`-search-path +/// `DBProvider` (the provisioning-test idiom). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller for the chargeback flow: the chart classes a dispute +/// touches — `CASH_CLEARING`, `UNALLOCATED` (the settle pool), `DISPUTE_HOLD`, +/// `DISPUTE_LOSS_EXPENSE`, `AR` — plus the `PSP_FEE_EXPENSE` the AR-invoice seed +/// credits. Mirrors `postgres_payments.rs::Seller` with the dispute classes +/// added. +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + dispute_hold: Uuid, + dispute_loss: Uuid, + ar: Uuid, + psp_fee: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision the seller: USD@2 scale, an OPEN fiscal period for the current +/// month, and the chart accounts a dispute touches. `CASH_CLEARING` / +/// `DISPUTE_HOLD` / `DISPUTE_LOSS_EXPENSE` / `AR` are debit-normal; `UNALLOCATED` +/// is credit-normal (settle parks cash there); `PSP_FEE_EXPENSE` is the unguarded +/// counter the AR-invoice seed credits. settle/dispute derive `period_id` from +/// `Utc::now()` when no `effective_at` is supplied, matching this OPEN period. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + dispute_hold: Uuid::now_v7(), + dispute_loss: Uuid::now_v7(), + ar: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + Uuid::now_v7(), + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.dispute_hold, + AccountClass::DisputeHold, + Side::Debit, + ), + account( + s.tenant, + s.dispute_loss, + AccountClass::DisputeLossExpense, + Side::Debit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn chargeback_svc(provider: &DBProvider) -> ChargebackService { + ChargebackService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +/// Settle `gross` (fee 0) for `payment_id` — lands the cash in `CASH_CLEARING` +/// (DR net) + the unallocated pool (CR gross), and seeds the +/// `payment_settlement` counter row (`settled_minor = gross`) the clawback cap +/// nets against. +async fn settle(provider: &DBProvider, s: &Seller, payment_id: &str, gross: i64) { + settle_with_fee(provider, s, payment_id, gross, 0).await; +} + +/// Settle `gross` with a PSP `fee` for `payment_id` (Model N): `settle` posts +/// `DR CASH_CLEARING (gross − fee) · DR PSP_FEE_EXPENSE (fee) · CR UNALLOCATED +/// (gross)`, so `CASH_CLEARING` only ever holds **net** = `gross − fee`. The +/// `payment_settlement` counter row is seeded `settled_minor = gross`, +/// `fee_minor = fee` — the orchestrator reads `net = settled − fee` pre-build to +/// size the CASH_HOLD dispute cash legs. +async fn settle_with_fee( + provider: &DBProvider, + s: &Seller, + payment_id: &str, + gross: i64, + fee: i64, +) { + settle_svc(provider) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle must succeed"); +} + +fn settlement_return_svc(provider: &DBProvider) -> SettlementReturnService { + SettlementReturnService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +/// Claw `amount` (fee 0) back out of `payment_id` through the real service — +/// decrements the payment's `settled_minor`/`net`, mirroring a PSP refund landing +/// AFTER a dispute has opened on the same payment. +async fn return_settlement( + provider: &DBProvider, + s: &Seller, + payment_id: &str, + psp_return_id: &str, + amount: i64, +) { + settlement_return_svc(provider) + .return_settlement( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementReturnInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + psp_return_id: psp_return_id.to_owned(), + amount_minor: amount, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settlement return must succeed"); +} + +/// Record one dispute phase through the real service, returning the outcome. +#[allow(clippy::too_many_arguments)] +async fn record( + provider: &DBProvider, + s: &Seller, + dispute_id: &str, + payment_id: &str, + invoice_id: Option<&str>, + cycle: i32, + phase: DisputePhase, + funds_at_open: FundsAtOpen, + disputed: i64, +) -> Result { + chargeback_svc(provider) + .record_phase( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + ChargebackRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + dispute_id: dispute_id.to_owned(), + invoice_id: invoice_id.map(ToOwned::to_owned), + cycle, + phase, + funds_at_open, + disputed_amount_minor: disputed, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await +} + +/// Unwrap the inline-post arm. Every service-level case here either has its +/// `opened` first or IS the `opened`, so the outcome is `Recorded`; a `Queued` +/// would be a test-setup bug (it is the out-of-order REST path, exercised in +/// `rest_disputes.rs`), so panic. +fn recorded(outcome: ChargebackOutcome) -> bss_ledger_sdk::PostingRef { + match outcome { + ChargebackOutcome::Recorded(r) => r, + ChargebackOutcome::Queued(q) => { + panic!("expected an inline-posted phase, got Queued: {q:?}") + } + } +} + +async fn account_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + account: Uuid, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn ar_invoice_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND invoice_id='{}'", + s.tenant, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn ar_disputed_minor( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT disputed_minor FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND invoice_id='{}'", + s.tenant, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Read the `ledger_dispute` current-state row's `(variant, last_phase, cycle)`. +async fn dispute_row( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + dispute_id: &str, +) -> Option<(String, String, i32)> { + raw.query_one(pg(format!( + "SELECT variant, last_phase, cycle FROM bss.ledger_dispute \ + WHERE tenant_id='{}' AND dispute_id='{}'", + s.tenant, dispute_id + ))) + .await + .unwrap() + .map(|r| { + ( + r.try_get_by_index::(0).unwrap(), + r.try_get_by_index::(1).unwrap(), + r.try_get_by_index::(2).unwrap(), + ) + }) +} + +/// The work-state `status` of the CHARGEBACK queue row for one dispute phase +/// (`business_id = dispute_id:cycle:phase`), or `None` when no row was enqueued. +async fn queue_status( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + dispute_id: &str, + cycle: i32, + phase: DisputePhase, +) -> Option { + let business_id = format!("{dispute_id}:{cycle}:{}", phase.as_str()); + raw.query_one(pg(format!( + "SELECT status FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='CHARGEBACK' AND business_id='{business_id}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// `clawed_back_minor` on the payment's settlement counter (0 when never bumped). +async fn clawed_back(provider: &DBProvider, s: &Seller, payment_id: &str) -> i64 { + PaymentRepo::new(provider.clone()) + .read_settlement(&AccessScope::for_tenant(s.tenant), s.tenant, payment_id) + .await + .unwrap() + .expect("settlement row present") + .clawed_back_minor +} + +/// Seed an OPEN AR invoice by posting `DR AR (invoice_id) / CR PSP_FEE_EXPENSE` +/// directly through the engine (mirrors `postgres_payments.rs::seed_ar_invoice`). +/// PSP_FEE_EXPENSE is unguarded, so this lands a clean `ar_invoice_balance` row +/// (`disputed_minor = 0`) the AR-reclass dispute then moves. +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +// ── 1. opened cash-hold (withheld) ─────────────────────────────────────────── + +/// `opened` with `funds_at_open = withheld` selects `CASH_HOLD`: settle 1000 +/// (CASH_CLEARING = 1000), then `opened` moves the cash into the hold +/// (`DR DISPUTE_HOLD / CR CASH_CLEARING`, each 1000) ⇒ DISPUTE_HOLD = 1000, +/// CASH_CLEARING net 0; the dispute row records `variant = CASH_HOLD`, +/// `last_phase = OPENED`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn opened_cash_hold_moves_cash_into_hold() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + settle(&provider, &s, "PAY-CH-1", 1000).await; + let posted = recorded( + record( + &provider, + &s, + "DSP-CH-1", + "PAY-CH-1", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("opened cash-hold must post"), + ); + assert!(!posted.replayed, "first opened is fresh"); + + // The cash parked in the hold; clearing is back to net zero. + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(1000), + "DISPUTE_HOLD holds the disputed cash" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(0), + "CASH_CLEARING net 0 (1000 in from settle, 1000 out to the hold)" + ); + // The dispute current-state row records the chosen variant + phase. + assert_eq!( + dispute_row(&raw, &s, "DSP-CH-1").await, + Some(("CASH_HOLD".to_owned(), "OPENED".to_owned(), 1)), + "dispute row: variant=CASH_HOLD, last_phase=OPENED, cycle=1" + ); +} + +// ── 2. opened AR-reclass (not_moved) ───────────────────────────────────────── + +/// `opened` with `funds_at_open = not_moved` selects `AR_RECLASS`: seed an open +/// AR invoice (1000), then `opened` reclasses it `ACTIVE → DISPUTED` +/// (`DR AR DISPUTED + CR AR ACTIVE`, AR-class-neutral) ⇒ `balance_minor` +/// unchanged (1000), `disputed_minor` = 1000. No cash moves. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn opened_ar_reclass_moves_disputed_slice() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + seed_ar_invoice( + &provider, + &s, + "INV-AR-2", + 1000, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + recorded( + record( + &provider, + &s, + "DSP-AR-2", + "PAY-AR-2", + Some("INV-AR-2"), + 1, + DisputePhase::Opened, + FundsAtOpen::NotMoved, + 1000, + ) + .await + .expect("opened AR-reclass must post"), + ); + + // AR-class-neutral: the full open AR is unchanged; the disputed slice moved. + assert_eq!( + ar_invoice_balance(&raw, &s, "INV-AR-2").await, + Some(1000), + "balance_minor unchanged by an AR-class-neutral reclass" + ); + assert_eq!( + ar_disputed_minor(&raw, &s, "INV-AR-2").await, + Some(1000), + "disputed_minor = the disputed amount (+D)" + ); + assert_eq!( + dispute_row(&raw, &s, "DSP-AR-2").await, + Some(("AR_RECLASS".to_owned(), "OPENED".to_owned(), 1)), + "dispute row: variant=AR_RECLASS, last_phase=OPENED" + ); +} + +// ── 3. won cash-hold ───────────────────────────────────────────────────────── + +/// `won` on a `CASH_HOLD` dispute releases the hold back to clearing +/// (`DR CASH_CLEARING / CR DISPUTE_HOLD`): DISPUTE_HOLD 0, CASH_CLEARING restored +/// to its pre-dispute net (1000); dispute `last_phase = WON`. No clawback. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn won_cash_hold_releases_hold() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + settle(&provider, &s, "PAY-CH-3", 1000).await; + record( + &provider, + &s, + "DSP-CH-3", + "PAY-CH-3", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("opened"); + recorded( + record( + &provider, + &s, + "DSP-CH-3", + "PAY-CH-3", + None, + 1, + DisputePhase::Won, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("won cash-hold must post"), + ); + + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(0), + "the hold is released on a won" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(1000), + "CASH_CLEARING restored (the withheld cash is the seller's again)" + ); + assert_eq!( + dispute_row(&raw, &s, "DSP-CH-3").await.map(|r| r.1), + Some("WON".to_owned()), + "last_phase=WON" + ); + assert_eq!( + clawed_back(&provider, &s, "PAY-CH-3").await, + 0, + "a won claws nothing back" + ); +} + +// ── 4. won AR-reclass ──────────────────────────────────────────────────────── + +/// `won` on an `AR_RECLASS` dispute reverses the reclass `DISPUTED → ACTIVE` +/// (`DR AR ACTIVE + CR AR DISPUTED`) ⇒ `disputed_minor` back to 0, +/// `balance_minor` still the full open AR; `last_phase = WON`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn won_ar_reclass_clears_disputed_slice() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + seed_ar_invoice( + &provider, + &s, + "INV-AR-4", + 1000, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + record( + &provider, + &s, + "DSP-AR-4", + "PAY-AR-4", + Some("INV-AR-4"), + 1, + DisputePhase::Opened, + FundsAtOpen::NotMoved, + 1000, + ) + .await + .expect("opened"); + recorded( + record( + &provider, + &s, + "DSP-AR-4", + "PAY-AR-4", + Some("INV-AR-4"), + 1, + DisputePhase::Won, + FundsAtOpen::NotMoved, + 1000, + ) + .await + .expect("won AR-reclass must post"), + ); + + assert_eq!( + ar_disputed_minor(&raw, &s, "INV-AR-4").await, + Some(0), + "disputed_minor cleared on a won (−D)" + ); + assert_eq!( + ar_invoice_balance(&raw, &s, "INV-AR-4").await, + Some(1000), + "balance_minor still the full open AR" + ); + assert_eq!( + dispute_row(&raw, &s, "DSP-AR-4").await.map(|r| r.1), + Some("WON".to_owned()), + "last_phase=WON" + ); +} + +// ── 5. lost cash-hold ──────────────────────────────────────────────────────── + +/// `lost` on a `CASH_HOLD` dispute forfeits the already-withheld hold funds +/// (`DR DISPUTE_LOSS_EXPENSE / CR DISPUTE_HOLD`): DISPUTE_LOSS_EXPENSE = 1000, +/// DISPUTE_HOLD 0, CASH_CLEARING untouched (the cash left clearing at open); the +/// orchestrator bumps `clawed_back_minor` by 1000. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn lost_cash_hold_forfeits_hold_and_claws_back() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + settle(&provider, &s, "PAY-CH-5", 1000).await; + record( + &provider, + &s, + "DSP-CH-5", + "PAY-CH-5", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("opened"); + recorded( + record( + &provider, + &s, + "DSP-CH-5", + "PAY-CH-5", + None, + 1, + DisputePhase::Lost, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("lost cash-hold must post"), + ); + + assert_eq!( + account_balance(&raw, &s, s.dispute_loss).await, + Some(1000), + "the forfeiture booked into DISPUTE_LOSS_EXPENSE" + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(0), + "the hold is emptied on the loss" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(0), + "CASH_CLEARING untouched by the cash-hold loss (cash left at open)" + ); + assert_eq!( + clawed_back(&provider, &s, "PAY-CH-5").await, + 1000, + "clawed_back_minor bumped by the forfeited held funds" + ); +} + +// ── 6. lost AR-reclass — write-off ─────────────────────────────────────────── + +/// `lost` on an `AR_RECLASS` dispute is a WRITE-OFF (Model N): the receivable was +/// never collected (funds `not_moved`), so the lone `CR AR (ar_status = DISPUTED)` +/// writes it off — `DR DISPUTE_LOSS_EXPENSE (disputed) / CR AR DISPUTED +/// (disputed)`. The single CR AR DISPUTED nets `−D` on BOTH `balance_minor` and +/// `disputed_minor`, so after `lost`: `disputed_minor → 0` AND `balance_minor` +/// dropped by `disputed`; `DISPUTE_LOSS_EXPENSE = disputed`. There is NO cash leg, +/// so `CASH_CLEARING` is UNTOUCHED (still whatever the settle left it) and the +/// payment's `clawed_back_minor` stays 0. A settle funds CASH_CLEARING here only +/// to prove the write-off leaves it alone (no clawback against the held cash). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn lost_ar_reclass_writes_off_to_loss() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // Settle (CASH_CLEARING = 1000) + seed the settlement counter — present ONLY + // so we can prove the write-off does NOT touch the cash. The disputed + // receivable is a separate seeded AR invoice (balance_minor = 1000). + settle(&provider, &s, "PAY-AR-6", 1000).await; + seed_ar_invoice( + &provider, + &s, + "INV-AR-6", + 1000, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + record( + &provider, + &s, + "DSP-AR-6", + "PAY-AR-6", + Some("INV-AR-6"), + 1, + DisputePhase::Opened, + FundsAtOpen::NotMoved, + 1000, + ) + .await + .expect("opened"); + recorded( + record( + &provider, + &s, + "DSP-AR-6", + "PAY-AR-6", + Some("INV-AR-6"), + 1, + DisputePhase::Lost, + FundsAtOpen::NotMoved, + 1000, + ) + .await + .expect("lost AR-reclass (write-off) must post"), + ); + + // The lone CR AR DISPUTED clears the disputed slice AND writes the receivable + // down: disputed_minor → 0 and balance_minor dropped by the full disputed. + assert_eq!( + ar_disputed_minor(&raw, &s, "INV-AR-6").await, + Some(0), + "disputed_minor → 0 (the disputed slice is written off)" + ); + assert_eq!( + ar_invoice_balance(&raw, &s, "INV-AR-6").await, + Some(0), + "balance_minor dropped by disputed (1000 → 0) via the lone CR AR DISPUTED" + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_loss).await, + Some(1000), + "the receivable written off to DISPUTE_LOSS_EXPENSE (a real loss)" + ); + // The write-off posts NO cash leg, so CASH_CLEARING is untouched by it (still + // the 1000 the settle left). + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(1000), + "CASH_CLEARING UNTOUCHED by the write-off (no cash leg)" + ); + assert_eq!( + clawed_back(&provider, &s, "PAY-AR-6").await, + 0, + "a write-off claws nothing back (nothing was ever collected)" + ); +} + +// ── 7. lost AR-reclass write-off on an unsettled payment ───────────────────── + +/// The AR-reclass write-off is independent of any cash / settlement: an +/// invoice/ACH receivable can be disputed and lost with NO payment ever settled +/// (the funds were `not_moved`, so nothing ever hit `CASH_CLEARING`). The +/// write-off still books a REAL loss — `DR DISPUTE_LOSS_EXPENSE (disputed) / +/// CR AR DISPUTED (disputed)` — it is NOT netted to zero. After `lost`: +/// `disputed_minor → 0`, `balance_minor` dropped by `disputed`, +/// `DISPUTE_LOSS_EXPENSE = disputed`. CASH_CLEARING is never funded (no settle) +/// and the write-off posts no cash leg, so it stays at 0; the unsettled payment +/// has no `payment_settlement` counter row at all (nothing clawed back). The post +/// must SUCCEED and no guarded balance may be negative. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn lost_ar_reclass_write_off_without_settlement() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // No settle ⇒ CASH_CLEARING is never funded (stays 0). Seed the disputed AR + // invoice (balance_minor = 1000). The write-off posts no cash leg, so it never + // touches clearing and never bumps a (non-existent) settlement counter. + seed_ar_invoice( + &provider, + &s, + "INV-AR-7", + 1000, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + record( + &provider, + &s, + "DSP-AR-7", + "PAY-AR-7", + Some("INV-AR-7"), + 1, + DisputePhase::Opened, + FundsAtOpen::NotMoved, + 1000, + ) + .await + .expect("opened"); + + // The write-off posts (no cash leg ⇒ no negative-balance abort). + let posted = recorded( + record( + &provider, + &s, + "DSP-AR-7", + "PAY-AR-7", + Some("INV-AR-7"), + 1, + DisputePhase::Lost, + FundsAtOpen::NotMoved, + 1000, + ) + .await + .expect("lost AR-reclass write-off must post"), + ); + assert!(!posted.replayed, "the write-off lost is a fresh post"); + + // The lone CR AR DISPUTED clears the disputed slice AND writes the receivable + // down: disputed_minor → 0 and balance_minor dropped by the full disputed. + assert_eq!( + ar_disputed_minor(&raw, &s, "INV-AR-7").await, + Some(0), + "disputed_minor → 0 (the disputed slice is written off)" + ); + assert_eq!( + ar_invoice_balance(&raw, &s, "INV-AR-7").await, + Some(0), + "balance_minor dropped by disputed (1000 → 0) via the lone CR AR DISPUTED" + ); + // A REAL loss is booked (the receivable written off) — NOT netted to zero. + assert_eq!( + account_balance(&raw, &s, s.dispute_loss).await, + Some(1000), + "DISPUTE_LOSS_EXPENSE = disputed (a real write-off loss, not zero)" + ); + // CASH_CLEARING was never funded and the write-off posts no cash leg, so it + // stays at 0 (and never went negative — the post would have aborted if it had). + let cash = account_balance(&raw, &s, s.cash).await.unwrap_or(0); + assert!( + cash >= 0, + "CASH_CLEARING must never be negative; got {cash}" + ); + assert_eq!( + cash, 0, + "CASH_CLEARING never funded and untouched by the write-off (no cash leg)" + ); + // The payment was never settled, so there is no counter row to claw back from. + assert!( + PaymentRepo::new(provider.clone()) + .read_settlement(&AccessScope::for_tenant(s.tenant), s.tenant, "PAY-AR-7") + .await + .unwrap() + .is_none(), + "no clawback occurred — the unsettled payment has no settlement counter row" + ); +} + +// ── 8. transition guard ────────────────────────────────────────────────────── + +/// An `opened` on a dispute whose `last_phase` is still `OPENED` is an illegal +/// re-open ⇒ `InvalidDisputeTransition`; the dispute row is unchanged. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn opened_on_open_dispute_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + settle(&provider, &s, "PAY-G-8", 1000).await; + record( + &provider, + &s, + "DSP-G-8", + "PAY-G-8", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("first opened"); + + // A SECOND opened (same dispute, same cycle) on a still-OPENED dispute: a + // distinct cycle would change the business id, so re-open the same cycle to + // reach the transition guard (not the dedup short-circuit). + let err = record( + &provider, + &s, + "DSP-G-8", + "PAY-G-8", + None, + 2, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect_err("an opened on a still-OPENED dispute must be rejected"); + assert!( + matches!(err, DomainError::InvalidDisputeTransition(_)), + "expected InvalidDisputeTransition, got {err:?}" + ); + + // The rejected re-open left the row at its first-cycle OPENED state. + assert_eq!( + dispute_row(&raw, &s, "DSP-G-8").await, + Some(("CASH_HOLD".to_owned(), "OPENED".to_owned(), 1)), + "the illegal re-open did not advance the dispute row" + ); +} + +// ── 9. idempotent replay ───────────────────────────────────────────────────── + +/// Re-recording the SAME `opened` (same `dispute_id:cycle:phase`) replays: the +/// second call returns `replayed = true` with the prior entry id, and the ledger +/// effect (DISPUTE_HOLD = 1000) is applied exactly once. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn opened_replay_is_idempotent() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + settle(&provider, &s, "PAY-R-9", 1000).await; + let fresh = recorded( + record( + &provider, + &s, + "DSP-R-9", + "PAY-R-9", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("fresh opened"), + ); + assert!(!fresh.replayed, "first opened is fresh"); + + let replay = recorded( + record( + &provider, + &s, + "DSP-R-9", + "PAY-R-9", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("replayed opened"), + ); + assert!(replay.replayed, "same dispute:cycle:phase replays"); + assert_eq!( + replay.entry_id, fresh.entry_id, + "replay returns the prior entry id" + ); + + // The hold move applied exactly once (1000, not 2000). + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(1000), + "the ledger effect applied once, not twice" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(0), + "CASH_CLEARING net 0 (the replay moved no further cash)" + ); +} + +// ── 10. cycle re-entrancy ──────────────────────────────────────────────────── + +/// `opened → won → opened(cycle 2)` succeeds: once the first cycle ends (WON), a +/// fresh `opened` on a NEW cycle is a legal transition. The row advances to the +/// new cycle's OPENED state and the hold is taken again. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reopen_after_won_starts_fresh_cycle() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // Cycle 1: settle enough for two successive holds (1000 each), open + win. + settle(&provider, &s, "PAY-RE-10", 2000).await; + record( + &provider, + &s, + "DSP-RE-10", + "PAY-RE-10", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("cycle 1 opened"); + record( + &provider, + &s, + "DSP-RE-10", + "PAY-RE-10", + None, + 1, + DisputePhase::Won, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("cycle 1 won"); + + // Cycle 2: a fresh opened on the now-resolved dispute is legal. + recorded( + record( + &provider, + &s, + "DSP-RE-10", + "PAY-RE-10", + None, + 2, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("cycle 2 opened must succeed on a resolved dispute"), + ); + + // The row advanced to cycle 2's OPENED state. + assert_eq!( + dispute_row(&raw, &s, "DSP-RE-10").await, + Some(("CASH_HOLD".to_owned(), "OPENED".to_owned(), 2)), + "the dispute re-opened on a fresh cycle (cycle=2, last_phase=OPENED)" + ); + // Cycle 1 won released the hold (→0), cycle 2 opened took it again (→1000). + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(1000), + "the new cycle's hold is taken" + ); +} + +// ── 11. fee-bearing CASH_HOLD — won (net-sized cash legs, Model N) ──────────── + +/// The real Model N coverage: a CASH_HOLD dispute over a payment settled with a +/// PSP fee. Settle gross 100 with fee 3 ⇒ `DR CASH_CLEARING 97 · DR +/// PSP_FEE_EXPENSE 3 · CR UNALLOCATED 100`, so CASH_CLEARING only holds the +/// **net** 97. `opened` then sizes its cash legs at net: `DR DISPUTE_HOLD 97 / CR +/// CASH_CLEARING 97` ⇒ DISPUTE_HOLD = 97 and CASH_CLEARING dropped by exactly 97 +/// (97 → 0), NOT by the gross 100 (which would underflow the guarded clearing). +/// `won` releases the hold: `DR CASH_CLEARING 97 / CR DISPUTE_HOLD 97` ⇒ +/// CASH_CLEARING restored to 97; nothing clawed back. `disputed` is the GROSS +/// claim (100); the orchestrator reads `net = settled − fee` to size the legs. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn fee_bearing_cash_hold_won_uses_net_legs() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // gross 100, fee 3 ⇒ CASH_CLEARING = net 97 (the fee is in PSP_FEE_EXPENSE). + settle_with_fee(&provider, &s, "PAY-FEE-W", 100, 3).await; + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(97), + "settle lands NET 97 in CASH_CLEARING (the 3 fee went to PSP_FEE_EXPENSE)" + ); + + // opened CASH_HOLD: disputed is the GROSS claim (100), but the cash legs are + // sized at net (97) — DISPUTE_HOLD = 97, CASH_CLEARING dropped by 97 (→ 0). + record( + &provider, + &s, + "DSP-FEE-W", + "PAY-FEE-W", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 100, + ) + .await + .expect("opened cash-hold (fee-bearing)"); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(97), + "DISPUTE_HOLD holds the NET 97, not the gross 100" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(0), + "CASH_CLEARING dropped by exactly 97 (97 → 0), not by the gross 100" + ); + + // won: release the net hold back to clearing (CASH_CLEARING restored to 97). + recorded( + record( + &provider, + &s, + "DSP-FEE-W", + "PAY-FEE-W", + None, + 1, + DisputePhase::Won, + FundsAtOpen::Withheld, + 100, + ) + .await + .expect("won cash-hold (fee-bearing)"), + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(0), + "the hold is released on the won" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(97), + "CASH_CLEARING restored by the net 97 (the 3 fee stays expensed)" + ); + assert_eq!( + clawed_back(&provider, &s, "PAY-FEE-W").await, + 0, + "a won claws nothing back" + ); +} + +// ── 12. fee-bearing CASH_HOLD — lost (net-sized loss + clawback, Model N) ───── + +/// The lost counterpart of the fee-bearing CASH_HOLD path. Settle gross 100 with +/// fee 3 (CASH_CLEARING = net 97); `opened` parks the net 97 in DISPUTE_HOLD +/// (CASH_CLEARING → 0). `lost` forfeits the held net out of the hold: `DR +/// DISPUTE_LOSS_EXPENSE 97 / CR DISPUTE_HOLD 97` ⇒ DISPUTE_LOSS_EXPENSE = 97, +/// DISPUTE_HOLD = 0, CASH_CLEARING untouched (the cash left at open). The +/// orchestrator bumps `clawed_back_minor` by the NET 97 (not the gross 100); the +/// total loss is net 97 + the 3 fee already expensed at settle = gross 100. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn fee_bearing_cash_hold_lost_uses_net_legs() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // gross 100, fee 3 ⇒ CASH_CLEARING = net 97. + settle_with_fee(&provider, &s, "PAY-FEE-L", 100, 3).await; + + // opened CASH_HOLD: net 97 into the hold, CASH_CLEARING dropped by 97 (→ 0). + record( + &provider, + &s, + "DSP-FEE-L", + "PAY-FEE-L", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 100, + ) + .await + .expect("opened cash-hold (fee-bearing)"); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(97), + "DISPUTE_HOLD holds the NET 97" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(0), + "CASH_CLEARING dropped by exactly 97 (→ 0)" + ); + + // lost: forfeit the net hold to loss; CASH_CLEARING untouched (cash left at open). + recorded( + record( + &provider, + &s, + "DSP-FEE-L", + "PAY-FEE-L", + None, + 1, + DisputePhase::Lost, + FundsAtOpen::Withheld, + 100, + ) + .await + .expect("lost cash-hold (fee-bearing)"), + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_loss).await, + Some(97), + "DISPUTE_LOSS_EXPENSE = the NET 97 (the 3 fee was already expensed at settle)" + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(0), + "the hold is emptied on the loss" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(0), + "CASH_CLEARING untouched by the loss (the cash left clearing at open)" + ); + assert_eq!( + clawed_back(&provider, &s, "PAY-FEE-L").await, + 97, + "clawed_back_minor bumped by the NET 97, not the gross 100" + ); +} + +// ── drain-on-opened ────────────────────────────────────────────────────────── + +/// Drain-on-`opened` (mirrors `postgres_queue.rs::settle_drains_queued_allocation`): +/// an out-of-order `won` (no `opened` landed yet) is durably QUEUED at intake +/// (§4.7); recording the `opened` then drains the CHARGEBACK queue INLINE — the +/// queued `won` applies in a second txn without waiting for the periodic sweep. +/// After the `opened`: the queue row flips `QUEUED → APPLIED`, the dispute advances +/// to `WON`, and the won's ledger effect lands (the hold is released back to +/// CASH_CLEARING). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn opened_drains_queued_outcome() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // Fund the cash leg so the cash-hold dispute has net to move. + settle(&provider, &s, "PAY-CH-DR", 1000).await; + + // Record a `won` BEFORE any `opened` — out-of-order, so it durably QUEUES (202). + let queued = record( + &provider, + &s, + "DSP-CH-DR", + "PAY-CH-DR", + None, + 1, + DisputePhase::Won, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("out-of-order won queues"); + assert!( + matches!(queued, ChargebackOutcome::Queued(_)), + "a won with no prior opened is enqueued, not posted" + ); + assert_eq!( + queue_status(&raw, &s, "DSP-CH-DR", 1, DisputePhase::Won) + .await + .as_deref(), + Some("QUEUED"), + "the out-of-order won is durably QUEUED" + ); + + // Record the `opened` — the drain-on-opened hook then applies the queued won + // inline (a fresh, non-replayed opened is the only outcome that drains). + recorded( + record( + &provider, + &s, + "DSP-CH-DR", + "PAY-CH-DR", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("opened must post (and drain the queue)"), + ); + + // The queued won applied inline: queue row → APPLIED, dispute advanced to WON. + assert_eq!( + queue_status(&raw, &s, "DSP-CH-DR", 1, DisputePhase::Won) + .await + .as_deref(), + Some("APPLIED"), + "the drain-on-opened flipped the queued won to APPLIED" + ); + assert_eq!( + dispute_row(&raw, &s, "DSP-CH-DR").await.map(|r| r.1), + Some("WON".to_owned()), + "the queued won advanced the dispute to WON" + ); + // The won's ledger effect landed: the hold is released back to clearing. + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(0), + "the hold is released by the drained won" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(1000), + "CASH_CLEARING restored to its pre-dispute net by the drained won" + ); +} + +/// Regression for the `(last_phase, cycle)` predicate on `dispute_advance`: an +/// outcome targeting a STALE cycle must NOT resolve the CURRENT open cycle. Open +/// cycle 1 (CASH_HOLD), win it, re-open as cycle 2, then submit a `lost` for the +/// already-closed cycle 1. Its dedup key (`DSP:1:lost`) never posted (cycle 1 was +/// WON, not lost), so it clears the dedup gate AND the out-of-txn transition guard +/// (which sees the cycle-2 row as OPENED and does not check the cycle). Only the +/// in-txn `cycle = 1` predicate stops it: the cycle-2 row matches 0 rows, so the +/// stale outcome is rejected as `InvalidDisputeTransition` instead of silently +/// resolving cycle 2 (and committing a second outcome entry) with cycle 1's data. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn stale_cycle_outcome_does_not_resolve_current_cycle() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + settle(&provider, &s, "PAY-SC-1", 1000).await; + // Cycle 1: open then WIN (releases the hold back to CASH_CLEARING). + record( + &provider, + &s, + "DSP-SC-1", + "PAY-SC-1", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("opened cycle 1"); + record( + &provider, + &s, + "DSP-SC-1", + "PAY-SC-1", + None, + 1, + DisputePhase::Won, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("won cycle 1"); + // Re-open as cycle 2 (a fresh OPENED on the same dispute). + record( + &provider, + &s, + "DSP-SC-1", + "PAY-SC-1", + None, + 2, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("re-opened cycle 2"); + assert_eq!( + dispute_row(&raw, &s, "DSP-SC-1").await, + Some(("CASH_HOLD".to_owned(), "OPENED".to_owned(), 2)), + "the dispute is OPENED at cycle 2 before the stale outcome" + ); + + // The stale cycle-1 `lost` clears dedup (DSP:1:lost never posted) and the + // out-of-txn guard (the row is OPENED) — only the cycle predicate rejects it. + let err = record( + &provider, + &s, + "DSP-SC-1", + "PAY-SC-1", + None, + 1, + DisputePhase::Lost, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect_err("a stale-cycle outcome must be rejected"); + assert!( + matches!(err, DomainError::InvalidDisputeTransition(_)), + "stale-cycle outcome must be InvalidDisputeTransition, got {err:?}" + ); + + // The cycle-2 row is UNCHANGED — still OPENED at cycle 2, not rewritten to + // LOST cycle 1 by the stale outcome. + assert_eq!( + dispute_row(&raw, &s, "DSP-SC-1").await, + Some(("CASH_HOLD".to_owned(), "OPENED".to_owned(), 2)), + "the stale outcome left the current open cycle untouched" + ); +} + +/// Regression (cross-currency dispute, H2): a CASH_HOLD chargeback sizes its net +/// cash leg off the SETTLED payment's counters and posts in the request currency, +/// so a request whose currency differs from the settlement (a mistyped or +/// malicious EUR dispute on a USD payment) must be rejected as `CurrencyMismatch` +/// before any leg is sized or posted — mirrors the allocation currency gate. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cash_hold_chargeback_in_wrong_currency_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + settle(&provider, &s, "PAY-XC-1", 1000).await; // settled in USD + let err = chargeback_svc(&provider) + .record_phase( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + ChargebackRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-XC-1".to_owned(), + dispute_id: "DSP-XC-1".to_owned(), + invoice_id: None, + cycle: 1, + phase: DisputePhase::Opened, + funds_at_open: FundsAtOpen::Withheld, + disputed_amount_minor: 1000, + currency: "EUR".to_owned(), // != settled USD + effective_at: None, + }, + ) + .await + .expect_err("a cross-currency chargeback must be rejected"); + assert!( + matches!(err, DomainError::CurrencyMismatch(_)), + "expected CurrencyMismatch, got {err:?}" + ); + // Nothing persisted: no dispute row was seeded. + assert_eq!( + dispute_row(&raw, &s, "DSP-XC-1").await, + None, + "a rejected cross-currency dispute seeds no row" + ); +} + +/// Regression — cross-feature stranded hold: a settlement-return that lowers +/// a payment's settled total AFTER a CASH_HOLD dispute opened must NOT change what +/// the outcome releases. The held cash is a fact fixed at open +/// (`cash_hold_minor`), so the `won` outcome releases the FULL amount held — not a +/// re-read `settled − fee`. Pre-fix the outcome re-read the now-lower net and +/// released too little, stranding cash in DISPUTE_HOLD. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn won_cash_hold_after_settlement_return_releases_full_held_amount() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // Two settled receipts fund the shared CASH_CLEARING pool so the return on + // PAY-A can credit clearing back (PAY-A's own net is parked in the hold). + settle(&provider, &s, "PAY-A", 1000).await; + settle(&provider, &s, "PAY-B", 1000).await; + + // Open a CASH_HOLD dispute on PAY-A: the full net (1000, fee 0) parks in the + // hold (CASH_CLEARING 2000 → 1000, DISPUTE_HOLD → 1000). + recorded( + record( + &provider, + &s, + "DISP-A", + "PAY-A", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .unwrap(), + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(1000), + "open parks the full net in the hold" + ); + + // Partial settlement-return on PAY-A AFTER the dispute opened: its settled/net + // drops 1000 → 500 (CASH_CLEARING 1000 → 500). Pre-fix the won outcome would + // then re-read net=500 and release only that. + return_settlement(&provider, &s, "PAY-A", "RET-A", 500).await; + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(500), + "the partial return credits clearing back down to 500" + ); + + // Win the dispute: the hold releases the amount HELD at open (1000), sized off + // the stored `cash_hold_minor` — not the now-lower net. + recorded( + record( + &provider, + &s, + "DISP-A", + "PAY-A", + None, + 1, + DisputePhase::Won, + FundsAtOpen::Withheld, + 1000, + ) + .await + .unwrap(), + ); + + // The hold is fully released (pre-fix: 500 stranded), and clearing is restored + // to the consistent 1500 = 2000 settled − 500 returned. + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(0), + "won releases the full held amount; DISPUTE_HOLD is not stranded" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(1500), + "clearing restored by the full held net; books consistent (2000 − 500)" + ); +} + +// ── partial cash-hold (disputed < net) ─────────────────────────────────────── + +/// A buyer disputes only PART of a settled receipt: settle 1000 (CASH_CLEARING = +/// 1000, fee 0 ⇒ net = 1000), then `opened` a CASH_HOLD dispute for only 600. The +/// orchestrator sizes the hold at `cash_hold_minor = min(disputed, net) = +/// min(600, 1000) = 600` (the `min` branch the all-or-nothing 1000-disputed cases +/// never exercise), so only 600 moves CASH_CLEARING → DISPUTE_HOLD and 400 of +/// collected cash stays in clearing. `won` then releases exactly the 600 held back +/// (CASH_CLEARING → 1000); a partial dispute leaves the un-disputed remainder +/// untouched throughout. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn opened_partial_cash_hold_parks_only_disputed_slice() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // Settle 1000: CASH_CLEARING holds the full net 1000. + settle(&provider, &s, "PAY-PCH-1", 1000).await; + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(1000), + "settle lands the full net in CASH_CLEARING" + ); + + // Dispute only 600 of the 1000: the hold is sized at min(600, 1000) = 600. + recorded( + record( + &provider, + &s, + "DSP-PCH-1", + "PAY-PCH-1", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 600, + ) + .await + .expect("partial opened cash-hold must post"), + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(600), + "DISPUTE_HOLD holds only the 600 disputed slice (min(disputed, net))" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(400), + "CASH_CLEARING keeps the 400 un-disputed remainder (1000 − 600)" + ); + assert_eq!( + dispute_row(&raw, &s, "DSP-PCH-1").await, + Some(("CASH_HOLD".to_owned(), "OPENED".to_owned(), 1)), + "dispute row: variant=CASH_HOLD, last_phase=OPENED" + ); + + // Win the partial dispute: the 600 held is released back, leaving CASH_CLEARING + // restored to the full 1000 (the 400 was never disputed). + recorded( + record( + &provider, + &s, + "DSP-PCH-1", + "PAY-PCH-1", + None, + 1, + DisputePhase::Won, + FundsAtOpen::Withheld, + 600, + ) + .await + .expect("partial won cash-hold must post"), + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(0), + "the 600 hold is fully released on won" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(1000), + "CASH_CLEARING restored to the full collected 1000 (only the slice round-tripped)" + ); + // A won claws nothing back. + assert_eq!( + clawed_back(&provider, &s, "PAY-PCH-1").await, + 0, + "a won claws nothing back" + ); +} + +// ── lost cash-hold on an already-refunded payment → CHARGEBACK_ON_REFUNDED ──── + +/// `guard_not_on_refunded` real-path (chargeback.rs L1138-1199): a `lost` +/// cash-hold whose clawback cannot fit under the total money-out cap BECAUSE the +/// payment was already (partially) refunded routes to the minimal exception stub — +/// logged + `ChargebackOnRefunded` — instead of a generic `ChargebackExceedsSettled`. +/// +/// Scenario (a real PSP sequence): a 1000 receipt is settled, then a 600 refund +/// already landed against it (`refunded_minor = 600`, seeded directly on the +/// settlement counter — the refund path's own write — exactly as +/// `postgres_payments.rs::bump_allocation_refund_nets_and_caps` drives a counter to +/// a target state). A CASH_HOLD dispute then opens for the full 1000 (hold = 1000) +/// and is LOST: the clawback (1000) would push `refunded(600) + clawed(0) + +/// clawback(1000) = 1600 > settled(1000)` over the cap, AND `refunded_minor > 0`, +/// so the pre-check raises `ChargebackOnRefunded`. The post never reaches the +/// engine: the dispute stays OPENED, DISPUTE_HOLD keeps the held 1000 (the loss did +/// not post), and `clawed_back_minor` stays 0. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn lost_cash_hold_on_refunded_payment_routes_to_exception() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // Settle 1000, open a CASH_HOLD dispute for the full 1000 (hold = 1000). + settle(&provider, &s, "PAY-CBR-1", 1000).await; + recorded( + record( + &provider, + &s, + "DSP-CBR-1", + "PAY-CBR-1", + None, + 1, + DisputePhase::Opened, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect("opened cash-hold must post"), + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(1000), + "the full net is held at open" + ); + + // A 600 refund already landed on this payment: bump `refunded_minor` to 600 on + // the settlement counter (the refund path's own write; seeded directly so this + // test owns the dispute lifecycle, mirroring the counter-seed idiom). 600 + 0 + // <= 1000 still satisfies the money-out cap, so the seed itself is admissible. + raw.execute(pg(format!( + "UPDATE bss.ledger_payment_settlement SET refunded_minor = 600 \ + WHERE tenant_id='{}' AND payment_id='PAY-CBR-1'", + s.tenant + ))) + .await + .expect("seeding a partial refund (within the cap) must be admissible"); + + // Lose the dispute: clawback 1000 cannot fit (600 + 1000 > 1000) AND the + // payment was refunded ⇒ ChargebackOnRefunded (the specific already-refunded + // signal), not the generic ChargebackExceedsSettled. + let err = record( + &provider, + &s, + "DSP-CBR-1", + "PAY-CBR-1", + None, + 1, + DisputePhase::Lost, + FundsAtOpen::Withheld, + 1000, + ) + .await + .expect_err("a lost on an already-refunded payment whose clawback can't fit must be rejected"); + assert!( + matches!(err, DomainError::ChargebackOnRefunded(_)), + "expected ChargebackOnRefunded (the already-refunded route), got {err:?}" + ); + + // The pre-check rejected BEFORE the post: the loss never booked. The dispute is + // still OPENED (not advanced to LOST), DISPUTE_HOLD still holds the 1000, no + // DISPUTE_LOSS_EXPENSE, and clawed_back_minor stays 0. + assert_eq!( + dispute_row(&raw, &s, "DSP-CBR-1").await.map(|r| r.1), + Some("OPENED".to_owned()), + "the rejected lost left the dispute OPENED (the outcome never advanced it)" + ); + assert_eq!( + account_balance(&raw, &s, s.dispute_hold).await, + Some(1000), + "the held cash is untouched (the forfeit never posted)" + ); + assert!( + matches!( + account_balance(&raw, &s, s.dispute_loss).await, + None | Some(0) + ), + "no DISPUTE_LOSS_EXPENSE was booked" + ); + assert_eq!( + clawed_back(&provider, &s, "PAY-CBR-1").await, + 0, + "nothing was clawed back (the cap pre-check rejected first)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_credit.rs b/gears/bss/ledger/ledger/tests/postgres_credit.rs new file mode 100644 index 000000000..f9c50ec9d --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_credit.rs @@ -0,0 +1,828 @@ +//! Postgres-only **service-level** integration tests for the reusable-credit +//! (wallet) slice: they drive the REAL `CreditApplicationService` (grant / apply) +//! through the foundation `PostingService` against a testcontainer Postgres. +//! Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_credit -- --ignored`. +//! +//! The credit counterpart to `postgres_payments.rs`. `setup_seller` provisions +//! the chart (CASH_CLEARING / UNALLOCATED / PSP_FEE_EXPENSE / AR + a stream-less +//! REUSABLE_CREDIT credit account), USD@2, and an OPEN fiscal period for the +//! CURRENT month (grant/apply derive `period_id` from `Utc::now()` — they post +//! effective-now). The wallet itself is a projector grain +//! (`bss.ledger_reusable_credit_subbalance`), read here by raw SQL. +//! +//! Covers: (a) a grant moves `DR UNALLOCATED / CR REUSABLE_CREDIT` — the wallet +//! sub-grain rises and the unallocated pool drops by the same amount; (b) an +//! apply draws the wallet OLDEST-GRANT-FIRST across two sub-grains ("promo" +//! before the later "goodwill"), drains the named AR, and reports the per-grain +//! `debits`; (c) a grant past the live unallocated pool is rejected +//! `GrantExceedsUnallocated` before any post; (d) an apply whose targets exceed +//! open AR is rejected `CreditExceedsOpenAr`; (e) an apply past the available +//! wallet is rejected `CreditExceedsWallet`; (f) a replay of the same +//! `credit_application_id` returns the prior posting and moves nothing. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::needless_pass_by_value +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::precedence::Allocated; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::credit::{ApplyRequest, CreditApplicationService, GrantRequest}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{PaymentRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot a container, run the chain on a raw connection, and return a +/// `bss`-search-path `DBProvider`. Mirrors `postgres_payments::boot`. +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller ids for the credit service tests (mirrors +/// `postgres_payments::Seller`, plus the stream-less REUSABLE_CREDIT account the +/// wallet posts hit). +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + reusable_credit: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision a seller: USD@2 scale, an OPEN fiscal period for the current month, +/// the four payment-flow chart accounts (CASH_CLEARING debit, UNALLOCATED credit, +/// PSP_FEE_EXPENSE debit, AR debit), and a stream-less REUSABLE_CREDIT credit +/// account (the wallet). Reuses the file's `boot()` for the container/provider. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + reusable_credit: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + // The wallet: REUSABLE_CREDIT is credit-normal and stream-less (resolves + // on `stream = None`, like UNALLOCATED). + account( + s.tenant, + s.reusable_credit, + AccountClass::ReusableCredit, + Side::Credit, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn credit_svc(provider: &DBProvider) -> CreditApplicationService { + CreditApplicationService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn settlement_input(s: &Seller, payment_id: &str, gross: i64, fee: i64) -> SettlementInput { + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "USD".to_owned(), + // None ⇒ the orchestrator stamps a current-month effective date / period + // (matching the OPEN period `setup_seller` provisions). + effective_at: None, + } +} + +/// Fund the payer's unallocated pool by settling a fee-less payment of `gross`. +async fn fund_pool(provider: &DBProvider, s: &Seller, payment_id: &str, gross: i64) { + settle_svc(provider) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + settlement_input(s, payment_id, gross, 0), + ) + .await + .expect("settle to fund the pool"); +} + +/// Read the live unallocated pool for the payer (the grant cap basis). +async fn unallocated(provider: &DBProvider, s: &Seller) -> i64 { + PaymentRepo::new(provider.clone()) + .read_unallocated(&AccessScope::for_tenant(s.tenant), s.tenant, s.payer, "USD") + .await + .unwrap() +} + +/// Read one wallet sub-grain's `balance_minor` from the projector cache. The +/// table is keyed by `(tenant, payer, account_id, currency, event_type)`; the +/// read here matches on `(tenant, payer, currency, event_type)` (the values that +/// uniquely identify the bucket for this seller's single REUSABLE_CREDIT account) +/// — mirrors the tax sub-balance raw-read idiom in `postgres_projector.rs`. +async fn wallet_subgrain( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + event_type: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_reusable_credit_subbalance \ + WHERE tenant_id='{}' AND payer_tenant_id='{}' AND currency='USD' \ + AND credit_grant_event_type='{}'", + s.tenant, s.payer, event_type + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn ar_invoice_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND invoice_id='{}'", + s.tenant, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Seed an OPEN AR invoice by posting a balanced `DR AR (invoice_id) / CR +/// PSP_FEE_EXPENSE` directly through the engine (mirrors +/// `postgres_payments::seed_ar_invoice`). PSP_FEE_EXPENSE is unguarded, so this +/// lands a clean `ar_invoice_balance` row with `original_posted_at = posted_at` +/// (the oldest-first sort key); `posted_at` is supplied explicitly so the test +/// controls ordering deterministically. +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn grant_raises_wallet_and_lowers_unallocated() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle 1000 into the pool, then grant 600 to the "promo" wallet bucket: + // DR UNALLOCATED 600 / CR REUSABLE_CREDIT 600. + fund_pool(&provider, &s, "PAY-GRANT-1", 1000).await; + assert_eq!( + unallocated(&provider, &s).await, + 1000, + "pool funded to 1000" + ); + + let outcome = credit_svc(&provider) + .grant_credit( + &ctx, + &scope, + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-GRANT-1".to_owned(), + currency: "USD".to_owned(), + amount_minor: 600, + credit_grant_event_type: "promo".to_owned(), + }, + ) + .await + .expect("grant must succeed"); + assert!(!outcome.posting.replayed, "first grant is fresh"); + // A grant moves no wallet/AR splits — both vectors are empty. + assert!(outcome.debits.is_empty(), "a grant emits no debits"); + assert!(outcome.targets.is_empty(), "a grant emits no targets"); + + // The wallet sub-grain rose to 600, and the pool dropped by exactly 600. + assert_eq!( + wallet_subgrain(&raw, &s, "promo").await, + Some(600), + "the promo wallet sub-grain holds the grant" + ); + assert_eq!( + unallocated(&provider, &s).await, + 400, + "the unallocated pool dropped by the grant amount (1000 - 600)" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn apply_draws_oldest_grant_first_across_subgrains() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let svc = credit_svc(&provider); + + // Fund the pool, then grant 400 "promo" FIRST and 300 "goodwill" SECOND. The + // wallet sub-balance projector stamps `first_granted_at` from each grant's + // post instant (first-write-wins, per bucket), so promo's stamp is strictly + // earlier than goodwill's — apply draws promo first (oldest-grant-first). The + // brief sleep makes that ordering immune to a same-instant clock collision + // (which would otherwise fall back to the `credit_grant_event_type` ASC + // tiebreak, i.e. "goodwill" before "promo"). + fund_pool(&provider, &s, "PAY-APPLY-1", 1000).await; + svc.grant_credit( + &ctx, + &scope, + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-PROMO".to_owned(), + currency: "USD".to_owned(), + amount_minor: 400, + credit_grant_event_type: "promo".to_owned(), + }, + ) + .await + .expect("grant promo"); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + svc.grant_credit( + &ctx, + &scope, + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-GOODWILL".to_owned(), + currency: "USD".to_owned(), + amount_minor: 300, + credit_grant_event_type: "goodwill".to_owned(), + }, + ) + .await + .expect("grant goodwill"); + assert_eq!(wallet_subgrain(&raw, &s, "promo").await, Some(400)); + assert_eq!(wallet_subgrain(&raw, &s, "goodwill").await, Some(300)); + + // One open AR invoice of 500, paid entirely by the wallet. + seed_ar_invoice( + &provider, + &s, + "inv-1", + 500, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + // Apply 500 against inv-1: oldest-grant-first drains promo (400) then takes + // the remaining 100 from goodwill. + let outcome = svc + .apply_credit( + &ctx, + &scope, + ApplyRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-APPLY-1".to_owned(), + currency: "USD".to_owned(), + targets: vec![Allocated { + invoice_id: "inv-1".to_owned(), + amount_minor: 500, + }], + }, + ) + .await + .expect("apply must succeed"); + assert!(!outcome.posting.replayed, "first apply is fresh"); + + // The debits are the per-sub-grain draw-downs in fill order: promo 400 then + // goodwill 100. + let debits: Vec<(String, i64)> = outcome + .debits + .iter() + .map(|d| (d.credit_grant_event_type.clone(), d.amount_minor)) + .collect(); + assert_eq!( + debits, + vec![("promo".to_owned(), 400), ("goodwill".to_owned(), 100)], + "oldest-grant-first draws promo (400) then goodwill (100)" + ); + // The targets echo the validated receivable shares. + assert_eq!(outcome.targets.len(), 1); + assert_eq!(outcome.targets[0].invoice_id, "inv-1"); + assert_eq!(outcome.targets[0].amount_minor, 500); + + // AR fully paid; promo drained to 0, goodwill down to 200. + assert_eq!(ar_invoice_balance(&raw, &s, "inv-1").await, Some(0)); + assert_eq!(wallet_subgrain(&raw, &s, "promo").await, Some(0)); + assert_eq!(wallet_subgrain(&raw, &s, "goodwill").await, Some(200)); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn grant_exceeding_unallocated_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Pool holds only 100; a grant of 500 exceeds it ⇒ rejected before any post. + fund_pool(&provider, &s, "PAY-GRANT-OVR", 100).await; + + let err = credit_svc(&provider) + .grant_credit( + &ctx, + &scope, + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-GRANT-OVR".to_owned(), + currency: "USD".to_owned(), + amount_minor: 500, + credit_grant_event_type: "promo".to_owned(), + }, + ) + .await + .expect_err("an over-pool grant must be rejected"); + assert!( + matches!(err, DomainError::GrantExceedsUnallocated(_)), + "expected GrantExceedsUnallocated, got {err:?}" + ); + + // Rejected before the post: no wallet sub-grain row, pool unchanged at 100. + assert_eq!( + wallet_subgrain(&raw, &s, "promo").await, + None, + "the rejected grant created no wallet sub-grain" + ); + assert_eq!( + unallocated(&provider, &s).await, + 100, + "the unallocated pool is unchanged by a rejected grant" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn apply_exceeding_open_ar_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let svc = credit_svc(&provider); + + // Fund a wallet of 1000 (ample), but seed an open AR invoice of only 300. + fund_pool(&provider, &s, "PAY-AR-OVR", 1000).await; + svc.grant_credit( + &ctx, + &scope, + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-AR-OVR-GRANT".to_owned(), + currency: "USD".to_owned(), + amount_minor: 1000, + credit_grant_event_type: "promo".to_owned(), + }, + ) + .await + .expect("grant to fund the wallet"); + seed_ar_invoice( + &provider, + &s, + "inv-1", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + // 500 > inv-1's open 300 ⇒ CreditExceedsOpenAr (the per-invoice open cap; the + // wallet (1000) is ample, so the wallet cap is NOT what trips). + let err = svc + .apply_credit( + &ctx, + &scope, + ApplyRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-AR-OVR".to_owned(), + currency: "USD".to_owned(), + targets: vec![Allocated { + invoice_id: "inv-1".to_owned(), + amount_minor: 500, + }], + }, + ) + .await + .expect_err("an over-open-AR apply must be rejected"); + assert!( + matches!(err, DomainError::CreditExceedsOpenAr(_)), + "expected CreditExceedsOpenAr, got {err:?}" + ); + + // Rejected before the post: AR untouched, wallet untouched. + assert_eq!(ar_invoice_balance(&raw, &s, "inv-1").await, Some(300)); + assert_eq!(wallet_subgrain(&raw, &s, "promo").await, Some(1000)); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn apply_exceeding_wallet_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let svc = credit_svc(&provider); + + // Wallet holds only 200, but the open AR invoice is 500. The targets sit + // within open AR (500 <= 500), so the receivable cap passes and the WALLET + // cap is what trips. + fund_pool(&provider, &s, "PAY-WAL-OVR", 1000).await; + svc.grant_credit( + &ctx, + &scope, + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-WAL-OVR-GRANT".to_owned(), + currency: "USD".to_owned(), + amount_minor: 200, + credit_grant_event_type: "promo".to_owned(), + }, + ) + .await + .expect("grant a small wallet"); + seed_ar_invoice( + &provider, + &s, + "inv-1", + 500, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let err = svc + .apply_credit( + &ctx, + &scope, + ApplyRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-WAL-OVR".to_owned(), + currency: "USD".to_owned(), + targets: vec![Allocated { + invoice_id: "inv-1".to_owned(), + amount_minor: 500, + }], + }, + ) + .await + .expect_err("an over-wallet apply must be rejected"); + assert!( + matches!(err, DomainError::CreditExceedsWallet(_)), + "expected CreditExceedsWallet, got {err:?}" + ); + + // Rejected before the post: AR untouched, wallet still 200. + assert_eq!(ar_invoice_balance(&raw, &s, "inv-1").await, Some(500)); + assert_eq!(wallet_subgrain(&raw, &s, "promo").await, Some(200)); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn apply_replays_idempotently() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let svc = credit_svc(&provider); + + // Fund a 500 wallet and an open AR of 500. + fund_pool(&provider, &s, "PAY-RPL", 1000).await; + svc.grant_credit( + &ctx, + &scope, + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-RPL-GRANT".to_owned(), + currency: "USD".to_owned(), + amount_minor: 500, + credit_grant_event_type: "promo".to_owned(), + }, + ) + .await + .expect("grant the wallet"); + seed_ar_invoice( + &provider, + &s, + "inv-1", + 500, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let apply = || ApplyRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-RPL-APPLY".to_owned(), + currency: "USD".to_owned(), + targets: vec![Allocated { + invoice_id: "inv-1".to_owned(), + amount_minor: 300, + }], + }; + + let first = svc + .apply_credit(&ctx, &scope, apply()) + .await + .expect("first"); + assert!(!first.posting.replayed, "first apply is fresh"); + // One application's effect landed: AR 500→200, wallet 500→200. + assert_eq!(ar_invoice_balance(&raw, &s, "inv-1").await, Some(200)); + assert_eq!(wallet_subgrain(&raw, &s, "promo").await, Some(200)); + + // A second apply with the SAME credit_application_id replays the prior + // posting and moves nothing further (the AR has drained below the target, so + // a *fresh* apply would be rejected — only a true replay leaves the prior + // entry and the balances untouched). + let second = svc + .apply_credit(&ctx, &scope, apply()) + .await + .expect("replay"); + assert!( + second.posting.replayed, + "second apply is an idempotent replay" + ); + assert_eq!( + first.posting.entry_id, second.posting.entry_id, + "replay returns the prior entry" + ); + assert_eq!( + ar_invoice_balance(&raw, &s, "inv-1").await, + Some(200), + "AR unchanged on replay" + ); + assert_eq!( + wallet_subgrain(&raw, &s, "promo").await, + Some(200), + "wallet unchanged on replay" + ); +} + +/// Idempotency-key reuse with a DIFFERENT payload is a conflict, not a silent +/// replay (Codex P2). Grant 600 under `CR-FP`, then re-send the SAME id: an +/// IDENTICAL grant replays cleanly, but a different amount is rejected as +/// `IdempotencyConflict` — the credit short-circuit compares the request-based +/// dedup hash instead of blindly returning the prior posting. The wallet keeps +/// exactly the original 600. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn credit_idempotency_key_reuse_with_different_payload_conflicts() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let svc = credit_svc(&provider); + + fund_pool(&provider, &s, "PAY-CR-FP", 1000).await; + let grant = |amount: i64| GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-FP".to_owned(), + currency: "USD".to_owned(), + amount_minor: amount, + credit_grant_event_type: "promo".to_owned(), + }; + + let first = svc + .grant_credit(&ctx, &scope, grant(600)) + .await + .expect("first grant"); + assert!(!first.posting.replayed, "first grant is fresh"); + + // Same id, IDENTICAL payload ⇒ a clean replay. + let replay = svc + .grant_credit(&ctx, &scope, grant(600)) + .await + .expect("identical replay is accepted"); + assert!(replay.posting.replayed, "an identical re-send replays"); + assert_eq!( + first.posting.entry_id, replay.posting.entry_id, + "replay returns the prior entry" + ); + + // Same id, DIFFERENT amount ⇒ idempotency conflict, not a silent replay. + let err = svc + .grant_credit(&ctx, &scope, grant(400)) + .await + .expect_err("reused id with a different payload is rejected"); + assert!( + matches!(err, DomainError::IdempotencyConflict(_)), + "expected IdempotencyConflict, got {err:?}" + ); + + // The wallet still holds exactly the original 600 (the conflict moved nothing). + assert_eq!(wallet_subgrain(&raw, &s, "promo").await, Some(600)); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_credit_concurrency.rs b/gears/bss/ledger/ledger/tests/postgres_credit_concurrency.rs new file mode 100644 index 000000000..baf858fb9 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_credit_concurrency.rs @@ -0,0 +1,646 @@ +//! Postgres-only **concurrency** tests for the reusable-credit (wallet) slice: +//! they race the real `CreditApplicationService` (grant / apply) on a +//! testcontainer Postgres and pin the wallet invariant under contention — the +//! sub-grain no-negative CHECK (`chk_reusable_credit_subbalance_no_negative`) is +//! the backstop that bounds total draw-down at the funded amount. Ignored by +//! default; run with +//! `cargo test -p bss-ledger --test postgres_credit_concurrency -- --ignored`. +//! +//! Covers: (1) two concurrent `apply_credit`s draining ONE wallet never overspend +//! it — the wallet never goes negative and the total drawn never exceeds the +//! funded amount (a clean reject of the loser is acceptable per Decision O); (2) +//! a grant and an apply touching the SAME `(payer, currency, event_type)` +//! sub-grain run concurrently without deadlock — both complete (or one cleanly +//! rejects) and the final wallet balance is consistent. +//! +//! Self-contained: re-declares the small `boot` / `setup_seller` / +//! `seed_ar_invoice` helpers it needs (mirrors `postgres_payment_concurrency.rs`), +//! so it does not depend on `postgres_credit.rs`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::needless_pass_by_value +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::precedence::Allocated; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::credit::{ApplyRequest, CreditApplicationService, GrantRequest}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot a container, migrate on a raw connection, and return a `bss`-search-path +/// `DBProvider`. Mirrors `postgres_payment_concurrency::boot`. +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller ids for the credit concurrency tests (mirrors +/// `postgres_credit::Seller`). +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + reusable_credit: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision a seller: USD@2 scale, an OPEN fiscal period for the current month, +/// the four payment-flow chart accounts, and a stream-less REUSABLE_CREDIT credit +/// account (the wallet). Mirrors `postgres_credit::setup_seller`. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + reusable_credit: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + account( + s.tenant, + s.reusable_credit, + AccountClass::ReusableCredit, + Side::Credit, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn credit_svc(provider: &DBProvider) -> CreditApplicationService { + CreditApplicationService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn settlement_input(s: &Seller, payment_id: &str, gross: i64) -> SettlementInput { + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + } +} + +/// Fund the payer's unallocated pool, then grant `amount` into the named wallet +/// sub-grain (the apply tests' wallet funding path; grant is the only way to land +/// a `reusable_credit_subbalance` row with a real `first_granted_at`). +async fn fund_wallet( + provider: &DBProvider, + s: &Seller, + payment_id: &str, + credit_application_id: &str, + event_type: &str, + amount: i64, +) { + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + settle_svc(provider) + .settle(&ctx, &scope, settlement_input(s, payment_id, amount)) + .await + .expect("settle to fund the pool"); + credit_svc(provider) + .grant_credit( + &ctx, + &scope, + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: credit_application_id.to_owned(), + currency: "USD".to_owned(), + amount_minor: amount, + credit_grant_event_type: event_type.to_owned(), + }, + ) + .await + .expect("grant to fund the wallet"); +} + +/// Read one wallet sub-grain's `balance_minor` from the projector cache (the +/// concurrency invariant surface). `0` when the row is absent. +async fn wallet_subgrain(raw: &sea_orm::DatabaseConnection, s: &Seller, event_type: &str) -> i64 { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_reusable_credit_subbalance \ + WHERE tenant_id='{}' AND payer_tenant_id='{}' AND currency='USD' \ + AND credit_grant_event_type='{}'", + s.tenant, s.payer, event_type + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +async fn ar_invoice_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND invoice_id='{}'", + s.tenant, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Seed an OPEN AR invoice by a DIRECT raw INSERT into the cache (mirrors the +/// `list_open_ar_invoices` / `allocate_too_large` seed idiom in +/// `postgres_payments.rs` / `postgres_payment_concurrency.rs`): no invoice +/// posting, just a `balance_minor > 0` candidate row the apply's open-AR read +/// will return. Only the NOT-NULL-without-default columns are supplied; +/// `original_posted_at` is set so the oldest-first order is deterministic. +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + // Post a REAL balanced invoice (DR AR / CR PSP_FEE_EXPENSE) so ALL three AR + // grains (account_balance, ar_payer_balance, ar_invoice_balance) are credited + // — a raw INSERT into only the per-invoice cache would leave the aggregate AR + // balances at 0, and the apply's CR AR would then drive them negative + // (masking the wallet-cap race these tests target). Credit apply uses EXPLICIT + // targets, so the AR posted-at ordering is irrelevant; the period must be the + // OPEN current month, so callers pass a current-month `posted_at`. + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Client-side retry of a service call on a projector-level serialization +/// conflict (copied verbatim from `postgres_payment_concurrency.rs`). Under +/// `SERIALIZABLE`, two ops racing the same wallet grain make one abort with a +/// 40001 that the projector stringifies into `DomainError::Internal("…could not +/// serialize…")` — decision O defers in-service recompute-on-retry to the CALLER, +/// so the test models what the SDK/client does: re-run the WHOLE operation until +/// it commits or hits a real business rejection. A genuine deadlock (40P01) is +/// NOT a serialization conflict and propagates — failing the test loudly, which +/// is exactly the deadlock-freedom guarantee. +async fn retry_on_serialization(mut op: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for _ in 0..20 { + match op().await { + Err(DomainError::Internal(m)) if m.contains("serialize") => {} + other => return other, + } + } + op().await +} + +/// Credit #1: two concurrent applies draining ONE wallet sub-grain must never +/// overspend it. The wallet is funded at 500 ("promo"); two open AR invoices of +/// 300 each give the pair more receivable headroom (600) than the wallet holds +/// (500), so the WALLET — not the AR — is the binding cap. Each apply draws 300; +/// at most one-and-a-fraction can fit. The no-negative sub-grain CHECK +/// (`chk_reusable_credit_subbalance_no_negative`) is the backstop: a loser racing +/// the same grain serializes (the client retries the 40001) and then either fits +/// in the remaining wallet or is cleanly rejected `CreditExceedsWallet`. INVARIANT: +/// the wallet never goes negative and the total drawn never exceeds the funded +/// 500. Mirrors `postgres_payment_concurrency::concurrent_allocate_respects_per +/// _payment_cap`, scaled to a wallet-cap race. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_applies_drain_one_wallet_without_overspend() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // Fund a 500 "promo" wallet, and seed two open AR invoices of 300 each (600 + // receivable headroom > 500 wallet, so the wallet is the binding cap). + fund_wallet(&provider, &s, "PAY-WAL", "CR-FUND", "promo", 500).await; + seed_ar_invoice(&provider, &s, "inv-1", 300, Utc::now()).await; + seed_ar_invoice(&provider, &s, "inv-2", 300, Utc::now()).await; + assert_eq!( + wallet_subgrain(&raw, &s, "promo").await, + 500, + "wallet funded" + ); + + // Two applies, each draining 300 from the wallet against a DISTINCT invoice + // (distinct credit_application_id ⇒ the dedup key never collides — both are + // genuine fresh applies racing the wallet cap). Each wrapped in + // `retry_on_serialization` so a projector-level 40001 is retried, not failed. + let apply = |credit_application_id: &'static str, invoice_id: &'static str| { + let provider = provider.clone(); + let scope = AccessScope::for_tenant(s.tenant); + let tenant = s.tenant; + let payer = s.payer; + tokio::spawn(async move { + let svc = credit_svc(&provider); + let ctx = SecurityContext::anonymous(); + retry_on_serialization(|| { + svc.apply_credit( + &ctx, + &scope, + ApplyRequest { + tenant_id: tenant, + payer_tenant_id: payer, + credit_application_id: credit_application_id.to_owned(), + currency: "USD".to_owned(), + targets: vec![Allocated { + invoice_id: invoice_id.to_owned(), + amount_minor: 300, + }], + }, + ) + }) + .await + }) + }; + + let a = apply("CR-A", "inv-1"); + let b = apply("CR-B", "inv-2"); + let (ra, rb) = tokio::join!(a, b); + let ra = ra.expect("apply task A must not panic / deadlock"); + let rb = rb.expect("apply task B must not panic / deadlock"); + + // Each side either committed or was CLEANLY rejected — and never deadlocked + // (a deadlock would have propagated out of `retry_on_serialization` and + // panicked the task). A loser of the wallet-cap race rejects one of two + // legitimate ways (decision O — a clean reject on a race, not necessarily a + // clean 400): the orchestrator's pre-check saw the already-reduced wallet + // (`CreditExceedsWallet`), or the projector's no-negative backstop caught the + // overdraw at post time on the wallet sub-grain (`NegativeBalance`). Both roll + // back with no overspend. + let mut drawn = 0i64; + for result in [&ra, &rb] { + match result { + Ok(outcome) => drawn += outcome.debits.iter().map(|d| d.amount_minor).sum::(), + Err(DomainError::CreditExceedsWallet(_) | DomainError::NegativeBalance(_)) => {} + Err(other) => panic!( + "a losing concurrent apply must cleanly reject (CreditExceedsWallet or \ + NegativeBalance), got: {other:?}" + ), + } + } + // At least one apply made progress. + assert!( + ra.is_ok() || rb.is_ok(), + "at least one concurrent apply must win: {ra:?} / {rb:?}" + ); + + // INVARIANT: the wallet never went negative, and the total drawn never + // exceeded the funded 500 (no overspend). The remaining balance equals the + // funded amount minus what the winners drew. + let remaining = wallet_subgrain(&raw, &s, "promo").await; + assert!( + remaining >= 0, + "the wallet sub-grain must never go negative, got {remaining}" + ); + assert!( + drawn <= 500, + "total drawn ({drawn}) must never exceed the funded wallet (500)" + ); + assert_eq!( + remaining, + 500 - drawn, + "remaining wallet == funded - drawn (no double-spend, no leak)" + ); +} + +/// Credit #2: a grant and an apply touching the SAME `(payer, currency, +/// event_type)` sub-grain run concurrently and must serialize WITHOUT deadlock. +/// The wallet starts at 400 "promo"; a grant of +300 and an apply of -300 (against +/// an open AR of 300) both write the same sub-grain row. SERIALIZABLE + the client +/// retry (decision O) must serialize them — both complete (the `tokio::join!` +/// returns with no runtime hang). The two orderings give two consistent end +/// states, both checked: grant-then-apply ⇒ 400 + 300 − 300 = 400; apply-then- +/// grant ⇒ 400 − 300 + 300 = 400. Either way the final balance is 400 and the AR +/// is fully paid. (Should the apply instead lose cleanly — e.g. if it serialized +/// against an empty wallet read — that is acceptable per decision O; the assert +/// admits a clean apply rejection and pins the resulting balance.) Mirrors +/// `postgres_payment_concurrency::allocate_and_invoice_post_serialize_without +/// _deadlock`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires Docker (testcontainers)"] +async fn grant_and_apply_on_same_subgrain_serialize_without_deadlock() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + // Start the "promo" wallet at 400, and fund the pool with extra headroom so a + // concurrent +300 grant has unallocated cash to draw from. Seed an open AR of + // 300 the apply pays. + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-SEED", 1000)) + .await + .expect("settle to fund the pool (400 grant + 300 concurrent grant headroom)"); + credit_svc(&provider) + .grant_credit( + &ctx, + &scope, + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: "CR-SEED".to_owned(), + currency: "USD".to_owned(), + amount_minor: 400, + credit_grant_event_type: "promo".to_owned(), + }, + ) + .await + .expect("seed the wallet at 400"); + seed_ar_invoice(&provider, &s, "inv-1", 300, Utc::now()).await; + assert_eq!( + wallet_subgrain(&raw, &s, "promo").await, + 400, + "wallet seeded" + ); + + // Race a +300 grant and a -300 apply on the SAME promo sub-grain. Both retry + // the projector serialization conflict (decision O). + let grant_provider = provider.clone(); + let grant_scope = scope.clone(); + let grant_tenant = s.tenant; + let grant_payer = s.payer; + let grant = tokio::spawn(async move { + let svc = credit_svc(&grant_provider); + let ctx = SecurityContext::anonymous(); + retry_on_serialization(|| { + svc.grant_credit( + &ctx, + &grant_scope, + GrantRequest { + tenant_id: grant_tenant, + payer_tenant_id: grant_payer, + credit_application_id: "CR-CONC-GRANT".to_owned(), + currency: "USD".to_owned(), + amount_minor: 300, + credit_grant_event_type: "promo".to_owned(), + }, + ) + }) + .await + }); + + let apply_provider = provider.clone(); + let apply_scope = scope.clone(); + let apply_tenant = s.tenant; + let apply_payer = s.payer; + let apply = tokio::spawn(async move { + let svc = credit_svc(&apply_provider); + let ctx = SecurityContext::anonymous(); + retry_on_serialization(|| { + svc.apply_credit( + &ctx, + &apply_scope, + ApplyRequest { + tenant_id: apply_tenant, + payer_tenant_id: apply_payer, + credit_application_id: "CR-CONC-APPLY".to_owned(), + currency: "USD".to_owned(), + targets: vec![Allocated { + invoice_id: "inv-1".to_owned(), + amount_minor: 300, + }], + }, + ) + }) + .await + }); + + let (grant_res, apply_res) = tokio::join!(grant, apply); + let grant_res = grant_res.expect("grant task must not panic / deadlock"); + let apply_res = apply_res.expect("apply task must not panic / deadlock"); + + // The grant has unallocated headroom regardless of interleaving, so it must + // commit. The apply commits in both real interleavings (wallet >= 300 before + // OR after the +300 grant); a clean `CreditExceedsWallet` is tolerated per + // decision O but not expected here. + grant_res.expect("the concurrent grant must commit"); + let apply_ok = match apply_res { + Ok(_) => true, + Err(DomainError::CreditExceedsWallet(_)) => false, + Err(other) => panic!("apply must succeed or cleanly reject, got: {other:?}"), + }; + + // Consistent final state: grant added 300; apply (if it ran) removed 300 and + // paid the AR. The no-negative CHECK was never violated (the tasks did not + // panic), and the balance reflects exactly the ops that committed. + let promo = wallet_subgrain(&raw, &s, "promo").await; + assert!(promo >= 0, "the wallet sub-grain must never go negative"); + if apply_ok { + assert_eq!( + promo, 400, + "grant +300 and apply -300 net to the seeded 400" + ); + assert_eq!( + ar_invoice_balance(&raw, &s, "inv-1").await, + Some(0), + "the apply fully paid inv-1" + ); + } else { + assert_eq!(promo, 700, "only the grant committed: 400 + 300"); + assert_eq!( + ar_invoice_balance(&raw, &s, "inv-1").await, + Some(300), + "the rejected apply left inv-1 open" + ); + } +} diff --git a/gears/bss/ledger/ledger/tests/postgres_credit_note.rs b/gears/bss/ledger/ledger/tests/postgres_credit_note.rs new file mode 100644 index 000000000..294d137b2 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_credit_note.rs @@ -0,0 +1,771 @@ +//! Postgres-only integration: the Slice-3 `CreditNoteHandler` (Group C), driven +//! through the REAL foundation engine (`PostingService` + the in-txn +//! `CreditNotePostSidecar`) and a real recognized+deferred invoice posted by +//! `InvoicePostService` (the credited obligation). Asserts the design §4.2 / §11 +//! durable effects: +//! +//! - a credit note posts DR `CONTRA_REVENUE`, DR `CONTRACT_LIABILITY` (deferred), +//! and DR `TAX_PAYABLE` against CR `AR`, **never touching the posted invoice +//! rows**, and in the SAME txn **reduces the owning schedule's +//! `total_deferred_minor`** (so a later S6 run cannot re-recognize it); +//! - the `invoice_exposure` headroom CHECK **blocks an over-cap** credit note +//! (`CreditNoteExceedsHeadroom` → `CREDIT_NOTE_EXCEEDS_HEADROOM`); +//! - a **goodwill** credit debits `GOODWILL` (not `CONTRA_REVENUE`) and touches no +//! schedule; +//! - a credit note on a **paid** invoice credits the `REUSABLE_CREDIT` remainder +//! and seeds the wallet sub-grain (K-2). +//! +//! `LedgerLocalClient::new` is `pub(crate)`, so this out-of-crate test drives the +//! `pub` `CreditNoteHandler` + `InvoicePostService` directly (mirrors +//! `postgres_recognition_build.rs`). Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::type_complexity +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::adjustment::credit_note::CreditNoteRequest; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::recognition::input::{RecognitionInput, RecognitionTiming}; +use bss_ledger::infra::adjustment::credit_note_service::CreditNoteHandler; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::NaiveDate; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Provisioned seller ids (incl. the Slice-3 contra/goodwill/wallet accounts). +struct Seller { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + revenue: Uuid, + contract_liability: Uuid, + contra_revenue: Uuid, + goodwill: Uuid, + reusable_credit: Uuid, + tax: Uuid, + suspense: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot, migrate, seed USD@2 + an OPEN period + the chart (AR / REVENUE(sub) / +/// CONTRACT_LIABILITY(sub) / CONTRA_REVENUE(sub) / GOODWILL / REUSABLE_CREDIT / +/// TAX / SUSPENSE). +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue: Uuid::now_v7(), + contract_liability: Uuid::now_v7(), + contra_revenue: Uuid::now_v7(), + goodwill: Uuid::now_v7(), + reusable_credit: Uuid::now_v7(), + tax: Uuid::now_v7(), + suspense: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + // Seed BOTH the invoice's period (`s.period_id`) and the CURRENT period: the + // adjustment handlers post into `Utc::now()`'s period (credit/debit-note + // `eff_date = Utc::now()`), so a fixed historical period alone makes the test + // date-dependent (green only in that calendar month). ON CONFLICT dedups when + // `now` already equals `s.period_id`. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{t}','{t}','{p}','UTC','OPEN'), ('{t}','{t}','{cur}','UTC','OPEN') + ON CONFLICT DO NOTHING", + t = s.tenant, + p = s.period_id, + cur = chrono::Utc::now().format("%Y%m") + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.revenue, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contract_liability, + AccountClass::ContractLiability, + Side::Credit, + Some("subscription"), + ), + // CONTRA_REVENUE is NOT a per-stream class (SDK `PER_STREAM` = REVENUE + + // CONTRACT_LIABILITY only), so `ChartIndex::resolve` keys it stream-less — + // provision it stream-less. The credit-note line may still CARRY the stream + // tag (the journal CHECK allows a stream on non-REVENUE/CL classes; it is + // forward-compat for the Phase-3 contra-paired-with-revenue disaggregation), + // but the chart row it resolves to is the single stream-less account. + account( + s.tenant, + s.contra_revenue, + AccountClass::ContraRevenue, + Side::Debit, + None, + ), + account( + s.tenant, + s.goodwill, + AccountClass::Goodwill, + Side::Debit, + None, + ), + account( + s.tenant, + s.reusable_credit, + AccountClass::ReusableCredit, + Side::Credit, + None, + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + account( + s.tenant, + s.suspense, + AccountClass::Suspense, + Side::Credit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +/// A `subscription` item, straight-line deferred over `periods`, with the +/// `invoice_item_ref` a deferred line requires. +fn recognized_item(amount: i64, periods: u32, item_ref: &str) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods, + first_period_id: None, + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some(item_ref.to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn invoice(s: &Seller, invoice_id: &str, items: Vec) -> PostedInvoice { + PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items, + tax: Vec::::new(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + } +} + +fn invoice_svc(provider: &DBProvider, metrics: &MetricsHarness) -> InvoicePostService { + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(metrics.metrics()), + RecognitionConfig::default(), + FxConfig::default(), + ) +} + +fn credit_handler(provider: &DBProvider) -> CreditNoteHandler { + CreditNoteHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + ) +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +async fn total_deferred(raw: &DatabaseConnection, s: &Seller, invoice_id: &str) -> Option { + scalar_i64( + raw, + &format!( + "SELECT total_deferred_minor FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='{invoice_id}'", + s.tenant + ), + ) + .await +} + +// TODO(VHP-1856 Slice 3 Phase 3): an `#[ignore]` integration test that posts a +// credit note carrying a multi-component `tax` breakdown and asserts the projected +// `tax_subbalance` is disaggregated per `(jurisdiction, filing)` (and that the +// `NegativeTaxSubbalance` alarm added in Group 2 is now reachable for notes). The +// pure per-component leg routing is covered by the `credit_note_tests` unit suite +// (`tax_breakdown_emits_per_component_tax_legs`); the projector-grain assertion +// needs the `tax_subbalance` read path wired into this harness first. + +/// A baseline non-goodwill credit-note request against `inv`'s `item-1` / +/// `subscription` stream. +fn credit_req( + s: &Seller, + credit_note_id: &str, + invoice_id: &str, + amount_minor: i64, + tax_minor: i64, + requested_deferred_minor: i64, +) -> CreditNoteRequest { + CreditNoteRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_note_id: credit_note_id.to_owned(), + origin_invoice_id: invoice_id.to_owned(), + origin_invoice_item_ref: Some("item-1".to_owned()), + po_allocation_group: Some("grp-1".to_owned()), + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + amount_minor, + tax_minor, + tax: Vec::new(), + requested_deferred_minor, + reason_code: "CUSTOMER_GOODWILL".to_owned(), + goodwill: false, + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn credit_note_against_unposted_invoice_is_note_invoice_not_found() { + // F4 (design §4.2 / §5): a credit note MUST link an originating posted invoice. + // No `INVOICE_POST` entry for the referenced invoice ⇒ `NOTE_INVOICE_NOT_FOUND` + // (404), BEFORE any read/split/post — no orphan compensating entry. + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Chart provisioned but NO invoice was ever posted for INV-NONE. + let err = credit_handler(&provider) + .post_credit_note(&ctx, &scope, credit_req(&s, "CN-NF", "INV-NONE", 300, 0, 0)) + .await + .expect_err("a credit note against an unposted invoice must be rejected"); + assert!( + matches!(err, DomainError::NoteInvoiceNotFound(_)), + "expected NoteInvoiceNotFound, got {err:?}" + ); + + // No books / record effect: no credit_note row was persisted. + assert_eq!( + scalar_i64( + &raw, + &format!( + "SELECT count(*) FROM bss.ledger_credit_note \ + WHERE tenant_id='{}' AND credit_note_id='CN-NF'", + s.tenant + ), + ) + .await, + Some(0), + "no credit_note row was persisted" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn deferred_credit_note_reduces_cl_ar_and_schedule_total() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A 1200 ex-tax subscription, straight-line over 12 ⇒ the whole 1200 defers to + // CONTRACT_LIABILITY; AR = 1200; schedule total_deferred = 1200. + let inv = invoice(&s, "INV-DEF", vec![recognized_item(1200, 12, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + assert_eq!(bal(&raw, &s, s.ar).await, Some(1200)); + assert_eq!(bal(&raw, &s, s.contract_liability).await, Some(1200)); + assert_eq!(total_deferred(&raw, &s, "INV-DEF").await, Some(1200)); + + // Credit 300 (ex-tax, no tax) entirely against the deferred balance. + let req = credit_req(&s, "CN-1", "INV-DEF", 300, 0, 300); + credit_handler(&provider) + .post_credit_note(&ctx, &scope, req) + .await + .expect("deferred credit note posts"); + + // CONTRACT_LIABILITY net down by 300 (1200 − 300); AR net down by 300; the + // posted invoice's schedule total_deferred reduced to 900 (a later run cannot + // re-recognize the 300). CONTRA_REVENUE untouched (no recognized part). + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(900), + "CONTRACT_LIABILITY reduced by the deferred credit" + ); + assert_eq!(bal(&raw, &s, s.ar).await, Some(900), "AR reduced incl. tax"); + assert_eq!( + total_deferred(&raw, &s, "INV-DEF").await, + Some(900), + "schedule total_deferred reduced — S6 cannot re-recognize the credited-back 300" + ); + assert!( + matches!(bal(&raw, &s, s.contra_revenue).await, None | Some(0)), + "no recognized part ⇒ no CONTRA_REVENUE" + ); + + // The headroom row is seeded (= posted AR 1200) with the running credit total. + let original = scalar_i64( + &raw, + &format!( + "SELECT original_total_minor FROM bss.ledger_invoice_exposure \ + WHERE tenant_id='{}' AND invoice_id='INV-DEF'", + s.tenant + ), + ) + .await; + assert_eq!(original, Some(1200), "headroom seeded = posted AR"); + let credit_total = scalar_i64( + &raw, + &format!( + "SELECT credit_note_total_minor FROM bss.ledger_invoice_exposure \ + WHERE tenant_id='{}' AND invoice_id='INV-DEF'", + s.tenant + ), + ) + .await; + assert_eq!(credit_total, Some(300), "running credit-note total bumped"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn headroom_check_blocks_over_cap_credit_note() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Posted AR 1000 (a fully-recognized 1000 invoice — recognition over 1 period + // recognizes now, so AR 1000 / Revenue 1000, no deferred). + let inv = invoice(&s, "INV-CAP", vec![recognized_item(1000, 1, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("invoice posts"); + + // First credit note 700 (recognized) — within headroom 1000. + credit_handler(&provider) + .post_credit_note(&ctx, &scope, credit_req(&s, "CN-A", "INV-CAP", 700, 0, 0)) + .await + .expect("first credit within headroom"); + + // Second credit note 500 ⇒ running 1200 > 1000 headroom ⇒ blocked by the + // invoice_exposure CHECK, surfaced as CreditNoteExceedsHeadroom; the whole post + // rolls back (no partial CONTRA/AR effect). + let err = credit_handler(&provider) + .post_credit_note(&ctx, &scope, credit_req(&s, "CN-B", "INV-CAP", 500, 0, 0)) + .await + .expect_err("over-cap credit note must block"); + assert!( + matches!(err, DomainError::CreditNoteExceedsHeadroom(_)), + "expected CreditNoteExceedsHeadroom, got {err:?}" + ); + // The running credit total stayed at 700 (the over-cap note rolled back). + let credit_total = scalar_i64( + &raw, + &format!( + "SELECT credit_note_total_minor FROM bss.ledger_invoice_exposure \ + WHERE tenant_id='{}' AND invoice_id='INV-CAP'", + s.tenant + ), + ) + .await; + assert_eq!(credit_total, Some(700), "over-cap note rolled back"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn goodwill_credit_uses_goodwill_class_not_contra() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Posted AR 1000 (fully recognized). + let inv = invoice(&s, "INV-GW", vec![recognized_item(1000, 1, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("invoice posts"); + + // A 200 goodwill credit (no tax, no deferred): DR GOODWILL 200 / CR AR 200. + let mut req = credit_req(&s, "CN-GW", "INV-GW", 200, 0, 0); + req.goodwill = true; + credit_handler(&provider) + .post_credit_note(&ctx, &scope, req) + .await + .expect("goodwill credit posts"); + + assert_eq!( + bal(&raw, &s, s.goodwill).await, + Some(200), + "GOODWILL debited" + ); + assert!( + matches!(bal(&raw, &s, s.contra_revenue).await, None | Some(0)), + "goodwill never uses CONTRA_REVENUE" + ); + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(800), + "AR reduced by goodwill" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn paid_invoice_credit_seeds_reusable_credit_wallet() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Post a 500 fully-recognized invoice, then drain its open AR to 0 by a manual + // AR-credit (simulating a payment allocation: DR SUSPENSE / CR AR) so the + // invoice is "paid" — the credit-note remainder must then route to the wallet. + let inv = invoice(&s, "INV-PAID", vec![recognized_item(500, 1, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("invoice posts"); + // Drain open AR to 0 directly in the cache (test shortcut — the open-AR read is + // what gates the AR-vs-wallet split; a real payment would net it the same). + raw.execute(pg(format!( + "UPDATE bss.ledger_ar_invoice_balance SET balance_minor = 0 \ + WHERE tenant_id='{}' AND invoice_id='INV-PAID'", + s.tenant + ))) + .await + .unwrap(); + + // A 300 credit on the now-paid invoice ⇒ open AR 0 ⇒ the whole 300 seeds the + // REUSABLE_CREDIT wallet (K-2), no AR credit. + credit_handler(&provider) + .post_credit_note( + &ctx, + &scope, + credit_req(&s, "CN-PAID", "INV-PAID", 300, 0, 0), + ) + .await + .expect("paid-invoice credit posts"); + + assert_eq!( + bal(&raw, &s, s.reusable_credit).await, + Some(300), + "remainder beyond open AR seeds REUSABLE_CREDIT" + ); + // The wallet sub-grain is seeded under credit_grant_event_type = CREDIT_NOTE. + let wallet = scalar_i64( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_reusable_credit_subbalance \ + WHERE tenant_id='{}' AND payer_tenant_id='{}' AND currency='USD' \ + AND credit_grant_event_type='CREDIT_NOTE'", + s.tenant, s.payer + ), + ) + .await; + assert_eq!(wallet, Some(300), "reusable_credit_subbalance seeded (K-2)"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn mixed_credit_note_books_both_contra_and_cl() { + // The MIXED split (the central §4.2 case the all-deferred / all-goodwill tests + // never exercise): a note whose ex-tax amount is SPLIT into a recognized part + // (→ DR CONTRA_REVENUE) AND a deferred part (→ DR CONTRACT_LIABILITY), both + // against CR AR. The recognized-leg path + the per-stream CL reduction fire + // together in one balanced post. + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A 1200 ex-tax subscription, straight-line over 12 ⇒ the whole 1200 defers to + // CONTRACT_LIABILITY (releasable remainder = 1200); AR = 1200. + let inv = invoice(&s, "INV-MIX", vec![recognized_item(1200, 12, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + assert_eq!(bal(&raw, &s, s.ar).await, Some(1200)); + assert_eq!(bal(&raw, &s, s.contract_liability).await, Some(1200)); + assert_eq!(total_deferred(&raw, &s, "INV-MIX").await, Some(1200)); + + // Credit 300 ex-tax, of which only 100 is requested deferred ⇒ recognized part + // = 300 − 100 = 200 (DR CONTRA_REVENUE 200), deferred part = 100 (DR + // CONTRACT_LIABILITY 100, within the 1200 releasable). CR AR = 300. + credit_handler(&provider) + .post_credit_note( + &ctx, + &scope, + credit_req(&s, "CN-MIX", "INV-MIX", 300, 0, 100), + ) + .await + .expect("mixed credit note posts"); + + // BOTH debit legs booked: CONTRA_REVENUE = 200 (the recognized part) and + // CONTRACT_LIABILITY net down by 100 (1200 − 100). AR net down by the full 300. + assert_eq!( + bal(&raw, &s, s.contra_revenue).await, + Some(200), + "the recognized part debits CONTRA_REVENUE" + ); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(1100), + "the deferred part reduces CONTRACT_LIABILITY (1200 − 100)" + ); + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(900), + "AR reduced by the full credited 300 (200 recognized + 100 deferred)" + ); + // Only the deferred 100 reduces the schedule (the recognized 200 was never + // deferred, so it does not touch total_deferred — S6 can't re-recognize the 100). + assert_eq!( + total_deferred(&raw, &s, "INV-MIX").await, + Some(1100), + "schedule total_deferred reduced by the deferred part only (1200 − 100)" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn goodwill_credit_over_open_ar_is_rejected() { + // Over-application guard (design §4.2, K-2): a GOODWILL credit is AR-only — it + // may relieve open AR but must NEVER mint spendable REUSABLE_CREDIT. A goodwill + // amount that exceeds the invoice's open AR has a wallet remainder, so it is + // rejected (InvalidRequest) rather than converting a goodwill gesture into a + // cash-equivalent grant. (A non-goodwill paid-invoice credit DOES seed the + // wallet — that is the `paid_invoice_credit_seeds_reusable_credit_wallet` path; + // this proves goodwill is held to the stricter AR-only rule.) + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Posted AR 1000 (fully recognized), then drain its open AR to 200 (a partial + // payment) so a 300 goodwill credit has only 200 of AR to relieve — a 100 + // wallet remainder the goodwill rule forbids. + let inv = invoice(&s, "INV-GWO", vec![recognized_item(1000, 1, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("invoice posts"); + raw.execute(pg(format!( + "UPDATE bss.ledger_ar_invoice_balance SET balance_minor = 200 \ + WHERE tenant_id='{}' AND invoice_id='INV-GWO'", + s.tenant + ))) + .await + .unwrap(); + // A real partial payment reduces BOTH the per-invoice AR grain (above) AND the + // per-account AR grain that `bal(s.ar)` reads (`ledger_account_balance`). The + // raw shortcut must touch both, else the post-rejection assertion `bal(s.ar) == + // 200` sees the untouched 1000 from the invoice post. + raw.execute(pg(format!( + "UPDATE bss.ledger_account_balance SET balance_minor = 200 \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, s.ar + ))) + .await + .unwrap(); + + // A 300 goodwill credit > open AR 200 ⇒ 100 wallet remainder ⇒ InvalidRequest + // (goodwill is AR-only). 300 is within the 1000 headroom, so it is the goodwill + // wallet-mint rule — not the headroom CHECK — that rejects it. + let mut req = credit_req(&s, "CN-GWO", "INV-GWO", 300, 0, 0); + req.goodwill = true; + let err = credit_handler(&provider) + .post_credit_note(&ctx, &scope, req) + .await + .expect_err("a goodwill credit exceeding open AR must be rejected"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest (goodwill is AR-only, cannot mint reusable credit), got {err:?}" + ); + + // Rejected with NO books / record effect: AR untouched at 200, GOODWILL never + // debited, no wallet sub-grain seeded, no credit_note row persisted. + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(200), + "AR untouched by the rejected goodwill credit" + ); + assert!( + matches!(bal(&raw, &s, s.goodwill).await, None | Some(0)), + "GOODWILL never debited" + ); + assert_eq!( + scalar_i64( + &raw, + &format!( + "SELECT count(*) FROM bss.ledger_reusable_credit_subbalance \ + WHERE tenant_id='{}' AND payer_tenant_id='{}'", + s.tenant, s.payer + ), + ) + .await, + Some(0), + "no REUSABLE_CREDIT wallet sub-grain was seeded" + ); + assert_eq!( + scalar_i64( + &raw, + &format!( + "SELECT count(*) FROM bss.ledger_credit_note \ + WHERE tenant_id='{}' AND credit_note_id='CN-GWO'", + s.tenant + ), + ) + .await, + Some(0), + "no credit_note row persisted (rejected before the post)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_cross_tenant.rs b/gears/bss/ledger/ledger/tests/postgres_cross_tenant.rs new file mode 100644 index 000000000..184c89301 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_cross_tenant.rs @@ -0,0 +1,415 @@ +//! Postgres-only end-to-end for the cross-tenant elevation gateway + the +//! audit-retrieval reads (Slice 6 Phase 2 Group 2C). Boots a container, migrates, +//! then drives `CrossTenantGateway::resolve_read_scope` inside a transaction and +//! asserts the elevation contract: +//! (a) `target = home` (routine) writes NO audit record and returns the home +//! scope; +//! (b) `target != home` + role + reason writes ONE `cross-tenant-access` +//! secured_audit_record and returns the target scope; +//! (c) `target != home` + no reason is `MissingInvestigationReason` BEFORE any +//! read or write (no audit record); +//! (d) `target != home` + role=false is `CrossTenantAccessDenied` (no record). +//! Plus the audit-retrieval reads: a posted entry's who/when/source/correlation +//! dims, and a tamper-status read reflecting an inserted scope-freeze. +//! +//! A forced audit-append failure (case (e)) is omitted: the append is sealed by +//! the same in-txn chain machinery as the post path, and there is no hermetic +//! seam to make ONLY the append fail without also breaking the read — the +//! propagation is covered structurally (a `?` on the append in +//! `resolve_read_scope`). Ignored by default; run with `-- --ignored`. +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use bss_ledger::infra::audit::retrieval::AuditRetrievalReader; +use bss_ledger::infra::authz::cross_tenant::{CrossTenantGateway, TargetScope}; +use bss_ledger::infra::storage::migrations::Migrator; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::pep_properties; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +async fn scalar_text(conn: &DatabaseConnection, sql: &str) -> Option { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.and_then(|r| r.try_get_by_index::>(0).unwrap()) +} + +/// Boot, migrate, return the migrate connection + the bss-search-path provider. +async fn setup(container_url: &str) -> (DatabaseConnection, DBProvider) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + (raw, DBProvider::::new(tdb)) +} + +/// (a) A routine resolve (`target == home`) writes NO audit record and returns +/// the home scope. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn routine_resolve_writes_no_record_returns_home_scope() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + let home = Uuid::now_v7(); + + let scope = provider + .transaction(move |txn| { + Box::pin(async move { + CrossTenantGateway::new() + .resolve_read_scope( + txn, + home, + Some(TargetScope { tenant_id: home }), + true, + "actor-1", + Some("reason text"), + Some("INVESTIGATION"), + None, + ) + .await + }) + }) + .await + .expect("routine resolve must succeed"); + + assert!( + scope.contains_uuid(pep_properties::OWNER_TENANT_ID, home), + "routine resolve returns the home scope" + ); + let records = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{home}' AND event_type='cross-tenant-access'" + ), + ) + .await; + assert_eq!(records, 0, "a routine resolve writes NO forensic record"); +} + +/// (b) A cross-tenant resolve with role + reason writes ONE `cross-tenant-access` +/// record (under the HOME tenant) and returns the TARGET scope. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_resolve_writes_record_returns_target_scope() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + + let scope = provider + .transaction(move |txn| { + Box::pin(async move { + CrossTenantGateway::new() + .resolve_read_scope( + txn, + home, + Some(TargetScope { tenant_id: target }), + true, + "investigator-7", + Some("fraud investigation #42"), + Some("FRAUD"), + None, + ) + .await + }) + }) + .await + .expect("elevated resolve must succeed"); + + assert!( + scope.contains_uuid(pep_properties::OWNER_TENANT_ID, target), + "elevated resolve returns the TARGET scope" + ); + assert!( + !scope.contains_uuid(pep_properties::OWNER_TENANT_ID, home), + "the returned scope opens the target, not the home tenant" + ); + + // Exactly one forensic record, written under the HOME tenant. + let records = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{home}' AND event_type='cross-tenant-access'" + ), + ) + .await; + assert_eq!(records, 1, "an elevated resolve writes ONE forensic record"); + + // The record carries the reason_code + targetScope/reason in before_after. + let reason_code = scalar_text( + &raw, + &format!( + "SELECT reason_code FROM bss.secured_audit_record \ + WHERE tenant_id='{home}' AND event_type='cross-tenant-access'" + ), + ) + .await; + assert_eq!(reason_code.as_deref(), Some("FRAUD")); + let target_in_payload = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{home}' AND event_type='cross-tenant-access' \ + AND before_after->'targetScope'->>'tenantId'='{target}'" + ), + ) + .await; + assert_eq!( + target_in_payload, 1, + "the forensic record's before_after names the target tenant" + ); + // No record under the target tenant (the actor's HOME owns the record). + let under_target = count( + &raw, + &format!("SELECT COUNT(*) FROM bss.secured_audit_record WHERE tenant_id='{target}'"), + ) + .await; + assert_eq!(under_target, 0, "the record is owned by the home tenant"); +} + +/// (c) A cross-tenant resolve with NO reason is `MissingInvestigationReason` +/// BEFORE any read or write — no forensic record. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_resolve_without_reason_is_rejected_no_record() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + + let err = provider + .transaction(move |txn| { + Box::pin(async move { + CrossTenantGateway::new() + .resolve_read_scope( + txn, + home, + Some(TargetScope { tenant_id: target }), + true, + "investigator-7", + None, // no X-Investigation-Reason + None, // no reasonCode + None, + ) + .await + }) + }) + .await + .expect_err("a reason-less cross-tenant resolve must be rejected"); + assert!( + err.to_string().contains("MissingInvestigationReason"), + "expected MissingInvestigationReason sentinel, got: {err}" + ); + + let records = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{home}' AND event_type='cross-tenant-access'" + ), + ) + .await; + assert_eq!(records, 0, "no forensic record on a reason-less rejection"); +} + +/// (d) A cross-tenant resolve with an unauthorized role is +/// `CrossTenantAccessDenied` — no forensic record. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_resolve_without_role_is_denied_no_record() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + + let err = provider + .transaction(move |txn| { + Box::pin(async move { + CrossTenantGateway::new() + .resolve_read_scope( + txn, + home, + Some(TargetScope { tenant_id: target }), + false, // role NOT authorized + "investigator-7", + Some("reason"), + Some("FRAUD"), + None, + ) + .await + }) + }) + .await + .expect_err("an unauthorized cross-tenant resolve must be denied"); + assert!( + err.to_string().contains("CrossTenantAccessDenied"), + "expected CrossTenantAccessDenied sentinel, got: {err}" + ); + + let records = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{home}' AND event_type='cross-tenant-access'" + ), + ) + .await; + assert_eq!(records, 0, "no forensic record on a role denial"); +} + +/// API-surface reads: a posted entry's audit dims are retrievable (who/when/ +/// source/correlation), and a tamper-status read reflects an inserted freeze. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn audit_retrieval_and_tamper_status_reads() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + let reader = AuditRetrievalReader::new(provider.clone()); + + let tenant = Uuid::now_v7(); + let entry_id = Uuid::now_v7(); + let actor = Uuid::now_v7(); + let correlation = Uuid::now_v7(); + let period = "202606"; + + // Insert one journal_entry row directly (the audit reader is a pure read). + // A line-less header would trip the DEFERRABLE balanced-entry constraint + // trigger at commit; this test only needs a readable header, so disable that + // trigger for the raw insert (the append-only guard still allows INSERT). + raw.execute(pg( + "ALTER TABLE bss.ledger_journal_entry DISABLE TRIGGER trg_journal_entry_balanced", + )) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, \ + source_doc_type, source_business_id, posted_at_utc, effective_at, \ + origin, posted_by_actor_id, correlation_id, rounding_evidence) \ + VALUES ('{entry_id}','{tenant}','{tenant}','{period}','USD', \ + 'INVOICE_POST','INV-AUDIT', now(), '2026-06-01', \ + 'SYSTEM','{actor}','{correlation}', '{{}}'::jsonb)" + ))) + .await + .unwrap(); + raw.execute(pg( + "ALTER TABLE bss.ledger_journal_entry ENABLE TRIGGER trg_journal_entry_balanced", + )) + .await + .unwrap(); + + let scope = AccessScope::for_tenant(tenant); + let record = reader + .audit_entry(&scope, tenant, entry_id) + .await + .expect("audit_entry read") + .expect("entry must be found"); + assert_eq!(record.posted_by_actor_id, actor, "who"); + assert_eq!(record.correlation_id, correlation, "correlation"); + assert_eq!(record.source_doc_type, "INVOICE_POST", "source doc type"); + assert_eq!(record.source_business_id, "INV-AUDIT", "source business id"); + assert_eq!(record.origin, "SYSTEM", "origin"); + + // A foreign tenant scope yields None (SQL-level BOLA, no existence leak). + let foreign = AccessScope::for_tenant(Uuid::now_v7()); + let none = reader + .audit_entry(&foreign, tenant, entry_id) + .await + .expect("audit_entry read (foreign)"); + assert!( + none.is_none(), + "a foreign-scope read must not see the entry" + ); + + // Document history: the one entry shows up under its source key. + let history = reader + .document_history(&scope, tenant, "INVOICE_POST", "INV-AUDIT") + .await + .expect("document_history read"); + assert_eq!(history.len(), 1, "the document's single entry"); + assert_eq!(history[0].entry_id, entry_id); + + // Tamper-status: clean (no freeze) ⇒ verified, not frozen. + let clean = provider + .transaction({ + let reader = reader.clone(); + let scope = scope.clone(); + move |txn| { + let reader = reader.clone(); + let scope = scope.clone(); + Box::pin(async move { + reader + .tamper_status_in_txn(txn, &scope, tenant) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + } + }) + .await + .expect("tamper-status (clean)"); + assert!(!clean.scope_frozen, "no freeze ⇒ not frozen"); + assert!(clean.verified, "no freeze ⇒ verified (MVP)"); + + // Insert an ACTIVE freeze, then re-read: frozen + not verified + one row. + raw.execute(pg(format!( + "INSERT INTO bss.scope_freeze \ + (tenant_id, scope, period_id, reason, frozen_at, set_by) \ + VALUES ('{tenant}','tenant','ALL','broken chain', now(), 'verifier')" + ))) + .await + .unwrap(); + + let frozen = provider + .transaction({ + let reader = reader.clone(); + let scope = scope.clone(); + move |txn| { + let reader = reader.clone(); + let scope = scope.clone(); + Box::pin(async move { + reader + .tamper_status_in_txn(txn, &scope, tenant) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + } + }) + .await + .expect("tamper-status (frozen)"); + assert!(frozen.scope_frozen, "an active freeze ⇒ scope_frozen"); + assert!(!frozen.verified, "an active freeze ⇒ not verified"); + assert_eq!(frozen.freezes.len(), 1, "one freeze row"); + assert_eq!(frozen.freezes[0].reason, "broken chain"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_debit_note.rs b/gears/bss/ledger/ledger/tests/postgres_debit_note.rs new file mode 100644 index 000000000..87d81f9a5 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_debit_note.rs @@ -0,0 +1,839 @@ +//! Postgres-only integration: the Slice-3 `DebitNoteHandler` (Group D), driven +//! through the REAL foundation engine (`PostingService` + the in-txn +//! `DebitNotePostSidecar`) against a base invoice posted by `InvoicePostService`. +//! Asserts the design §4.3 / §11 durable effects of a debit note (an additional +//! charge, a DIRECT split mirroring S1 invoice-post): +//! +//! - a deferred debit note posts DR `AR` (incl. tax) + CR `REVENUE` (recognized +//! now) + CR `CONTRACT_LIABILITY` (deferred) + CR `TAX_PAYABLE`, **never touching +//! the posted invoice rows**, and in the SAME txn **builds a `recognition_schedule` +//! (+ segments)** for the new deferred balance (D4) so a later S6 run can release +//! it (no stuck liability); +//! - a debit note **raises** the invoice's headroom +//! (`invoice_exposure.debit_note_total_minor += amount`), so a *credit note* that +//! would have been over-cap before now fits; +//! - a **fully-recognized** debit note posts DR `AR` / CR `REVENUE` (+ tax) with NO +//! `CONTRACT_LIABILITY` line and builds NO schedule. +//! +//! `LedgerLocalClient::new` is `pub(crate)`, so this out-of-crate test drives the +//! `pub` `DebitNoteHandler` + `InvoicePostService` + `CreditNoteHandler` directly +//! (mirrors `postgres_credit_note.rs`). Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::type_complexity +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::adjustment::credit_note::CreditNoteRequest; +use bss_ledger::domain::adjustment::debit_note::DebitNoteRequest; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::recognition::input::{RecognitionInput, RecognitionTiming}; +use bss_ledger::infra::adjustment::credit_note_service::CreditNoteHandler; +use bss_ledger::infra::adjustment::debit_note_service::DebitNoteHandler; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::NaiveDate; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Provisioned seller ids (AR / REVENUE(sub) / CONTRACT_LIABILITY(sub) / +/// CONTRA_REVENUE / TAX / SUSPENSE) — the chart a debit note + a follow-up credit +/// note resolve against. +struct Seller { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + revenue: Uuid, + contract_liability: Uuid, + contra_revenue: Uuid, + tax: Uuid, + suspense: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot, migrate, seed USD@2 + an OPEN period + the chart. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue: Uuid::now_v7(), + contract_liability: Uuid::now_v7(), + contra_revenue: Uuid::now_v7(), + tax: Uuid::now_v7(), + suspense: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + // Seed BOTH the invoice's period (`s.period_id`) and the CURRENT period: the + // adjustment handlers post into `Utc::now()`'s period (credit/debit-note + // `eff_date = Utc::now()`), so a fixed historical period alone makes the test + // date-dependent (green only in that calendar month). ON CONFLICT dedups when + // `now` already equals `s.period_id`. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{t}','{t}','{p}','UTC','OPEN'), ('{t}','{t}','{cur}','UTC','OPEN') + ON CONFLICT DO NOTHING", + t = s.tenant, + p = s.period_id, + cur = chrono::Utc::now().format("%Y%m") + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.revenue, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contract_liability, + AccountClass::ContractLiability, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contra_revenue, + AccountClass::ContraRevenue, + Side::Debit, + None, + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + account( + s.tenant, + s.suspense, + AccountClass::Suspense, + Side::Credit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +/// A fully-recognized `subscription` invoice item — PointInTime, so the whole +/// ex-tax amount recognizes now (deferred = 0, no schedule), carrying the +/// `invoice_item_ref` it books under. +fn recognized_item(amount: i64, item_ref: &str) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + // Recognize-now ⇒ PointInTime (whole ex-tax amount recognizes at + // invoice, deferred = 0, no schedule). Modeling this as + // StraightLine { periods: 1 } was wrong: by the domain contract a + // single-period straight line still DEFERS the whole amount to + // CONTRACT_LIABILITY and recognizes it in that one segment + // (is_deferred() == true), minting a schedule and booking 0 to + // REVENUE. PointInTime is the "recognizes now" the docstring intends. + timing: RecognitionTiming::PointInTime, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some(item_ref.to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +/// A `subscription` invoice item deferred straight-line over `periods` — builds a +/// live ACTIVE recognition schedule (total_deferred = amount) that the debit-note +/// EXTEND path then folds into. (`recognized_item` is point-in-time; this defers.) +fn deferred_item(amount: i64, periods: u32, item_ref: &str) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods, + first_period_id: None, + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some(item_ref.to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn invoice(s: &Seller, invoice_id: &str, items: Vec) -> PostedInvoice { + PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items, + tax: Vec::::new(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + } +} + +fn invoice_svc(provider: &DBProvider, metrics: &MetricsHarness) -> InvoicePostService { + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(metrics.metrics()), + RecognitionConfig::default(), + FxConfig::default(), + ) +} + +fn debit_handler(provider: &DBProvider) -> DebitNoteHandler { + DebitNoteHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + RecognitionConfig::default(), + ) +} + +fn credit_handler(provider: &DBProvider) -> CreditNoteHandler { + CreditNoteHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + ) +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +/// The number of `recognition_schedule` rows built against `invoice_id` (the debit +/// note's `source_invoice_id`) for `stream` — the D4 schedule-build assertion. +async fn schedule_count(raw: &DatabaseConnection, s: &Seller, invoice_id: &str) -> Option { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='{invoice_id}' \ + AND revenue_stream='subscription'", + s.tenant + ), + ) + .await +} + +async fn schedule_total_deferred( + raw: &DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + scalar_i64( + raw, + &format!( + "SELECT total_deferred_minor FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='{invoice_id}' \ + AND revenue_stream='subscription'", + s.tenant + ), + ) + .await +} + +/// Σ of all recognition_segment amounts for the invoice's `subscription` schedule — +/// must equal `total_deferred` after an EXTEND merge (else the S6 run cannot drain +/// it exactly — stranded or over-recognized revenue). +async fn segment_sum(raw: &DatabaseConnection, s: &Seller, invoice_id: &str) -> Option { + scalar_i64( + raw, + &format!( + "SELECT COALESCE(SUM(seg.amount_minor), 0)::bigint \ + FROM bss.ledger_recognition_segment seg \ + JOIN bss.ledger_recognition_schedule sch \ + ON seg.tenant_id = sch.tenant_id AND seg.schedule_id = sch.schedule_id \ + WHERE sch.tenant_id = '{}' AND sch.source_invoice_id = '{invoice_id}' \ + AND sch.revenue_stream = 'subscription'", + s.tenant + ), + ) + .await +} + +async fn debit_note_total(raw: &DatabaseConnection, s: &Seller, invoice_id: &str) -> Option { + scalar_i64( + raw, + &format!( + "SELECT debit_note_total_minor FROM bss.ledger_invoice_exposure \ + WHERE tenant_id='{}' AND invoice_id='{invoice_id}'", + s.tenant + ), + ) + .await +} + +/// A debit-note request against `inv`'s `item-1` / `subscription` stream. Carries a +/// straight-line spec (the schedule build needs it) when `deferred_minor > 0`. +fn debit_req( + s: &Seller, + debit_note_id: &str, + invoice_id: &str, + amount_minor: i64, + tax_minor: i64, + deferred_minor: i64, + periods: u32, +) -> DebitNoteRequest { + DebitNoteRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + debit_note_id: debit_note_id.to_owned(), + origin_invoice_id: invoice_id.to_owned(), + origin_invoice_item_ref: Some("item-1".to_owned()), + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + amount_minor, + tax_minor, + // Tax booking requires a dimensioned breakdown — chk_journal_line_tax_dims + // rejects a TAX_PAYABLE line without (jurisdiction, filing_period). The + // legacy bare-`tax_minor` path in `build_debit_note_legs` emits a + // dimensionless TAX_PAYABLE leg that the schema rejects at insert, so a + // taxed note must carry a breakdown (mirrors the S1 invoice-post tests). + tax: if tax_minor > 0 { + vec![TaxBreakdown { + amount_minor: tax_minor, + currency: "USD".to_owned(), + tax_jurisdiction: "US-CA".to_owned(), + tax_filing_period: "2026Q2".to_owned(), + tax_rate_ref: None, + }] + } else { + Vec::new() + }, + deferred_minor, + reason_code: "ADDITIONAL_USAGE".to_owned(), + recognition: if deferred_minor > 0 { + Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods, + first_period_id: None, + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }) + } else { + None + }, + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn debit_note_against_unposted_invoice_is_note_invoice_not_found() { + // F4 (design §4.3 / §5): a debit note MUST link an originating posted invoice. + // No `INVOICE_POST` entry for the referenced invoice ⇒ `NOTE_INVOICE_NOT_FOUND` + // (404), BEFORE any ledger effect — no orphan charge entry, no exposure row. + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Note the chart is provisioned but NO invoice was ever posted for INV-NONE. + let err = debit_handler(&provider) + .post_debit_note( + &ctx, + &scope, + debit_req(&s, "DN-NF", "INV-NONE", 1000, 0, 0, 0), + true, + ) + .await + .expect_err("a debit note against an unposted invoice must be rejected"); + assert!( + matches!( + err, + bss_ledger::domain::error::DomainError::NoteInvoiceNotFound(_) + ), + "expected NoteInvoiceNotFound, got {err:?}" + ); + + // No books / counter effect: neither a debit_note row nor an exposure row exists. + assert_eq!( + scalar_i64( + &raw, + &format!( + "SELECT count(*) FROM bss.ledger_debit_note \ + WHERE tenant_id='{}' AND debit_note_id='DN-NF'", + s.tenant + ), + ) + .await, + Some(0), + "no debit_note row was persisted" + ); + assert_eq!( + scalar_i64( + &raw, + &format!( + "SELECT count(*) FROM bss.ledger_invoice_exposure \ + WHERE tenant_id='{}' AND invoice_id='INV-NONE'", + s.tenant + ), + ) + .await, + Some(0), + "no invoice_exposure row was seeded" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn deferred_debit_note_books_ar_revenue_cl_and_builds_schedule() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Base invoice: 1000 fully-recognized ⇒ AR 1000 / REVENUE 1000. Posted AR 1000. + let inv = invoice(&s, "INV-DN", vec![recognized_item(1000, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("base invoice posts"); + assert_eq!(bal(&raw, &s, s.ar).await, Some(1000)); + assert_eq!(bal(&raw, &s, s.revenue).await, Some(1000)); + // The base invoice mints NO schedule (recognized over 1 period). + assert_eq!(schedule_count(&raw, &s, "INV-DN").await, Some(0)); + + // A 1000 ex-tax debit note, 600 deferred over 6 periods ⇒ DR AR 1000 / CR + // REVENUE 400 / CR CONTRACT_LIABILITY 600 (+ D4 schedule for the 600). + debit_handler(&provider) + .post_debit_note( + &ctx, + &scope, + debit_req(&s, "DN-1", "INV-DN", 1000, 0, 600, 6), + true, + ) + .await + .expect("deferred debit note posts"); + + // AR up by the full incl-tax 1000 ⇒ 2000; REVENUE up by the recognized 400 ⇒ + // 1400; CONTRACT_LIABILITY up by the deferred 600. + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(2000), + "AR up by the additional charge" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(1400), + "REVENUE up by the recognized-now part" + ); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(600), + "CONTRACT_LIABILITY up by the deferred part" + ); + + // D4: a recognition_schedule was built for the deferred 600 (so a later S6 run + // can release it — no stuck liability). + assert_eq!( + schedule_count(&raw, &s, "INV-DN").await, + Some(1), + "D4 — a schedule was built for the deferred CL credit" + ); + assert_eq!( + schedule_total_deferred(&raw, &s, "INV-DN").await, + Some(600), + "schedule total_deferred = the debit note's deferred part" + ); + + // The headroom row is seeded (= posted AR 1000) and the debit note RAISED it. + let original = scalar_i64( + &raw, + &format!( + "SELECT original_total_minor FROM bss.ledger_invoice_exposure \ + WHERE tenant_id='{}' AND invoice_id='INV-DN'", + s.tenant + ), + ) + .await; + assert_eq!(original, Some(1000), "headroom seeded = posted AR"); + assert_eq!( + debit_note_total(&raw, &s, "INV-DN").await, + Some(1000), + "debit_note_total_minor raised by the incl-tax note amount" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn debit_note_raises_headroom_for_a_later_credit_note() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Posted AR 1000 ⇒ headroom 1000 for credit notes. + let inv = invoice(&s, "INV-HR", vec![recognized_item(1000, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("invoice posts"); + + // A 500 fully-recognized debit note raises the headroom to 1500 (DR AR 500 / CR + // REVENUE 500, no CL line, no schedule). + debit_handler(&provider) + .post_debit_note( + &ctx, + &scope, + debit_req(&s, "DN-HR", "INV-HR", 500, 0, 0, 0), + true, + ) + .await + .expect("fully-recognized debit note posts"); + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(1500), + "AR up by the additional charge" + ); + assert_eq!( + schedule_count(&raw, &s, "INV-HR").await, + Some(0), + "fully-recognized debit note builds no schedule" + ); + assert_eq!( + debit_note_total(&raw, &s, "INV-HR").await, + Some(500), + "headroom raised" + ); + + // A 1300 credit note now fits the raised headroom (1000 + 500 = 1500); it would + // have been over-cap (1300 > 1000) before the debit note. + let credit = CreditNoteRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_note_id: "CN-HR".to_owned(), + origin_invoice_id: "INV-HR".to_owned(), + origin_invoice_item_ref: Some("item-1".to_owned()), + po_allocation_group: Some("grp-1".to_owned()), + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + amount_minor: 1300, + tax_minor: 0, + tax: Vec::new(), + requested_deferred_minor: 0, + reason_code: "CUSTOMER_GOODWILL".to_owned(), + goodwill: false, + }; + credit_handler(&provider) + .post_credit_note(&ctx, &scope, credit) + .await + .expect("credit note fits the debit-note-raised headroom"); + + let credit_total = scalar_i64( + &raw, + &format!( + "SELECT credit_note_total_minor FROM bss.ledger_invoice_exposure \ + WHERE tenant_id='{}' AND invoice_id='INV-HR'", + s.tenant + ), + ) + .await; + assert_eq!( + credit_total, + Some(1300), + "credit note recorded against the raised headroom" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn fully_recognized_debit_note_books_no_contract_liability() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let inv = invoice(&s, "INV-FR", vec![recognized_item(1000, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("invoice posts"); + + // A 1100 incl 100 tax fully-recognized debit note ⇒ DR AR 1100 / CR REVENUE + // 1000 / CR TAX 100; NO CONTRACT_LIABILITY line, NO schedule. + debit_handler(&provider) + .post_debit_note( + &ctx, + &scope, + debit_req(&s, "DN-FR", "INV-FR", 1100, 100, 0, 0), + true, + ) + .await + .expect("fully-recognized debit note posts"); + + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(2100), + "AR 1000 base + 1100 note" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(2000), + "REVENUE 1000 base + 1000 note" + ); + assert_eq!( + bal(&raw, &s, s.tax).await, + Some(100), + "TAX_PAYABLE credited the posted tax" + ); + assert!( + matches!(bal(&raw, &s, s.contract_liability).await, None | Some(0)), + "fully-recognized debit note books no CONTRACT_LIABILITY" + ); + assert_eq!( + schedule_count(&raw, &s, "INV-FR").await, + Some(0), + "fully-recognized debit note builds no schedule" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn deferred_debit_note_extends_the_live_schedule_not_a_second() { + // Z3-1: a deferred debit note on an item that ALREADY has a live ACTIVE + // recognition schedule EXTENDS it (one ACTIVE per key, the partial UNIQUE) — + // total_deferred grows, overlapping-period segments fold in, and there is still + // exactly ONE schedule_id. Without the fix the note's SCHEDULE_BUILD claim + // collided with the base build → replay → skip, and its 600 deferred was lost. + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Base invoice: a 1200 ex-tax item DEFERRED straight-line over 12 periods → one + // live ACTIVE schedule (total_deferred 1200, 12 PENDING segments summing to 1200). + let inv = invoice(&s, "INV-EXT", vec![deferred_item(1200, 12, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("base deferred invoice posts"); + assert_eq!( + schedule_count(&raw, &s, "INV-EXT").await, + Some(1), + "base deferred item built exactly one schedule" + ); + assert_eq!( + schedule_total_deferred(&raw, &s, "INV-EXT").await, + Some(1200) + ); + assert_eq!( + segment_sum(&raw, &s, "INV-EXT").await, + Some(1200), + "base Σ(segments) == total_deferred" + ); + + // A deferred debit note: 600 deferred over 6 periods on the SAME item-1 → + // EXTENDS the live schedule (folds into periods 1-6), NOT a second schedule. + debit_handler(&provider) + .post_debit_note( + &ctx, + &scope, + debit_req(&s, "DN-EXT", "INV-EXT", 600, 0, 600, 6), + true, + ) + .await + .expect("deferred debit note extends the live schedule"); + + // STILL exactly one ACTIVE schedule for the key (the partial UNIQUE allows one), + // and total_deferred grew by the note's deferred part (1200 + 600). + assert_eq!( + schedule_count(&raw, &s, "INV-EXT").await, + Some(1), + "the debit note EXTENDED the live schedule — still ONE schedule, not a second" + ); + assert_eq!( + schedule_total_deferred(&raw, &s, "INV-EXT").await, + Some(1800), + "total_deferred grew by the note's deferred part (1200 + 600)" + ); + // The merged segments still sum to total_deferred — the S6 run drains exactly, + // no stranded or over-recognized revenue. + assert_eq!( + segment_sum(&raw, &s, "INV-EXT").await, + Some(1800), + "Σ(segment amounts) == total_deferred after the EXTEND merge" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn debit_note_for_closed_payer_is_rejected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let inv = invoice(&s, "INV-PC", vec![recognized_item(1000, "item-1")]); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("invoice posts"); + + // A debit note with payer_open = false is rejected before any ledger effect + // (A-2 payer-close gate); AR stays at the base 1000 (no charge posted). + let err = debit_handler(&provider) + .post_debit_note( + &ctx, + &scope, + debit_req(&s, "DN-PC", "INV-PC", 500, 0, 0, 0), + false, + ) + .await + .expect_err("closed-payer debit note must reject"); + assert!( + matches!(err, bss_ledger::domain::error::DomainError::PayerClosed(_)), + "expected PayerClosed, got {err:?}" + ); + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(1000), + "no charge posted for a closed payer" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_dual_control.rs b/gears/bss/ledger/ledger/tests/postgres_dual_control.rs new file mode 100644 index 000000000..fab6f7822 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_dual_control.rs @@ -0,0 +1,1046 @@ +//! Postgres-only repo/service-level tests for the dual-control approval engine +//! (`ApprovalRepo` + `ApprovalService`, VHP-1852). Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_dual_control -- --ignored`. +//! +//! These are the FAST (testcontainers) counterpart to the cluster-gated e2e +//! `tests/e2e/tests/bss-ledger/test_dual_control.py`. The e2e drives the full +//! REST workflow but with two actors in ONE tenant, so it cannot exercise the +//! cross-tenant isolation of the queue. This suite pins exactly that gap plus the +//! service invariants that have no gear-level coverage: +//! +//! - **approval BOLA** (`approvals_are_invisible_to_a_foreign_tenant_scope`): an +//! approval seeded for tenant A is unreadable / unlistable under a tenant-B +//! `AccessScope` — every `ApprovalRepo` read (`read` / `list` / `read_thread` / +//! `read_active`) resolves to empty, even when asked for tenant A's exact id. +//! Mirrors `postgres_payments.rs::payment_grains_are_invisible_to_a_foreign_tenant_scope`. +//! - **cross-tenant approve** (`approve_across_a_tenant_boundary_…`): an actor +//! authenticated in tenant B cannot approve tenant A's approval — it surfaces as +//! `ApprovalNotFound` and the held mutation NEVER executes (the executor is +//! never reached). This is the confused-deputy guard the single-tenant e2e +//! cannot reach. +//! - **preparer != approver** (`self_approval_is_forbidden_…`): the preparer +//! approving their own request is `SelfApprovalForbidden` and does not execute. +//! - **execute-then-mark** (`approve_by_a_second_actor_executes_…`): a second +//! actor's approve runs the held mutation exactly once and marks `APPROVED`. +//! - **reject / expiry**: a reject never executes; an expired PENDING is not +//! actionable. +//! +//! The executor is a `RecordingExecutor` stub (counts `execute` calls) so the +//! lifecycle is tested in isolation from the posting engine — the per-kind replay +//! dispatch lives in `infra/approval/executor.rs` and is exercised by the e2e. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::needless_pass_by_value +)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use bss_ledger::domain::approval::ApprovalKind; +use bss_ledger::domain::approval::intent::{ApprovalIntent, CreditGrantIntent, ReverseIntent}; +use bss_ledger::domain::approval::policy::{OperationFacts, resolve_policy}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::RepoError; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::approval::service::{ApprovalExecutor, ApprovalService}; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{ApprovalRepo, NewPendingApproval}; +use chrono::{DateTime, Duration, Utc}; +use sea_orm::{Database, DbErr}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +/// Lift a component `RepoError` into a `DbError` so a repo write can be the +/// transaction's typed success value (mirrors `postgres_payments.rs`). +fn lift(e: RepoError) -> DbError { + DbError::Sea(DbErr::Custom(e.to_string())) +} + +/// Spin up a Postgres container, migrate the `bss` schema, and return the raw +/// connection + a `bss`-search-path `DBProvider` (mirrors `postgres_payments.rs`). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// An `ApprovalExecutor` stub that only counts how many times the held mutation +/// was executed — lets a test assert "executed exactly once" vs "never executed" +/// without wiring the real posting engine. Cheap to clone (the counter is shared). +#[derive(Clone, Default)] +struct RecordingExecutor { + calls: Arc, +} + +impl RecordingExecutor { + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +#[async_trait::async_trait] +impl ApprovalExecutor for RecordingExecutor { + async fn execute( + &self, + _ctx: &SecurityContext, + _scope: &AccessScope, + _intent: &ApprovalIntent, + ) -> Result<(), DomainError> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +/// An authenticated `SecurityContext` for `subject` in `tenant` (mirrors the +/// `authz_tests.rs` builder). +fn ctx_for(subject: Uuid, tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(subject) + .subject_tenant_id(tenant) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// Seed one PENDING approval (a `REVERSE` intent) for `tenant`, prepared by +/// `prepared_by`, expiring at `expires_at`. Returns its id + the intent (for the +/// `business_key`/`kind` lookups). +async fn seed_pending( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + prepared_by: Uuid, + expires_at: DateTime, +) -> (Uuid, ApprovalIntent) { + let approval_id = Uuid::now_v7(); + let intent = ApprovalIntent::Reverse(ReverseIntent { + entry_id: Uuid::now_v7(), + into_period_id: None, + effective_at: None, + reason: "dual-control unit".to_owned(), + }); + let row = NewPendingApproval { + approval_id, + tenant, + kind: intent.kind().as_str().to_owned(), + business_key: intent.business_key(), + intent: serde_json::to_value(&intent).expect("serialize approval intent"), + amount_usd_eq_minor: Some(120_000), + threshold_snapshot: serde_json::json!({ "d2_threshold_minor": 100_000 }), + reason_code: "unit-test".to_owned(), + prepared_by, + prepared_at: Utc::now(), + correlation_id: Uuid::now_v7(), + expires_at, + }; + provider + .transaction(|txn| { + let scope = scope.clone(); + let row = row.clone(); + Box::pin(async move { + ApprovalRepo::insert_pending(txn, &scope, row) + .await + .map_err(lift) + }) + }) + .await + .expect("seed pending approval"); + (approval_id, intent) +} + +/// Append one comment to the approval thread (so the BOLA `read_thread` has a row +/// to be denied). +async fn seed_comment( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + approval_id: Uuid, + author: Uuid, +) { + provider + .transaction(|txn| { + let scope = scope.clone(); + Box::pin(async move { + ApprovalRepo::append_comment( + txn, + &scope, + Uuid::now_v7(), + approval_id, + tenant, + 0, + author, + "thread note".to_owned(), + Utc::now(), + ) + .await + .map_err(lift) + }) + }) + .await + .expect("seed approval comment"); +} + +fn in_one_hour() -> DateTime { + Utc::now() + Duration::hours(1) +} + +/// SQL-level BOLA on the approval queue: an approval (and its comment thread) +/// seeded for tenant A is invisible to a tenant-B `AccessScope` across EVERY +/// read, even when the query names tenant A's exact id. The single-tenant e2e +/// cannot cover this; it is the queue's tenant-isolation guarantee. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn approvals_are_invisible_to_a_foreign_tenant_scope() { + let (_c, _raw, provider) = boot().await; + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let own = AccessScope::for_tenant(tenant_a); + let foreign = AccessScope::for_tenant(tenant_b); + + let (approval_id, intent) = + seed_pending(&provider, &own, tenant_a, preparer, in_one_hour()).await; + seed_comment(&provider, &own, tenant_a, approval_id, preparer).await; + + let repo = ApprovalRepo::new(provider.clone()); + + // Sanity: tenant A's OWN scope sees the row + the thread — so "empty below" + // means scoped-out, not "the store is simply empty". + assert!( + repo.read(&own, tenant_a, approval_id) + .await + .expect("read own") + .is_some(), + "tenant A's own scope must see its approval" + ); + assert_eq!( + repo.read_thread(&own, tenant_a, approval_id) + .await + .expect("thread own") + .len(), + 1, + "tenant A's own scope must see its comment" + ); + + // Foreign (tenant B) scope: every read resolves to empty/None. + assert!( + repo.read(&foreign, tenant_a, approval_id) + .await + .expect("read foreign->A") + .is_none(), + "a tenant-B scope must NOT read tenant A's approval (SQL-level BOLA)" + ); + assert!( + repo.read(&foreign, tenant_b, approval_id) + .await + .expect("read foreign->B") + .is_none(), + "the approval is not tenant B's either" + ); + assert!( + repo.list(&foreign, tenant_a, None, None) + .await + .expect("list foreign") + .is_empty(), + "a tenant-B scope must list none of tenant A's approvals" + ); + assert!( + repo.read_thread(&foreign, tenant_a, approval_id) + .await + .expect("thread foreign") + .is_empty(), + "a tenant-B scope must not read tenant A's comment thread" + ); + assert!( + repo.read_active( + &foreign, + tenant_a, + intent.kind().as_str(), + &intent.business_key(), + Utc::now(), + ) + .await + .expect("read_active foreign") + .is_none(), + "a tenant-B scope must not resolve tenant A's active approval" + ); +} + +/// A SECOND actor approving a PENDING approval executes the held mutation exactly +/// once and marks the row `APPROVED` with the approver stamped (execute-then-mark). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn approve_by_a_second_actor_executes_and_marks_approved() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let approver = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + let (approval_id, _) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + + let exec = RecordingExecutor::default(); + let service = ApprovalService::new( + provider.clone(), + Arc::new(exec.clone()), + Arc::new(NoopLedgerMetrics), + bss_ledger::config::FxConfig::default(), + ); + let ctx = ctx_for(approver, tenant); + + service + .approve(&ctx, &scope, approval_id) + .await + .expect("approve"); + + assert_eq!( + exec.calls(), + 1, + "the held mutation must execute exactly once" + ); + let row = service + .get(&ctx, &scope, approval_id) + .await + .expect("get") + .expect("approval present"); + assert_eq!(row.state, "APPROVED"); + assert_eq!(row.approved_by, Some(approver), "the approver is stamped"); +} + +/// The preparer cannot approve their own pending mutation: `SelfApprovalForbidden` +/// and the held mutation never executes (the row stays PENDING). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn self_approval_is_forbidden_and_does_not_execute() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + let (approval_id, _) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + + let exec = RecordingExecutor::default(); + let service = ApprovalService::new( + provider.clone(), + Arc::new(exec.clone()), + Arc::new(NoopLedgerMetrics), + bss_ledger::config::FxConfig::default(), + ); + // The approver IS the preparer. + let ctx = ctx_for(preparer, tenant); + + let err = service + .approve(&ctx, &scope, approval_id) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::SelfApprovalForbidden(_)), + "self-approval must be forbidden, got {err:?}" + ); + assert_eq!( + exec.calls(), + 0, + "self-approval must not execute the mutation" + ); + let row = service + .get(&ctx, &scope, approval_id) + .await + .expect("get") + .expect("present"); + assert_eq!( + row.state, "PENDING", + "the rejected self-approval leaves it pending" + ); +} + +/// Confused-deputy guard: an actor authenticated in tenant B cannot approve +/// tenant A's approval — it surfaces as `ApprovalNotFound` (scoped-out), the held +/// mutation never executes, and tenant A's approval is untouched. This is the +/// cross-tenant case the single-tenant e2e cannot reach. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn approve_across_a_tenant_boundary_is_not_found_and_does_not_execute() { + let (_c, _raw, provider) = boot().await; + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let attacker = Uuid::now_v7(); + let scope_a = AccessScope::for_tenant(tenant_a); + let scope_b = AccessScope::for_tenant(tenant_b); + + let (approval_id, _) = + seed_pending(&provider, &scope_a, tenant_a, preparer, in_one_hour()).await; + + let exec = RecordingExecutor::default(); + let service = ApprovalService::new( + provider.clone(), + Arc::new(exec.clone()), + Arc::new(NoopLedgerMetrics), + bss_ledger::config::FxConfig::default(), + ); + + // Attacker in tenant B, with tenant B's scope, targets tenant A's approval id. + let ctx_b = ctx_for(attacker, tenant_b); + let err = service + .approve(&ctx_b, &scope_b, approval_id) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::ApprovalNotFound(_)), + "a cross-tenant approve must be NotFound, got {err:?}" + ); + assert_eq!( + exec.calls(), + 0, + "a cross-tenant approve must never execute the mutation" + ); + + // Tenant A's approval is still PENDING and unstamped. + let ctx_a = ctx_for(preparer, tenant_a); + let row = service + .get(&ctx_a, &scope_a, approval_id) + .await + .expect("get") + .expect("present"); + assert_eq!( + row.state, "PENDING", + "the foreign approval must remain pending" + ); + assert_eq!(row.approved_by, None); +} + +/// A reject marks the approval `REJECTED` and never executes the held mutation. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reject_marks_rejected_without_executing() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let approver = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + let (approval_id, _) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + + let exec = RecordingExecutor::default(); + let service = ApprovalService::new( + provider.clone(), + Arc::new(exec.clone()), + Arc::new(NoopLedgerMetrics), + bss_ledger::config::FxConfig::default(), + ); + let ctx = ctx_for(approver, tenant); + + service + .reject(&ctx, &scope, approval_id, "not this period".to_owned()) + .await + .expect("reject"); + + assert_eq!(exec.calls(), 0, "a reject must never execute the mutation"); + let row = service + .get(&ctx, &scope, approval_id) + .await + .expect("get") + .expect("present"); + assert_eq!(row.state, "REJECTED"); +} + +/// An approval past its TTL is not actionable: an approve attempt fails +/// `ApprovalNotActionable` and never executes. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn an_expired_pending_is_not_actionable() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let approver = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // Already expired an hour ago. + let (approval_id, _) = seed_pending( + &provider, + &scope, + tenant, + preparer, + Utc::now() - Duration::hours(1), + ) + .await; + + let exec = RecordingExecutor::default(); + let service = ApprovalService::new( + provider.clone(), + Arc::new(exec.clone()), + Arc::new(NoopLedgerMetrics), + bss_ledger::config::FxConfig::default(), + ); + let ctx = ctx_for(approver, tenant); + + let err = service + .approve(&ctx, &scope, approval_id) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::ApprovalNotActionable(_)), + "an expired approval must not be actionable, got {err:?}" + ); + assert_eq!(exec.calls(), 0, "an expired approval must never execute"); +} + +// ─── service lifecycle: gate / list / request-changes / resubmit / cancel / comments ─── + +/// A `CreditGrant` intent keyed by `app_id`, sized at `amount` minor (the +/// amount-gated kind the `gate` threshold check reads). +fn credit_grant_intent(app_id: &str, amount: i64) -> ApprovalIntent { + ApprovalIntent::CreditGrant(CreditGrantIntent { + tenant_id: Uuid::now_v7(), + payer_tenant_id: Uuid::now_v7(), + credit_application_id: app_id.to_owned(), + currency: "USD".to_owned(), + amount_minor: amount, + credit_grant_event_type: Some("promo".to_owned()), + }) +} + +/// A fresh `Reverse` intent (same kind as `seed_pending`'s) — resubmit requires +/// the edited intent's kind to match the stored row. +fn reverse_intent() -> ApprovalIntent { + ApprovalIntent::Reverse(ReverseIntent { + entry_id: Uuid::now_v7(), + into_period_id: None, + effective_at: None, + reason: "resubmitted".to_owned(), + }) +} + +/// Build an `ApprovalService` over `provider` with the recording stub executor. +fn service(provider: &DBProvider, exec: &RecordingExecutor) -> ApprovalService { + ApprovalService::new( + provider.clone(), + Arc::new(exec.clone()), + Arc::new(NoopLedgerMetrics), + bss_ledger::config::FxConfig::default(), + ) +} + +/// `gate` over the default D2 threshold ($1000 = 100_000 minor; no tenant policy +/// row ⇒ ratified defaults) creates a PENDING approval and returns its id; a +/// second gate on the same `(tenant, kind, business_key)` returns the SAME id +/// (DC13 active-uniqueness), never a duplicate. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn gate_over_threshold_creates_pending_and_is_idempotent() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let exec = RecordingExecutor::default(); + let svc = service(&provider, &exec); + let ctx = ctx_for(Uuid::now_v7(), tenant); + + let intent = credit_grant_intent("CA-DC13", 120_000); + let facts = OperationFacts { + kind: ApprovalKind::CreditGrant, + amount_usd_eq_minor: Some(120_000), + effective_at: None, + has_outstanding_balance: false, + }; + let id1 = svc + .gate( + &ctx, + &scope, + intent.clone(), + facts, + "credit-grant".to_owned(), + ) + .await + .expect("gate") + .expect("over-threshold gate must create a pending approval"); + let id2 = svc + .gate(&ctx, &scope, intent, facts, "credit-grant".to_owned()) + .await + .expect("gate (replay)") + .expect("replay returns the active approval"); + assert_eq!( + id1, id2, + "DC13: one active approval per (tenant, kind, business_key)" + ); + + let pending = svc + .list(&ctx, &scope, Some("PENDING"), Some("CREDIT_GRANT")) + .await + .expect("list"); + assert_eq!(pending.len(), 1, "exactly one PENDING, got {pending:?}"); + assert_eq!(exec.calls(), 0, "gating never executes the mutation"); +} + +/// `gate` below the D2 threshold returns `None` — the caller proceeds single-actor +/// and no approval row is created. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn gate_below_threshold_creates_no_approval() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let svc = service(&provider, &RecordingExecutor::default()); + let ctx = ctx_for(Uuid::now_v7(), tenant); + + let out = svc + .gate( + &ctx, + &scope, + credit_grant_intent("CA-SMALL", 50_000), + OperationFacts { + kind: ApprovalKind::CreditGrant, + amount_usd_eq_minor: Some(50_000), + effective_at: None, + has_outstanding_balance: false, + }, + "credit-grant".to_owned(), + ) + .await + .expect("gate"); + assert!( + out.is_none(), + "below-threshold gate must not require approval" + ); + assert!( + svc.list(&ctx, &scope, None, None) + .await + .expect("list") + .is_empty(), + "no approval row is created below threshold" + ); +} + +/// request-changes (by a second actor) parks a PENDING approval NEEDS_REWORK; the +/// preparer then resubmits an edited same-kind intent and it returns to PENDING +/// with a bumped revision. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn request_changes_then_resubmit_recycles_to_pending() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let approver = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let (id, seeded) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + let svc = service(&provider, &RecordingExecutor::default()); + + svc.request_changes( + &ctx_for(approver, tenant), + &scope, + id, + "add a memo".to_owned(), + ) + .await + .expect("request_changes"); + let row = svc + .get(&ctx_for(approver, tenant), &scope, id) + .await + .expect("get") + .expect("present"); + assert_eq!(row.state, "NEEDS_REWORK"); + + // Resubmit the SAME target (only the amount may be edited, and Reverse carries + // none), so the identity-pin admits it and the row recycles. + svc.resubmit(&ctx_for(preparer, tenant), &scope, id, seeded) + .await + .expect("resubmit"); + let row = svc + .get(&ctx_for(preparer, tenant), &scope, id) + .await + .expect("get") + .expect("present"); + assert_eq!(row.state, "PENDING"); + assert!( + row.revision >= 1, + "resubmit bumps the revision, got {}", + row.revision + ); +} + +/// DC #1: a resubmit that SWAPS the target identity (same kind, but a +/// fresh `entry_id` — a different recipient/target than the seeded intent) is +/// rejected and the row stays NEEDS_REWORK. Without the identity-pin a preparer +/// could, after a request-changes, redirect an over-threshold approval to a swapped +/// party; the approver — who never sees the target in `ApprovalDto` — would then +/// execute the swapped intent the executor replays from storage. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn resubmit_with_swapped_target_is_rejected() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let approver = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let (id, _seeded) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + let svc = service(&provider, &RecordingExecutor::default()); + + svc.request_changes(&ctx_for(approver, tenant), &scope, id, "redo".to_owned()) + .await + .expect("request_changes"); + + // `reverse_intent()` is the SAME kind (Reverse) but a fresh `entry_id` — a + // swapped target. The kind guard passes; the identity-pin must reject it. + let err = svc + .resubmit(&ctx_for(preparer, tenant), &scope, id, reverse_intent()) + .await + .expect_err("a swapped-target resubmit must be rejected"); + assert!( + matches!(err, DomainError::ApprovalNotActionable(_)), + "expected ApprovalNotActionable, got {err:?}" + ); + let row = svc + .get(&ctx_for(preparer, tenant), &scope, id) + .await + .expect("get") + .expect("present"); + assert_eq!( + row.state, "NEEDS_REWORK", + "a rejected resubmit leaves the row in NEEDS_REWORK" + ); +} + +/// request-changes by the preparer themselves is forbidden (decider != preparer). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn request_changes_by_preparer_is_forbidden() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let (id, _) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + let svc = service(&provider, &RecordingExecutor::default()); + + let err = svc + .request_changes(&ctx_for(preparer, tenant), &scope, id, "x".to_owned()) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::SelfApprovalForbidden(_)), + "got {err:?}" + ); +} + +/// resubmit by a non-preparer is rejected (only the preparer may resubmit). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn resubmit_by_non_preparer_is_rejected() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let approver = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let (id, _) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + let svc = service(&provider, &RecordingExecutor::default()); + svc.request_changes(&ctx_for(approver, tenant), &scope, id, "rework".to_owned()) + .await + .expect("request_changes"); + + let err = svc + .resubmit( + &ctx_for(Uuid::now_v7(), tenant), + &scope, + id, + reverse_intent(), + ) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::ApprovalNotActionable(_)), + "got {err:?}" + ); +} + +/// The preparer cancels their own active approval (-> CANCELLED); a non-preparer +/// cannot (it stays active). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cancel_is_preparer_only() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let (id, _) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + let svc = service(&provider, &RecordingExecutor::default()); + + let err = svc + .cancel(&ctx_for(Uuid::now_v7(), tenant), &scope, id) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::ApprovalNotActionable(_)), + "got {err:?}" + ); + let row = svc + .get(&ctx_for(preparer, tenant), &scope, id) + .await + .expect("get") + .expect("present"); + assert_eq!(row.state, "PENDING", "a failed cancel leaves it active"); + + svc.cancel(&ctx_for(preparer, tenant), &scope, id) + .await + .expect("cancel"); + let row = svc + .get(&ctx_for(preparer, tenant), &scope, id) + .await + .expect("get") + .expect("present"); + assert_eq!(row.state, "CANCELLED"); +} + +/// add_comment appends to the append-only thread (no state change); thread reads +/// it back. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn add_comment_appends_to_the_thread() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let (id, _) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + let svc = service(&provider, &RecordingExecutor::default()); + let ctx = ctx_for(Uuid::now_v7(), tenant); + + svc.add_comment(&ctx, &scope, id, "please clarify the period".to_owned()) + .await + .expect("add_comment"); + let thread = svc.thread(&ctx, &scope, id).await.expect("thread"); + assert!( + thread.iter().any(|c| c.body == "please clarify the period"), + "the appended comment must appear in the thread, got {thread:?}" + ); + let row = svc + .get(&ctx, &scope, id) + .await + .expect("get") + .expect("present"); + assert_eq!(row.state, "PENDING", "a comment is not a state change"); +} + +/// An `ApprovalExecutor` that BLOCKS in `execute` until released — lets a test +/// interpose a concurrent decision while an approve is mid-execute (the row +/// latched `APPROVING`). `entered` fires when execute is reached; `gate` releases +/// it; `calls` counts executions (like `RecordingExecutor`). +#[derive(Clone)] +struct BlockingExecutor { + entered: Arc, + gate: Arc, + calls: Arc, +} + +#[async_trait::async_trait] +impl ApprovalExecutor for BlockingExecutor { + async fn execute( + &self, + _ctx: &SecurityContext, + _scope: &AccessScope, + _intent: &ApprovalIntent, + ) -> Result<(), DomainError> { + self.entered.notify_one(); + self.gate.notified().await; + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +/// H2: once an approve latches `PENDING → APPROVING` and begins executing, a +/// concurrent `reject` can no longer win (the decision verbs are keyed on +/// `PENDING`) — so the governed mutation never ends up committed against a +/// REJECTED approval. The approve then completes `APPROVED`, executing exactly +/// once. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reject_cannot_win_after_approve_latches_approving() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let preparer = Uuid::now_v7(); + let approver = Uuid::now_v7(); + let rejecter = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let (id, _) = seed_pending(&provider, &scope, tenant, preparer, in_one_hour()).await; + + let exec = BlockingExecutor { + entered: Arc::new(tokio::sync::Notify::new()), + gate: Arc::new(tokio::sync::Notify::new()), + calls: Arc::new(AtomicUsize::new(0)), + }; + let svc = ApprovalService::new( + provider.clone(), + Arc::new(exec.clone()), + Arc::new(NoopLedgerMetrics), + bss_ledger::config::FxConfig::default(), + ); + + // Spawn approve: it latches PENDING→APPROVING (own txn, committed), then blocks + // inside execute. + let svc_a = svc.clone(); + let ctx_a = ctx_for(approver, tenant); + let scope_a = scope.clone(); + let approve_task = tokio::spawn(async move { svc_a.approve(&ctx_a, &scope_a, id).await }); + + // Wait until approve is inside execute — the row is now APPROVING (committed). + exec.entered.notified().await; + let row = svc + .get(&ctx_for(approver, tenant), &scope, id) + .await + .expect("get") + .expect("present"); + assert_eq!( + row.state, "APPROVING", + "approve must latch APPROVING before executing" + ); + + // A concurrent reject must NOT win — the row is no longer PENDING. + let reject_err = svc + .reject( + &ctx_for(rejecter, tenant), + &scope, + id, + "too late".to_owned(), + ) + .await + .unwrap_err(); + assert!( + matches!(reject_err, DomainError::ApprovalNotActionable(_)), + "reject after the APPROVING latch must fail, got {reject_err:?}" + ); + + // Release execute → approve completes APPROVED, having executed exactly once. + exec.gate.notify_one(); + approve_task + .await + .expect("approve task joins") + .expect("approve completes"); + let row = svc + .get(&ctx_for(approver, tenant), &scope, id) + .await + .expect("get") + .expect("present"); + assert_eq!(row.state, "APPROVED", "approve completes APPROVED"); + assert_eq!( + exec.calls.load(Ordering::SeqCst), + 1, + "the governed mutation executed exactly once" + ); +} + +/// Z11-2 / DC8: `set_policy` writes an effective-dated threshold version that the +/// resolver then picks up; a second write supersedes (version increments). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn set_policy_persists_and_resolves() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let admin = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let svc = service(&provider, &RecordingExecutor::default()); + let repo = ApprovalRepo::new(provider.clone()); + + // No row yet → the resolver yields the ratified defaults (d2 = 100_000). + assert!( + repo.read_policy_versions(&scope, tenant) + .await + .expect("read") + .is_empty() + ); + + let v0 = svc + .set_policy( + &ctx_for(admin, tenant), + &scope, + 250_000, + 10, + 3600, + Utc::now() - Duration::hours(1), + ) + .await + .expect("set policy"); + assert_eq!(v0, 0, "first version is 0"); + + let versions = repo + .read_policy_versions(&scope, tenant) + .await + .expect("read"); + let policy = resolve_policy(&versions, Utc::now()); + assert_eq!( + policy.d2_threshold_minor, 250_000, + "resolver picks the written threshold" + ); + assert_eq!(policy.a6_backdating_biz_days, 10); + assert_eq!(policy.pending_ttl_seconds, 3600); + + // A second write supersedes (version increments; resolver picks the latest). + let v1 = svc + .set_policy( + &ctx_for(admin, tenant), + &scope, + 500_000, + 5, + 7200, + Utc::now(), + ) + .await + .expect("set policy 2"); + assert_eq!(v1, 1, "second version is 1"); + let versions = repo + .read_policy_versions(&scope, tenant) + .await + .expect("read"); + assert_eq!( + resolve_policy(&versions, Utc::now()).d2_threshold_minor, + 500_000 + ); +} + +/// Z11-2 / DC9: an out-of-range threshold is REJECTED (no clamp) and writes no row. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn set_policy_rejects_out_of_range() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let admin = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let svc = service(&provider, &RecordingExecutor::default()); + + // d2 below the floor (10_000 minor = 100 USD) → rejected. + let err = svc + .set_policy(&ctx_for(admin, tenant), &scope, 5_000, 5, 7200, Utc::now()) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::DualControlPolicyOutOfRange(_)), + "out-of-range d2 must be rejected, got {err:?}" + ); + assert!( + ApprovalRepo::new(provider.clone()) + .read_policy_versions(&scope, tenant) + .await + .expect("read") + .is_empty(), + "a rejected config writes no row" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_entry_annotation.rs b/gears/bss/ledger/ledger/tests/postgres_entry_annotation.rs new file mode 100644 index 000000000..a8810d17b --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_entry_annotation.rs @@ -0,0 +1,476 @@ +//! Postgres-only end-to-end: the typed controlled non-financial annotation path +//! (`AnnotationService::set`, Slice 6 Phase 2 Group 2B, Variant C remodel). +//! Boots a container, migrates, seeds reference data, posts one balanced entry, +//! then drives the annotation writes and asserts: +//! * a PII-bearing description is rejected `PII_IN_METADATA_VALUE` pre-write +//! (no `entry_annotation` row); +//! * a dangling target is rejected `INVALID_REQUEST` pre-write (no row); +//! * an allow-listed description writes ONE `entry_annotation` row + ONE +//! `metadata-change` `secured_audit_record`, and the posted +//! `journal_entry` `row_hash` is byte-unchanged; +//! * a second set UPSERTs in place (one row) with the description updated. +//! +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::annotation::{AnnotationService, AnnotationTarget}; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Hex string of a single-column, single-row SELECT, `None` when absent/NULL. +async fn scalar_hex(conn: &DatabaseConnection, sql: &str) -> Option { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.and_then(|r| r.try_get_by_index::>(0).unwrap()) +} + +/// Count rows matching a bss-qualified predicate. +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +/// A single text column over one row, `None` when absent/NULL. +async fn scalar_text(conn: &DatabaseConnection, sql: &str) -> Option { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.and_then(|r| r.try_get_by_index::>(0).unwrap()) +} + +struct Fixture { + tenant: Uuid, + ar_account: Uuid, + cash_account: Uuid, + legal_entity: Uuid, + period_id: String, +} + +/// Boot, migrate, seed USD@2 + OPEN period + AR/CASH accounts. (Mirrors +/// `postgres_chain.rs::setup`.) +async fn setup( + container_url: &str, +) -> ( + DatabaseConnection, + PostingService, + DBProvider, + Fixture, +) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = "202606".to_owned(); + let ar_account = Uuid::now_v7(); + let cash_account = Uuid::now_v7(); + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{legal_entity}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + + reference + .insert_account(AccountRow { + account_id: ar_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + reference + .insert_account(AccountRow { + account_id: cash_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "CASH_CLEARING".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "CR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + let service = PostingService::new( + provider.clone(), + std::sync::Arc::new(bss_ledger::infra::events::publisher::LedgerEventPublisher::noop()), + ); + ( + raw, + service, + provider, + Fixture { + tenant, + ar_account, + cash_account, + legal_entity, + period_id, + }, + ) +} + +/// Build a balanced entry: DR AR / CR CASH, each `amount`. +fn balanced_entry(f: &Fixture, business_id: &str, amount: i64) -> (NewEntry, Vec) { + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: f.tenant, + legal_entity_id: f.legal_entity, + period_id: f.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: business_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: f.tenant, + correlation_id: f.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + line(f, f.ar_account, AccountClass::Ar, Side::Debit, amount), + line( + f, + f.cash_account, + AccountClass::CashClearing, + Side::Credit, + amount, + ), + ]; + (entry, lines) +} + +fn line(f: &Fixture, account: Uuid, class: AccountClass, side: Side, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + ar_status: None, + payer_tenant_id: f.tenant, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + } +} + +/// The full Group 2B annotation path against Postgres: a dangling target and a +/// PII-bearing note are rejected pre-write (no row); an allow-listed note writes +/// one `entry_annotation` row + one `metadata-change` secured-audit record and +/// leaves the posted journal rows byte-unchanged; a second set UPSERTs in place +/// (one row) with before = the prior value. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +#[allow( + clippy::too_many_lines, + reason = "one container boot amortizes the full pre-write rejection matrix + happy-path upsert + journal-unchanged proof" +)] +async fn annotation_set_upserts_and_leaves_journal_unchanged() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + let (entry, lines) = balanced_entry(&f, "biz-annot", 1000); + let entry_id = entry.entry_id; + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("post must succeed"); + + let entry_hash_before = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.ledger_journal_entry WHERE entry_id='{entry_id}'" + ), + ) + .await + .expect("entry row_hash before"); + + let svc = AnnotationService::new(); + + // (1) A PII-bearing description is rejected PII_IN_METADATA_VALUE, pre-write. + let err = svc + .set( + &provider, + &ctx, + &scope, + f.tenant, + entry_id, + f.period_id.clone(), + AnnotationTarget::Entry, + Some("refund for jane.doe@example.com".to_owned()), + "actor-1".to_owned(), + None, + None, + ) + .await + .expect_err("a PII-bearing annotation must be rejected"); + assert!( + matches!(err, DomainError::PiiInMetadataValue(_)), + "got: {err:?}" + ); + + // (2) A dangling target is rejected pre-write. + let err = svc + .set( + &provider, + &ctx, + &scope, + f.tenant, + uuid::Uuid::now_v7(), + f.period_id.clone(), + AnnotationTarget::Entry, + Some("note for a ghost".to_owned()), + "actor-1".to_owned(), + None, + None, + ) + .await + .expect_err("a dangling-target annotation must be rejected"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "got: {err:?}" + ); + + let rows_after_rejections = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.entry_annotation WHERE tenant_id='{}'", + f.tenant + ), + ) + .await; + assert_eq!( + rows_after_rejections, 0, + "a pre-write rejection must write NO entry_annotation row" + ); + + // (3) First set: writes one current-state row + one audit record. + svc.set( + &provider, + &ctx, + &scope, + f.tenant, + entry_id, + f.period_id.clone(), + AnnotationTarget::Entry, + Some("first note".to_owned()), + "actor-1".to_owned(), + Some("ops note".to_owned()), + // S-1 cross-trace (Variant B): the handler anchors the audit record on the + // annotated entry's OWN correlation_id; the fixture posts it as `f.tenant`. + Some(f.tenant), + ) + .await + .expect("an annotation set must succeed"); + + let rows = count( + &raw, + &format!("SELECT COUNT(*) FROM bss.entry_annotation WHERE tenant_id='{}' AND target_id='{entry_id}'", f.tenant), + ) + .await; + assert_eq!(rows, 1, "exactly one entry_annotation row"); + + let desc = scalar_text( + &raw, + &format!("SELECT description FROM bss.entry_annotation WHERE tenant_id='{}' AND target_id='{entry_id}' AND target_kind='ENTRY'", f.tenant), + ) + .await; + assert_eq!(desc.as_deref(), Some("first note")); + + let audit_rows = count( + &raw, + &format!("SELECT COUNT(*) FROM bss.secured_audit_record WHERE tenant_id='{}' AND event_type='metadata-change'", f.tenant), + ) + .await; + assert_eq!( + audit_rows, 1, + "exactly one metadata-change secured-audit record" + ); + + // S-1 cross-trace (Variant B): the audit record carries the annotated entry's + // own correlation_id, so it joins back to `journal_entry` by construction. + let audit_correlation = scalar_text( + &raw, + &format!( + "SELECT correlation_id::text FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='metadata-change'", + f.tenant + ), + ) + .await; + assert_eq!( + audit_correlation.as_deref(), + Some(f.tenant.to_string().as_str()), + "the metadata-change record must carry the entry's correlation_id" + ); + + // (4) Second set: UPSERTs in place (still one row), description updated. + svc.set( + &provider, + &ctx, + &scope, + f.tenant, + entry_id, + f.period_id.clone(), + AnnotationTarget::Entry, + Some("second note".to_owned()), + "actor-2".to_owned(), + Some("revised".to_owned()), + None, + ) + .await + .expect("the second annotation set must succeed"); + + let rows = count( + &raw, + &format!("SELECT COUNT(*) FROM bss.entry_annotation WHERE tenant_id='{}' AND target_id='{entry_id}'", f.tenant), + ) + .await; + assert_eq!(rows, 1, "the second set UPSERTs in place — still one row"); + + let desc = scalar_text( + &raw, + &format!("SELECT description FROM bss.entry_annotation WHERE tenant_id='{}' AND target_id='{entry_id}' AND target_kind='ENTRY'", f.tenant), + ) + .await; + assert_eq!( + desc.as_deref(), + Some("second note"), + "current-state reflects the latest set" + ); + + let audit_rows = count( + &raw, + &format!("SELECT COUNT(*) FROM bss.secured_audit_record WHERE tenant_id='{}' AND event_type='metadata-change'", f.tenant), + ) + .await; + assert_eq!( + audit_rows, 2, + "two changes ⇒ two audit records (history lives in the chain)" + ); + + // (5) The latest secured-audit record must carry before="first note" / after="second note". + // `before_after` is the ONLY place change history is kept (design decision D2). + let ba_before = scalar_text( + &raw, + &format!( + "SELECT before_after->>'before' FROM bss.secured_audit_record \ + WHERE tenant_id='{0}' AND event_type='metadata-change' \ + ORDER BY at_utc DESC LIMIT 1", + f.tenant + ), + ) + .await; + assert_eq!( + ba_before.as_deref(), + Some("first note"), + "audit chain must record the prior description as 'before'" + ); + + let ba_after = scalar_text( + &raw, + &format!( + "SELECT before_after->>'after' FROM bss.secured_audit_record \ + WHERE tenant_id='{0}' AND event_type='metadata-change' \ + ORDER BY at_utc DESC LIMIT 1", + f.tenant + ), + ) + .await; + assert_eq!( + ba_after.as_deref(), + Some("second note"), + "audit chain must record the new description as 'after'" + ); + + // The posted journal row_hash is byte-unchanged by either annotation. + let entry_hash_after = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.ledger_journal_entry WHERE entry_id='{entry_id}'" + ), + ) + .await + .expect("entry row_hash after"); + assert_eq!( + entry_hash_before, entry_hash_after, + "annotation must NOT touch the journal" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_exception.rs b/gears/bss/ledger/ledger/tests/postgres_exception.rs new file mode 100644 index 000000000..8b9a49a7e --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_exception.rs @@ -0,0 +1,222 @@ +//! Postgres-only integration tests for the Slice 7 Phase 2 exception queue: +//! `ExceptionRouter` (additive, period-bound, deduped routing) + `ExceptionQueueRepo` +//! (list / read / resolve) + the close-gate consumption of OPEN rows (incl. the +//! `GL_WRITEOFF_VARIANCE` acknowledge-to-non-block path). Ignored by default; run +//! with `cargo test -p bss-ledger --test postgres_exception -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::exception::ExceptionType; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::exception::ExceptionRouter; +use bss_ledger::infra::period_close::PeriodCloseService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ExceptionQueueRepo; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot + migrate; seed USD@2 + ONE OPEN fiscal period for `tenant` (LE = tenant). +/// Returns the raw conn, the provider, and the tenant id. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Uuid) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) \ + VALUES ('{tenant}','{tenant}','202606','UTC','OPEN')" + ))) + .await + .unwrap(); + (raw, provider, tenant) +} + +/// Count exception rows for `(tenant, type)` in a given status. +async fn count_rows( + raw: &DatabaseConnection, + tenant: Uuid, + exception_type: &str, + status: &str, +) -> i64 { + let row = raw + .query_one(pg(format!( + "SELECT count(*) AS c FROM bss.ledger_exception_queue \ + WHERE tenant_id='{tenant}' AND exception_type='{exception_type}' AND status='{status}'" + ))) + .await + .unwrap() + .expect("count row"); + row.try_get::("", "c").unwrap() +} + +/// `ExceptionRouter::route` opens ONE OPEN row, period-bound to the current OPEN +/// period; a second route on the same `(type, business_ref)` dedups (still ONE row). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn route_opens_one_period_bound_row_and_dedups() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, tenant) = setup(&url).await; + let router = ExceptionRouter::new(provider); + + router + .route(tenant, ExceptionType::ReconMismatch, "ref-1", None) + .await; + assert_eq!( + count_rows(&raw, tenant, "RECON_MISMATCH", "OPEN").await, + 1, + "the first route opens exactly one OPEN row" + ); + + // The row is bound to the current OPEN period (so the close gate sees it). + let period = raw + .query_one(pg(format!( + "SELECT period_id FROM bss.ledger_exception_queue \ + WHERE tenant_id='{tenant}' AND exception_type='RECON_MISMATCH'" + ))) + .await + .unwrap() + .expect("row") + .try_get::>("", "period_id") + .unwrap(); + assert_eq!(period.as_deref(), Some("202606"), "row is period-bound"); + + // A second route on the same business key dedups — still exactly one OPEN row. + router + .route(tenant, ExceptionType::ReconMismatch, "ref-1", None) + .await; + assert_eq!( + count_rows(&raw, tenant, "RECON_MISMATCH", "OPEN").await, + 1, + "a re-route on the same (type, business_ref) does not duplicate the OPEN row" + ); +} + +/// `resolve_one` clears an OPEN row (OPEN → RESOLVED); `list` filters by status. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn resolve_transitions_and_list_filters() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (_raw, provider, tenant) = setup(&url).await; + let scope = AccessScope::for_tenant(tenant); + let router = ExceptionRouter::new(provider.clone()); + let repo = ExceptionQueueRepo::new(provider); + + router + .route(tenant, ExceptionType::SplitAmbiguous, "cn-1", None) + .await; + let open = repo.list(&scope, tenant, Some("OPEN")).await.unwrap(); + assert_eq!(open.len(), 1, "one OPEN row listed"); + let id = open[0].exception_id; + + repo.resolve_one(&scope, tenant, id, "RESOLVED", "operator-1") + .await + .unwrap(); + + assert!( + repo.list(&scope, tenant, Some("OPEN")) + .await + .unwrap() + .is_empty(), + "no OPEN rows after resolve" + ); + let resolved = repo.list(&scope, tenant, Some("RESOLVED")).await.unwrap(); + assert_eq!(resolved.len(), 1, "the row is now RESOLVED"); + assert_eq!(resolved[0].resolved_by.as_deref(), Some("operator-1")); +} + +/// An OPEN exception blocks close; a `GL_WRITEOFF_VARIANCE` acknowledged to +/// `APPROVED_EXCEPTION` does NOT block (the one acknowledge-to-non-block kind). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn gl_writeoff_approved_exception_does_not_block_close() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, tenant) = setup(&url).await; + let scope = AccessScope::for_tenant(tenant); + let router = ExceptionRouter::new(provider.clone()); + let repo = ExceptionQueueRepo::new(provider.clone()); + let close = PeriodCloseService::new( + provider, + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new()), + ); + + // A GL-writeoff exception, OPEN → blocks close. + router + .route(tenant, ExceptionType::GlWriteoffVariance, "gl-1", None) + .await; + let id = repo.list(&scope, tenant, Some("OPEN")).await.unwrap()[0].exception_id; + let err = close + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + "202606".to_owned(), + ) + .await + .expect_err("an OPEN GL-writeoff exception blocks close"); + assert!( + matches!(err, DomainError::PeriodCloseBlocked(_)), + "got {err:?}" + ); + + // Finance acknowledges it → APPROVED_EXCEPTION; it no longer blocks close + // (there are no books, so the tie-out is clean and the close now succeeds). + repo.resolve_one(&scope, tenant, id, "APPROVED_EXCEPTION", "finance-1") + .await + .unwrap(); + let outcome = close + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + "202606".to_owned(), + ) + .await + .expect("an APPROVED_EXCEPTION GL-writeoff does not block close"); + assert!(!outcome.already_closed, "the period closes cleanly"); + let status = raw + .query_one(pg(format!( + "SELECT status FROM bss.ledger_fiscal_period \ + WHERE tenant_id='{tenant}' AND period_id='202606'" + ))) + .await + .unwrap() + .expect("period row") + .try_get::("", "status") + .unwrap(); + assert_eq!(status, "CLOSED"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_executor.rs b/gears/bss/ledger/ledger/tests/postgres_executor.rs new file mode 100644 index 000000000..b2f11916d --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_executor.rs @@ -0,0 +1,539 @@ +//! Tests for `LedgerApprovalExecutor` — the concrete `ApprovalExecutor` that +//! replays an approved `ApprovalIntent` against the real mutation surfaces +//! (VHP-1852, Group E). Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_executor -- --ignored`. +//! +//! Drives `execute` per intent kind against a recording stub `LedgerClientV1` + +//! stub `InvoicePoster` and a real `PayerStateRepo` (testcontainers, for the +//! payer-closure arm). Asserts each arm dispatches to the right surface, and that +//! a `Reverse` whose entry no longer resolves (`get_entry -> None`) fails closed +//! (`ApprovalNotActionable`) rather than posting a reversal — the confused-deputy +//! guard at the executor seam. The full happy reverse / material-backdating posts +//! are exercised by the e2e (they need a reversible entry / a built invoice). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; +use std::sync::Mutex; + +use bss_ledger::domain::approval::intent::{ + ApprovalIntent, ChargebackLossIntent, CreditGrantIntent, PayerClosureIntent, + RecognitionScheduleChangeIntent, ReverseIntent, +}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::infra::adjustment::manual_adjustment_service::ManualAdjustmentHandler; +use bss_ledger::infra::adjustment::refund_service::RefundHandler; +use bss_ledger::infra::approval::executor::LedgerApprovalExecutor; +use bss_ledger::infra::approval::service::ApprovalExecutor; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePoster; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::PayerStateRepo; +use bss_ledger_sdk::api::LedgerClientV1; +use bss_ledger_sdk::posting::{ + CreditApplicationApplied, DisputeOutcome, DisputeRecorded, PostingRef, ScheduleChangeRef, +}; +use sea_orm::Database; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + (container, DBProvider::::new(tdb)) +} + +fn stub_posting_ref() -> PostingRef { + PostingRef { + entry_id: Uuid::now_v7(), + created_seq: 1, + replayed: false, + } +} + +/// A recording stub `LedgerClientV1`: the four methods the executor dispatches to +/// push a tag onto `calls` and return a canned Ok; the rest are unreached. +/// `get_entry` returns `None` (so the `Reverse` arm hits its not-found branch). +#[derive(Clone, Default)] +struct RecordingClient { + calls: Arc>>, +} + +impl RecordingClient { + fn calls(&self) -> Vec<&'static str> { + self.calls.lock().expect("lock").clone() + } +} + +#[async_trait::async_trait] +impl LedgerClientV1 for RecordingClient { + async fn get_entry( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _entry_id: Uuid, + ) -> Result, toolkit::api::canonical_prelude::CanonicalError> + { + self.calls.lock().expect("lock").push("get_entry"); + Ok(None) + } + + async fn post_credit_application( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::CreditApplication, + ) -> Result { + self.calls + .lock() + .expect("lock") + .push("post_credit_application"); + Ok(CreditApplicationApplied { + posting: stub_posting_ref(), + debits: Vec::new(), + applications: Vec::new(), + }) + } + + async fn record_dispute_phase( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::RecordDisputePhase, + ) -> Result { + self.calls + .lock() + .expect("lock") + .push("record_dispute_phase"); + Ok(DisputeOutcome::Recorded(DisputeRecorded { + posting: stub_posting_ref(), + })) + } + + async fn change_recognition_schedule( + &self, + _ctx: &SecurityContext, + _cmd: bss_ledger_sdk::ChangeRecognitionSchedule, + ) -> Result { + self.calls + .lock() + .expect("lock") + .push("change_recognition_schedule"); + Ok(ScheduleChangeRef { + schedule_id: "SCH-1".to_owned(), + new_schedule_id: None, + status: "CANCELLED".to_owned(), + }) + } + + // ── not reached by the executor ── + async fn return_payment( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::ReturnPayment, + ) -> Result { + unimplemented!() + } + async fn post_balanced_entry( + &self, + _ctx: &SecurityContext, + _entry: bss_ledger_sdk::PostEntry, + ) -> Result { + unimplemented!() + } + async fn read_account_balance( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _account_id: Uuid, + ) -> Result, toolkit::api::canonical_prelude::CanonicalError> { + unimplemented!() + } + async fn list_accounts( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &bss_ledger_sdk::ODataQuery, + ) -> Result< + bss_ledger_sdk::Page, + toolkit::api::canonical_prelude::CanonicalError, + > { + unimplemented!() + } + async fn list_lines( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &bss_ledger_sdk::ODataQuery, + ) -> Result< + bss_ledger_sdk::Page, + toolkit::api::canonical_prelude::CanonicalError, + > { + unimplemented!() + } + async fn list_balances( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &bss_ledger_sdk::ODataQuery, + ) -> Result< + bss_ledger_sdk::Page, + toolkit::api::canonical_prelude::CanonicalError, + > { + unimplemented!() + } + async fn list_ar_invoice_balances( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Option, + ) -> Result< + Vec, + toolkit::api::canonical_prelude::CanonicalError, + > { + unimplemented!() + } + async fn provision( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::ProvisionRequest, + ) -> Result + { + unimplemented!() + } + async fn close_period( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _period_id: String, + ) -> Result { + unimplemented!() + } + async fn settle_payment( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::SettlePayment, + ) -> Result { + unimplemented!() + } + async fn allocate_payment( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::AllocatePayment, + ) -> Result + { + unimplemented!() + } + async fn list_payment_allocations( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payment_id: String, + ) -> Result, toolkit::api::canonical_prelude::CanonicalError> + { + unimplemented!() + } + async fn read_unallocated( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Uuid, + _currency: String, + ) -> Result + { + unimplemented!() + } + async fn trigger_recognition_run( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::TriggerRecognitionRun, + ) -> Result< + bss_ledger_sdk::RecognitionRunOutcome, + toolkit::api::canonical_prelude::CanonicalError, + > { + unimplemented!() + } + async fn list_revenue_disaggregation( + &self, + _ctx: &SecurityContext, + _query: bss_ledger_sdk::RevenueDisaggregationQuery, + ) -> Result< + bss_ledger_sdk::RevenueDisaggregation, + toolkit::api::canonical_prelude::CanonicalError, + > { + unimplemented!() + } + async fn get_recognition_schedule( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _schedule_id: String, + ) -> Result< + Option, + toolkit::api::canonical_prelude::CanonicalError, + > { + unimplemented!() + } + async fn list_recognition_schedules( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _invoice_id: Option, + _revenue_stream: Option, + ) -> Result< + bss_ledger_sdk::RecognitionScheduleList, + toolkit::api::canonical_prelude::CanonicalError, + > { + unimplemented!() + } +} + +/// Stub `InvoicePoster` — the executor's reverse/backdating posts are not reached +/// by these arms (reverse hits the `get_entry -> None` branch first). +struct UnusedPoster; + +#[async_trait::async_trait] +impl InvoicePoster for UnusedPoster { + async fn post_invoice( + &self, + _ctx: &SecurityContext, + _scope: &AccessScope, + _inv: &bss_ledger::domain::invoice::builder::PostedInvoice, + _payer_open: bool, + ) -> Result { + unimplemented!("material-backdating post is exercised by the e2e") + } + async fn post_reversal( + &self, + _ctx: &SecurityContext, + _scope: &AccessScope, + _reversal: bss_ledger_sdk::PostEntry, + _reason: Option, + ) -> Result { + unimplemented!("the reverse arm fails at get_entry -> None before posting") + } + async fn post_correction( + &self, + _ctx: &SecurityContext, + _scope: &AccessScope, + _correction: bss_ledger_sdk::PostEntry, + ) -> Result { + unimplemented!() + } +} + +fn ctx_for(tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::now_v7()) + .subject_tenant_id(tenant) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +fn executor(provider: &DBProvider, client: &RecordingClient) -> LedgerApprovalExecutor { + LedgerApprovalExecutor::new( + Arc::new(client.clone()) as Arc, + Arc::new(UnusedPoster) as Arc, + PayerStateRepo::new(provider.clone()), + // The refund replay arm is exercised by `postgres_dual_control.rs` end-to-end + // (gate -> approve -> post); here the un-gated handler is just wired so the + // executor constructs (the client-routed arms under test never touch it). + Arc::new(RefundHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + )), + // Likewise the manual-adjustment replay arm: an un-gated handler wired only so + // the executor constructs (the client-routed arms under test never touch it). + Arc::new(ManualAdjustmentHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new()), + )), + // The note replay arms (CreditNote/DebitNote) are un-gated handlers wired only + // so the executor constructs (the client-routed arms under test never touch + // them; the gated gate->approve->post path is exercised elsewhere). + Arc::new( + bss_ledger::infra::adjustment::credit_note_service::CreditNoteHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + ), + ), + Arc::new( + bss_ledger::infra::adjustment::debit_note_service::DebitNoteHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + bss_ledger::config::RecognitionConfig::default(), + ), + ), + bss_ledger::infra::period_close::PeriodCloseService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new()), + ), + ) +} + +/// Each client-routed intent dispatches to its matching `LedgerClientV1` surface +/// (the surface re-applies its own PEP gate + idempotency — replay is safe). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn client_routed_intents_dispatch_to_their_surface() { + let (_c, provider) = boot().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let ctx = ctx_for(tenant); + let client = RecordingClient::default(); + let exec = executor(&provider, &client); + + exec.execute( + &ctx, + &scope, + &ApprovalIntent::CreditGrant(CreditGrantIntent { + tenant_id: tenant, + payer_tenant_id: Uuid::now_v7(), + credit_application_id: "CA-1".to_owned(), + currency: "USD".to_owned(), + amount_minor: 120_000, + credit_grant_event_type: Some("promo".to_owned()), + }), + ) + .await + .expect("credit grant replays"); + + exec.execute( + &ctx, + &scope, + &ApprovalIntent::ChargebackLoss(ChargebackLossIntent { + tenant_id: tenant, + payer_tenant_id: Uuid::now_v7(), + payment_id: "PAY-1".to_owned(), + dispute_id: "DSP-1".to_owned(), + invoice_id: Some("INV-1".to_owned()), + cycle: 1, + funds_at_open: "withheld".to_owned(), + disputed_amount_minor: 120_000, + currency: "USD".to_owned(), + }), + ) + .await + .expect("chargeback loss replays"); + + exec.execute( + &ctx, + &scope, + &ApprovalIntent::RecognitionScheduleChange(RecognitionScheduleChangeIntent { + tenant_id: tenant, + schedule_id: "SCH-1".to_owned(), + change_id: "CHG-1".to_owned(), + action: "cancel".to_owned(), + treatment: "prospective".to_owned(), + new_segments: None, + }), + ) + .await + .expect("schedule change replays"); + + assert_eq!( + client.calls(), + vec![ + "post_credit_application", + "record_dispute_phase", + "change_recognition_schedule" + ] + ); +} + +/// Confused-deputy guard: a `Reverse` whose entry no longer resolves under the +/// approver's tenant (`get_entry -> None`) fails closed (`ApprovalNotActionable`), +/// it never posts a reversal. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reverse_of_a_vanished_entry_fails_closed() { + let (_c, provider) = boot().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let ctx = ctx_for(tenant); + let client = RecordingClient::default(); + let exec = executor(&provider, &client); + + let err = exec + .execute( + &ctx, + &scope, + &ApprovalIntent::Reverse(ReverseIntent { + entry_id: Uuid::now_v7(), + into_period_id: None, + effective_at: None, + reason: "reverse".to_owned(), + }), + ) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::ApprovalNotActionable(_)), + "a vanished entry must fail closed, got {err:?}" + ); + assert_eq!( + client.calls(), + vec!["get_entry"], + "only the read was attempted" + ); +} + +/// The `PayerClosure` arm runs the real `PayerStateRepo.close` under the +/// approver's scope — the row lands CLOSED for the intent's tenant/payer. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn payer_closure_intent_closes_the_payer() { + let (_c, provider) = boot().await; + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let ctx = ctx_for(tenant); + let client = RecordingClient::default(); + let exec = executor(&provider, &client); + + exec.execute( + &ctx, + &scope, + &ApprovalIntent::PayerClosure(PayerClosureIntent { + tenant_id: tenant, + payer_tenant_id: payer, + closed_with_open_balance: true, + disposition: None, + }), + ) + .await + .expect("payer closure replays"); + + let row = PayerStateRepo::new(provider.clone()) + .read(&scope, tenant, payer) + .await + .expect("read") + .expect("payer-state row present after closure"); + assert_eq!(row.lifecycle_state, "CLOSED"); + assert!(row.closed_with_open_balance); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_fx_revaluation_mode.rs b/gears/bss/ledger/ledger/tests/postgres_fx_revaluation_mode.rs new file mode 100644 index 000000000..50f74d2cb --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_fx_revaluation_mode.rs @@ -0,0 +1,186 @@ +//! Postgres-only integration tests for Group B — the per-tenant FX +//! revaluation-mode repository (`FxRevaluationModeRepo`, VHP-1986). Exercises the +//! effective-dated read/write against a testcontainer Postgres through the REAL +//! `SecureORM` scoping: +//! +//! - no row ⇒ the fail-safe gear default (`ModeA`, revaluation off); +//! - `write_version` mints `0`, then a read in effect returns the written mode; +//! - two versions at different `effective_from` ⇒ the latest one whose +//! `effective_from <= now` wins; a future row is not yet in effect; +//! - a cross-tenant read is blocked at the SQL level (no leak of another +//! tenant's `ModeB`). +//! +//! Ignored by default (Docker/testcontainers); run with +//! `cargo test -p bss-ledger --test postgres_fx_revaluation_mode -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::panic +)] + +use bss_ledger::domain::fx::revaluation_mode::RevaluationMode; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::FxRevaluationModeRepo; +use chrono::{Duration, Utc}; +use sea_orm::Database; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +/// Boot a container, run the migration chain, and return a `bss`-search-path +/// `DBProvider` for the repo (the payments-test idiom). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, provider) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn no_row_defaults_to_mode_a_fail_safe() { + let (_c, provider) = boot().await; + let repo = FxRevaluationModeRepo::new(provider); + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + let mode = repo + .read_effective_mode(&scope, tenant, Utc::now()) + .await + .expect("read mode"); + assert_eq!(mode, None, "an un-configured tenant has no row"); + // The caller applies the fleet default; with the global flag off it is ModeA. + assert_eq!( + mode.unwrap_or(RevaluationMode::fleet_default(false)), + RevaluationMode::ModeA, + "un-configured + fleet-off resolves to the fail-safe ModeA" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn write_then_read_mode_b() { + let (_c, provider) = boot().await; + let repo = FxRevaluationModeRepo::new(provider); + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + let version = repo + .write_version( + &scope, + tenant, + RevaluationMode::ModeB, + Utc::now() - Duration::hours(1), + ) + .await + .expect("write ModeB"); + assert_eq!(version, 0, "the first version is 0"); + + let mode = repo + .read_effective_mode(&scope, tenant, Utc::now()) + .await + .expect("read mode"); + assert_eq!(mode, Some(RevaluationMode::ModeB)); + assert!(mode.expect("a row exists").revalues(), "ModeB revalues"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn latest_effective_version_wins_and_future_not_yet() { + let (_c, provider) = boot().await; + let repo = FxRevaluationModeRepo::new(provider); + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // v0: ModeB, effective 2h ago (superseded). + assert_eq!( + repo.write_version( + &scope, + tenant, + RevaluationMode::ModeB, + Utc::now() - Duration::hours(2), + ) + .await + .expect("v0"), + 0 + ); + // v1: ModeA, effective 1h ago (the one in effect now). + assert_eq!( + repo.write_version( + &scope, + tenant, + RevaluationMode::ModeA, + Utc::now() - Duration::hours(1), + ) + .await + .expect("v1"), + 1 + ); + // v2: ModeB, effective TOMORROW (not yet in effect). + assert_eq!( + repo.write_version( + &scope, + tenant, + RevaluationMode::ModeB, + Utc::now() + Duration::days(1), + ) + .await + .expect("v2"), + 2 + ); + + let mode = repo + .read_effective_mode(&scope, tenant, Utc::now()) + .await + .expect("read mode"); + assert_eq!( + mode, + Some(RevaluationMode::ModeA), + "the latest effective-now version (v1 ModeA) wins; the future v2 ModeB is not yet in effect" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_read_is_blocked_at_sql_level() { + let (_c, provider) = boot().await; + let repo = FxRevaluationModeRepo::new(provider); + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + + repo.write_version( + &AccessScope::for_tenant(tenant_a), + tenant_a, + RevaluationMode::ModeB, + Utc::now() - Duration::hours(1), + ) + .await + .expect("tenant A writes ModeB"); + + // Tenant B's scope attempts to read tenant A's mode: SQL-level BOLA yields no + // rows, so the read falls back to the fail-safe default — never A's ModeB. + let leaked = repo + .read_effective_mode(&AccessScope::for_tenant(tenant_b), tenant_a, Utc::now()) + .await + .expect("scoped read"); + assert_eq!( + leaked, None, + "a cross-tenant read yields no row — no leak of tenant A's ModeB" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_idempotency.rs b/gears/bss/ledger/ledger/tests/postgres_idempotency.rs new file mode 100644 index 000000000..903e5421a --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_idempotency.rs @@ -0,0 +1,159 @@ +//! Postgres-only: the idempotency gate. First `claim` of a key wins +//! (`Claimed`); after a `finalize` stamps the result entry, a second `claim` +//! of the same key returns `Replay` with a populated `result_entry_id`; a +//! `claim` with a different payload hash still returns the stored row (the +//! caller maps the hash mismatch to `IDEMPOTENCY_PAYLOAD_CONFLICT`). Ignored +//! by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::needless_pass_by_value +)] + +use bss_ledger::domain::model::RepoError; +use bss_ledger::infra::posting::idempotency::{ClaimOutcome, IdempotencyGate}; +use bss_ledger::infra::storage::migrations::Migrator; +use sea_orm::{ConnectionTrait, Database, DbErr, Statement, TransactionTrait}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Map a component `RepoError` into a `DbError` so the gate result can be the +/// transaction's typed success value (`T`), surviving COMMIT. +fn lift(e: RepoError) -> DbError { + DbError::Sea(DbErr::Custom(e.to_string())) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn claim_then_finalize_then_replay() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let entry_id = Uuid::now_v7(); + let flow = "MANUAL_ADJUSTMENT"; + let business_id = "biz-1"; + let hash_a = "a".repeat(64); + let hash_b = "b".repeat(64); + let gate = IdempotencyGate::new(); + + // First claim wins. + let claimed = provider + .transaction(|txn| { + let gate = gate.clone(); + let hash = hash_a.clone(); + Box::pin(async move { + gate.claim(txn, tenant, flow, business_id, &hash) + .await + .map_err(lift) + }) + }) + .await + .unwrap(); + assert!(matches!(claimed, ClaimOutcome::Claimed)); + + // Seed a journal entry so finalize references a real id (one txn so the + // deferred balance trigger sees both lines at COMMIT). + let seed = raw.begin().await.unwrap(); + seed.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, + source_doc_type, source_business_id, posted_at_utc, effective_at, + origin, posted_by_actor_id, correlation_id) + VALUES ('{entry_id}','{tenant}','{tenant}','202606','USD', + 'MANUAL_ADJUSTMENT','biz-1', now(), CURRENT_DATE, + 'SYSTEM','{tenant}','{tenant}')" + ))) + .await + .unwrap(); + for (side, class) in [("DR", "AR"), ("CR", "CASH_CLEARING")] { + let line = Uuid::now_v7(); + seed.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, + account_class, side, amount_minor, currency, currency_scale, mapping_status) + VALUES ('{line}','{entry_id}','{tenant}','202606','{tenant}','{tenant}', + '{class}','{side}', 1000, 'USD', 2, 'RESOLVED')" + ))) + .await + .unwrap(); + } + seed.commit().await.unwrap(); + + // Finalize the claim. + provider + .transaction(|txn| { + let gate = gate.clone(); + Box::pin(async move { + gate.finalize(txn, tenant, flow, business_id, entry_id, 1) + .await + .map_err(lift) + }) + }) + .await + .unwrap(); + + // Replay with the same hash → stored row with the populated entry id. + let replay_same = provider + .transaction(|txn| { + let gate = gate.clone(); + let hash = hash_a.clone(); + Box::pin(async move { + gate.claim(txn, tenant, flow, business_id, &hash) + .await + .map_err(lift) + }) + }) + .await + .unwrap(); + match replay_same { + ClaimOutcome::Replay(row) => { + assert_eq!(row.result_entry_id, Some(entry_id)); + assert_eq!(row.payload_hash, hash_a); + assert_eq!(row.status, "POSTED"); + } + ClaimOutcome::Claimed => panic!("expected a replay"), + } + + // Replay with a different hash → still the stored row (caller compares + // payload_hash to detect IDEMPOTENCY_PAYLOAD_CONFLICT). + let replay_diff = provider + .transaction(|txn| { + let gate = gate.clone(); + let hash = hash_b.clone(); + Box::pin(async move { + gate.claim(txn, tenant, flow, business_id, &hash) + .await + .map_err(lift) + }) + }) + .await + .unwrap(); + match replay_diff { + ClaimOutcome::Replay(row) => { + assert_eq!(row.payload_hash, hash_a, "stored hash is the original"); + } + ClaimOutcome::Claimed => panic!("expected a replay"), + } +} diff --git a/gears/bss/ledger/ledger/tests/postgres_inquiry.rs b/gears/bss/ledger/ledger/tests/postgres_inquiry.rs new file mode 100644 index 000000000..9c5590124 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_inquiry.rs @@ -0,0 +1,468 @@ +//! Postgres-only end-to-end for the audit-inquiry / drill reads + the audit-pack +//! CSV exporter (Slice 6 Phase 4 Group 4A). Boots a container, migrates, seeds a +//! seller (chart + USD@2 + an OPEN period), posts a couple of invoices through +//! the REAL `InvoicePostService`, then asserts: +//! (1) `filter_entries` by payer + period returns the posted entries; +//! (2) `export_csv` returns a CSV whose header + data-row count match, and a +//! field containing a comma is RFC-4180 quoted; +//! (3) `drill(entry_id)` returns the entry with its lines (and links a +//! reversal back to its original); +//! (4) a cross-tenant pack (`target_scope` set + investigation reason) writes +//! ONE `cross-tenant-access` forensic record (asserted via the same +//! `bss.secured_audit_record` query `postgres_cross_tenant.rs` uses); +//! the own-tenant default writes none. +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::authz::cross_tenant::{CrossTenantGateway, TargetScope}; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::inquiry::{AuditPackExporter, InquiryFilter, InquiryService}; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::NaiveDate; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Provisioned seller ids. +struct Seller { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + revenue: Uuid, + tax: Uuid, + suspense: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot, migrate, seed USD@2 + an OPEN period + AR/REVENUE/TAX/SUSPENSE. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue: Uuid::now_v7(), + tax: Uuid::now_v7(), + suspense: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.revenue, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + account( + s.tenant, + s.suspense, + AccountClass::Suspense, + Side::Credit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +fn revenue_item(amount: i64) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn tax_breakdown(amount: i64) -> TaxBreakdown { + TaxBreakdown { + amount_minor: amount, + currency: "USD".to_owned(), + tax_jurisdiction: "US-CA".to_owned(), + tax_filing_period: "2026Q2".to_owned(), + tax_rate_ref: None, + } +} + +fn invoice(s: &Seller, invoice_id: &str) -> PostedInvoice { + PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items: vec![revenue_item(1000)], + tax: vec![tax_breakdown(200)], + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + } +} + +fn svc(provider: &DBProvider, metrics: &MetricsHarness) -> InvoicePostService { + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(metrics.metrics()), + bss_ledger::config::RecognitionConfig::default(), + bss_ledger::config::FxConfig::default(), + ) +} + +/// (1) `filter_entries` by payer + period returns the posted entries; (2) +/// `export_csv` header + row_count match and an injected comma is RFC-4180 +/// quoted; (3) `drill` returns the entry with its lines. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn filter_export_and_drill() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let inv1 = invoice(&s, "INV-100"); + let inv2 = invoice(&s, "INV-200"); + let p1 = service + .post_invoice(&ctx, &scope, &inv1, true) + .await + .expect("post 1"); + let p2 = service + .post_invoice(&ctx, &scope, &inv2, true) + .await + .expect("post 2"); + + let inquiry = InquiryService::new(provider.clone()); + + // (1) Filter by payer + period: both posted entries come back. + let filter = InquiryFilter { + payer_tenant_id: Some(s.payer), + period_id: Some(s.period_id.clone()), + account_class: None, + legal_entity_id: None, + }; + let rows = inquiry + .filter_entries(&scope, &filter) + .await + .expect("filter_entries"); + let ids: std::collections::HashSet = rows.iter().map(|r| r.entry_id).collect(); + assert!(ids.contains(&p1.entry_id), "INV-100 entry is in the filter"); + assert!(ids.contains(&p2.entry_id), "INV-200 entry is in the filter"); + assert_eq!(rows.len(), 2, "exactly the two posted entries"); + + // A foreign-tenant scope sees nothing (SQL-level BOLA). + let foreign = AccessScope::for_tenant(Uuid::now_v7()); + let none = inquiry + .filter_entries(&foreign, &filter) + .await + .expect("filter_entries (foreign)"); + assert!(none.is_empty(), "a foreign scope returns no entries"); + + // Filter by a wrong account class returns nothing. + let wrong_class = InquiryFilter { + payer_tenant_id: None, + period_id: None, + account_class: Some("EQUITY".to_owned()), + legal_entity_id: None, + }; + let empty = inquiry + .filter_entries(&scope, &wrong_class) + .await + .expect("filter_entries (wrong class)"); + assert!(empty.is_empty(), "no entry carries an EQUITY line"); + + // (2) Export CSV: header + one row per (entry, line). Each invoice posts 3 + // lines (AR debit + Revenue credit + Tax credit), so 2 entries => 6 rows. + let exporter = AuditPackExporter::new(provider.clone()); + let (csv, row_count) = exporter + .export_csv(&scope, &filter) + .await + .expect("export_csv"); + let mut lines = csv.lines(); + let header = lines.next().expect("a header row"); + assert_eq!(header.split(',').count(), 22, "the header has 22 columns"); + assert!( + header.starts_with("entry_id,tenant_id,period_id"), + "header order" + ); + let body_rows = csv.lines().count() - 1; + assert_eq!(row_count, 6, "two 3-line entries => 6 data rows"); + assert_eq!(body_rows, row_count, "row_count matches the CSV body rows"); + + // RFC-4180 quoting: post an invoice whose business id carries a comma, then + // assert the CSV quotes that field. `source_business_id` (= the invoice id) + // is an exported column and is free text, so it can carry a comma — unlike + // `revenue_stream`, which is a chart-of-accounts join key and must match a + // provisioned account. + let commad = invoice(&s, "INV,COMMA"); + service + .post_invoice(&ctx, &scope, &commad, true) + .await + .expect("post comma invoice"); + let (csv2, _) = exporter + .export_csv(&scope, &filter) + .await + .expect("export_csv (comma)"); + assert!( + csv2.contains("\"INV,COMMA\""), + "a comma-bearing field is RFC-4180 quoted; csv2 = {csv2}" + ); + + // (3) Drill into INV-100's entry: header + its 3 lines, no links. + let drill = inquiry + .drill(&scope, s.tenant, p1.entry_id) + .await + .expect("drill") + .expect("entry found"); + assert_eq!(drill.entry.entry_id, p1.entry_id, "drill header"); + assert_eq!( + drill.entry.source_business_id, "INV-100", + "source business id" + ); + assert_eq!(drill.lines.len(), 3, "AR + Revenue + Tax lines"); + assert!(drill.linked.is_empty(), "an un-reversed entry has no links"); + + // A foreign-scope drill yields None (no existence leak). + let none = inquiry + .drill(&foreign, s.tenant, p1.entry_id) + .await + .expect("drill (foreign)"); + assert!(none.is_none(), "a foreign scope cannot drill the entry"); + + // Sanity: the lines really were written for our payer. + let line_count = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_line \ + WHERE tenant_id='{}' AND payer_tenant_id='{}'", + s.tenant, s.payer + ), + ) + .await; + assert!(line_count >= 6, "at least the two invoices' lines exist"); +} + +/// (4) A cross-tenant pack (target_scope set + reason) writes ONE +/// `cross-tenant-access` forensic record; the own-tenant default writes none. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_pack_writes_forensic_record() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let home = Uuid::now_v7(); + let exporter = AuditPackExporter::new(provider.clone()); + let gateway = CrossTenantGateway::new(); + let filter = InquiryFilter::default(); + + // Own-tenant pack (target == home): NO forensic record, scope = home. + provider + .transaction({ + let exporter = exporter.clone(); + let gateway = gateway.clone(); + let filter = filter.clone(); + move |txn| { + let exporter = exporter.clone(); + let gateway = gateway.clone(); + let filter = filter.clone(); + Box::pin(async move { + let scope = gateway + .resolve_read_scope( + txn, + home, + Some(TargetScope { tenant_id: home }), + true, + "actor-own", + Some("reason"), + Some("ROUTINE"), + None, + ) + .await?; + exporter + .export_csv_in_txn(txn, &scope, &filter) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string())))?; + Ok::<_, DbError>(()) + }) + } + }) + .await + .expect("own-tenant pack"); + + let own_records = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{home}' AND event_type='cross-tenant-access'" + ), + ) + .await; + assert_eq!( + own_records, 0, + "an own-tenant pack writes NO forensic record" + ); + + // Cross-tenant pack (target = the seller tenant): ONE forensic record under + // HOME, the export reads the target tenant's rows. + let target = s.tenant; + let (_csv, _rows) = provider + .transaction({ + let exporter = exporter.clone(); + let gateway = gateway.clone(); + let filter = filter.clone(); + move |txn| { + let exporter = exporter.clone(); + let gateway = gateway.clone(); + let filter = filter.clone(); + Box::pin(async move { + let scope = gateway + .resolve_read_scope( + txn, + home, + Some(TargetScope { tenant_id: target }), + true, + "investigator-9", + Some("fraud investigation #7"), + Some("FRAUD"), + None, + ) + .await?; + exporter + .export_csv_in_txn(txn, &scope, &filter) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + } + }) + .await + .expect("cross-tenant pack"); + + let cross_records = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{home}' AND event_type='cross-tenant-access'" + ), + ) + .await; + assert_eq!( + cross_records, 1, + "a cross-tenant pack writes ONE forensic record under home" + ); + // The record names the target tenant in its before_after payload. + let target_in_payload = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{home}' AND event_type='cross-tenant-access' \ + AND before_after->'targetScope'->>'tenantId'='{target}'" + ), + ) + .await; + assert_eq!(target_in_payload, 1, "the record names the target tenant"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_invoice_post.rs b/gears/bss/ledger/ledger/tests/postgres_invoice_post.rs new file mode 100644 index 000000000..628a6532f --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_invoice_post.rs @@ -0,0 +1,1190 @@ +//! Postgres-only integration: the invoice-post domain driven through the REAL +//! foundation engine (`InvoicePostService` over `PostingService`). +//! +//! Provisions a seller (AR / Revenue / Tax / Suspense chart + USD@2 + an OPEN +//! period), then: +//! - posts a balanced invoice and asserts the AR-debit / Revenue+Tax-credit +//! cache balances + the emitted `invoice_post` / duration metrics; +//! - rejects a closed-payer post (`PAYER_CLOSED`) while a reversal of an +//! already-posted invoice still posts; +//! - rejects a Revenue line missing `revenue_stream` at the foundation +//! invariant (the `chk_journal_line_revenue_stream` DB CHECK); +//! - blocks a period close while a `SUSPENSE`/`PENDING` line is open; +//! - replays a `MAPPING_CORRECTION` re-post idempotently; +//! - rejects a reverse-of-a-reversal. +//! +//! `LedgerLocalClient::new` is `pub(crate)`, so this out-of-crate test drives +//! the `pub` `InvoicePostService` / `PostingService` directly (mirrors +//! `postgres_posting.rs`). Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::type_complexity +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::invoice::reversal::build_reversal; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::period_close::PeriodCloseService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, EntryView, LineView, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Provisioned seller ids. +struct Seller { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + revenue: Uuid, + tax: Uuid, + suspense: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot, migrate, seed USD@2 + an OPEN period + AR/REVENUE(subscription)/TAX/ +/// SUSPENSE accounts. Returns the migrate connection, the search_path-scoped +/// provider, and the seller ids. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue: Uuid::now_v7(), + tax: Uuid::now_v7(), + suspense: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.revenue, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + account( + s.tenant, + s.suspense, + AccountClass::Suspense, + Side::Credit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +/// One ex-tax `subscription` item mapped to REVENUE via the Catalog class. +fn revenue_item(amount: i64) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn tax_breakdown(amount: i64) -> TaxBreakdown { + TaxBreakdown { + amount_minor: amount, + currency: "USD".to_owned(), + tax_jurisdiction: "US-CA".to_owned(), + tax_filing_period: "2026Q2".to_owned(), + tax_rate_ref: None, + } +} + +fn invoice( + s: &Seller, + invoice_id: &str, + items: Vec, + tax: Vec, +) -> PostedInvoice { + PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items, + tax, + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + } +} + +fn svc(provider: &DBProvider, metrics: &MetricsHarness) -> InvoicePostService { + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(metrics.metrics()), + RecognitionConfig::default(), + FxConfig::default(), + ) +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn posts_balanced_invoice_and_emits_metrics() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // DR AR 1200 / CR Revenue 1000 / CR Tax 200. + let inv = invoice( + &s, + "INV-1", + vec![revenue_item(1000)], + vec![tax_breakdown(200)], + ); + let posted = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("invoice post must succeed"); + assert!(!posted.replayed, "first post is fresh"); + + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(1200), + "AR debit = gross 1200" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(1000), + "Revenue credit = 1000" + ); + assert_eq!(bal(&raw, &s, s.tax).await, Some(200), "Tax credit = 200"); + + // Metrics: one posted attempt + one duration sample. + harness.force_flush(); + assert_eq!( + harness.counter_value( + "ledger_invoice_post_total", + &[("result", "posted"), ("flow", "invoice_post")] + ), + 1, + "one posted invoice-post counted" + ); + assert_eq!( + harness.histogram_count( + "ledger_invoice_post_duration_seconds", + &[("flow", "invoice_post")] + ), + 1, + "one duration sample recorded" + ); + + // Re-post the same invoice ⇒ idempotent replay (no new ledger effect). + let replay = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("replay must succeed"); + assert!(replay.replayed, "second identical post replays"); + assert_eq!( + replay.entry_id, posted.entry_id, + "replay returns the prior id" + ); + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(1200), + "AR unchanged on replay" + ); + harness.force_flush(); + assert_eq!( + harness.counter_value( + "ledger_invoice_post_total", + &[("result", "replayed"), ("flow", "invoice_post")] + ), + 1, + "the replay is counted as replayed" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn closed_payer_is_rejected_but_a_reversal_still_posts() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // 1. Post a normal invoice (payer open) so there is something to reverse. + let inv = invoice( + &s, + "INV-2", + vec![revenue_item(1000)], + vec![tax_breakdown(200)], + ); + let original = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("the original post must succeed"); + + // 2. A NEW invoice for a CLOSED payer is rejected with PAYER_CLOSED, no + // ledger effect. `payer_open: false` is injected directly because the gear + // has NO payer-state reader by design — this exercises the service's + // fast-path payer gate. The DB account-lifecycle guard (a CLOSED AR account + // rejected at post time) is the complementary authority, covered by + // `closed_ar_account_post_is_rejected_at_the_db_guard`. + let inv2 = invoice(&s, "INV-3", vec![revenue_item(500)], vec![]); + let err = service + .post_invoice(&ctx, &scope, &inv2, false) + .await + .expect_err("a closed-payer post must be rejected"); + assert!(matches!(err, DomainError::PayerClosed(_)), "got {err:?}"); + harness.force_flush(); + assert_eq!( + harness.counter_value( + "ledger_invoice_post_total", + &[("result", "rejected"), ("flow", "invoice_post")] + ), + 1, + "the rejected post is counted" + ); + + // 3. A reversal of the ALREADY-POSTED invoice still posts even though the + // payer is closed (the reversal path bypasses the payer gate). Build the + // reversal from the original's read-back view. + let view = original_view( + &s, + original.entry_id, + &[ + ( + s.ar, + AccountClass::Ar, + Side::Debit, + 1200, + Some("INV-2"), + None, + ), + ( + s.revenue, + AccountClass::Revenue, + Side::Credit, + 1000, + Some("INV-2"), + Some("subscription"), + ), + ( + s.tax, + AccountClass::TaxPayable, + Side::Credit, + 200, + Some("INV-2"), + None, + ), + ], + ); + let reversal = build_reversal( + &view, + s.period_id.clone(), + naive(2026, 6, 2), + s.tenant, + s.tenant, + ) + .expect("reversal of an invoice-post must build"); + service + .post_reversal(&ctx, &scope, reversal, None) + .await + .expect("the reversal must post for a closed payer"); + + // The reversal nets AR/Revenue/Tax back to zero. + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(0), + "AR nets to zero after reversal" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(0), + "Revenue nets to zero" + ); + assert_eq!(bal(&raw, &s, s.tax).await, Some(0), "Tax nets to zero"); +} + +/// Companion to `closed_payer_is_rejected_but_a_reversal_still_posts`: the REST +/// seam hardcodes `payer_open=true` (no payer-state reader), so the REAL +/// authority for a genuinely closed counterparty is the foundation account- +/// lifecycle invariant. Provision the chart, CLOSE the AR account, then post a +/// normal invoice through the full `post_invoice` path (payer gate OPEN). The DB +/// guard reads the AR account back and rejects the post with `AccountClosed` — +/// exercising the read → gate chain end-to-end, not the `payer_open` flag. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn closed_ar_account_post_is_rejected_at_the_db_guard() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Close the provisioned AR account (the post's DR leg targets it). The chart + // of accounts is `bss.ledger_tenant_account`; `find_account` reads lifecycle_state + // from it during the post. + raw.execute(pg(format!( + "UPDATE bss.ledger_tenant_account SET lifecycle_state='CLOSED' \ + WHERE tenant_id='{}' AND account_id='{}'", + s.tenant, s.ar + ))) + .await + .unwrap(); + + // Post a normal invoice with the payer gate OPEN: the fast-path flag passes, + // and the DB account-lifecycle guard is the one that rejects. + let inv = invoice(&s, "INV-CLOSED-AR", vec![revenue_item(1000)], vec![]); + let err = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect_err("a post to a CLOSED AR account must be rejected by the DB guard"); + assert!( + matches!(err, DomainError::AccountClosed(_)), + "expected AccountClosed from the lifecycle guard, got {err:?}" + ); + + // No ledger effect: the AR balance row was never written. + assert_eq!( + bal(&raw, &s, s.ar).await, + None, + "a rejected closed-account post leaves no AR balance" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn revenue_line_missing_revenue_stream_is_rejected_by_the_foundation() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, provider, s) = setup(&url).await; + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A balanced DR AR / CR REVENUE entry whose Revenue line carries NO + // revenue_stream — the chk_journal_line_revenue_stream DB CHECK must reject + // it at COMMIT (the foundation invariant), not silently accept it. + let (entry, lines) = bad_revenue_entry(&s); + let err = posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect_err("a Revenue line without a revenue_stream must be rejected"); + // The DB CHECK surfaces as an Internal-mapped fault (a constraint the gear + // does not pre-validate); assert the post did NOT succeed and named the + // revenue-stream constraint. + let msg = format!("{err:?}"); + assert!( + msg.contains("revenue_stream") || matches!(err, DomainError::Internal(_)), + "expected a revenue_stream constraint rejection, got {err:?}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn a_pending_suspense_line_blocks_period_close() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // An invoice whose single item has NO mapping ⇒ routes to SUSPENSE/PENDING. + let mut unmapped = revenue_item(1000); + unmapped.catalog_class = None; + unmapped.contract_class = None; + let inv = invoice(&s, "INV-SUS", vec![unmapped], vec![]); + service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("the suspense post itself succeeds (the line is parked, not rejected)"); + + // The suspense gauge was emitted for the parked line. + harness.force_flush(); + assert_eq!( + harness.counter_value( + "ledger_invoice_post_total", + &[("result", "posted"), ("flow", "invoice_post")] + ), + 1 + ); + + // Closing the period must now FAIL the pre-close tie-out (a PENDING line is + // a soft defect that blocks a clean close). + let close = PeriodCloseService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + std::sync::Arc::new( + bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new(), + ), + ); + let err = close + .close( + &SecurityContext::anonymous(), + s.tenant, + s.tenant, + s.period_id.clone(), + ) + .await + .expect_err("a PENDING suspense line must block the close"); + // Group B unified the gate: a PENDING mapping line is a tie-out defect surfaced + // as one accumulated blocked reason on `PeriodCloseBlocked` (design §4.5), not a + // bare `PreCloseTieOutFailed`. + assert!( + matches!(&err, DomainError::PeriodCloseBlocked(d) if d.contains("tie-out")), + "got {err:?}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn mapping_correction_retry_replays_idempotently() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Post an original invoice, then a MAPPING_CORRECTION re-post (a corrected + // re-book keyed on invoice_id:correction_id). Posting the SAME correction + // twice must replay — exactly one correction entry persists. + let inv = invoice(&s, "INV-MC", vec![revenue_item(1000)], vec![]); + let original = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("original post"); + + // A reversal first clears the original (so AR nets out before the re-book). + let view = original_view( + &s, + original.entry_id, + &[ + ( + s.ar, + AccountClass::Ar, + Side::Debit, + 1000, + Some("INV-MC"), + None, + ), + ( + s.revenue, + AccountClass::Revenue, + Side::Credit, + 1000, + Some("INV-MC"), + Some("subscription"), + ), + ], + ); + let reversal = build_reversal( + &view, + s.period_id.clone(), + naive(2026, 6, 2), + s.tenant, + s.tenant, + ) + .expect("reversal builds"); + let reversal_ref = service + .post_reversal(&ctx, &scope, reversal, None) + .await + .expect("reversal posts"); + + // The corrected re-post, posted directly through the engine (a balanced + // DR AR / CR Revenue), keyed MAPPING_CORRECTION on invoice_id:correction_id. + let correction = bss_ledger::domain::invoice::reversal::correction_id( + original.entry_id, + reversal_ref.entry_id, + ); + let business_id = format!("INV-MC:{correction}"); + let (e1, l1) = correction_entry(&s, &business_id, original.entry_id, reversal_ref.entry_id); + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let first = posting + .post(&ctx, &scope, e1, l1, None) + .await + .expect("the correction posts"); + assert!(!first.replayed); + + // Retry the SAME correction (same business id + payload) ⇒ replay. + let (e2, l2) = correction_entry(&s, &business_id, original.entry_id, reversal_ref.entry_id); + let retry = posting + .post(&ctx, &scope, e2, l2, None) + .await + .expect("the correction retry replays"); + assert!(retry.replayed, "the retried correction must replay"); + assert_eq!(retry.entry_id, first.entry_id); + + // Exactly one MAPPING_CORRECTION entry persists for the business key. + let count = scalar_i64( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_business_id='{}'", + s.tenant, business_id + ), + ) + .await + .unwrap_or(0); + assert_eq!(count, 1, "exactly one correction entry for the key"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reverse_of_a_reversal_is_rejected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Post + reverse an invoice, then read the reversal back and attempt to + // reverse IT — the domain must reject a reverse-of-a-reversal. + let inv = invoice(&s, "INV-RR", vec![revenue_item(1000)], vec![]); + let original = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("original post"); + let orig_view = original_view( + &s, + original.entry_id, + &[ + ( + s.ar, + AccountClass::Ar, + Side::Debit, + 1000, + Some("INV-RR"), + None, + ), + ( + s.revenue, + AccountClass::Revenue, + Side::Credit, + 1000, + Some("INV-RR"), + Some("subscription"), + ), + ], + ); + let reversal = build_reversal( + &orig_view, + s.period_id.clone(), + naive(2026, 6, 2), + s.tenant, + s.tenant, + ) + .expect("reversal builds"); + let reversal_ref = service + .post_reversal(&ctx, &scope, reversal, None) + .await + .expect("reversal posts"); + + // Build a view of the REVERSAL (source_doc_type = REVERSAL) and try to + // reverse it — CannotReverseReversal. + let mut reversal_view = original_view( + &s, + reversal_ref.entry_id, + &[ + ( + s.ar, + AccountClass::Ar, + Side::Credit, + 1000, + Some("INV-RR"), + None, + ), + ( + s.revenue, + AccountClass::Revenue, + Side::Debit, + 1000, + Some("INV-RR"), + Some("subscription"), + ), + ], + ); + reversal_view.source_doc_type = SourceDocType::Reversal; + let err = build_reversal( + &reversal_view, + s.period_id.clone(), + naive(2026, 6, 3), + s.tenant, + s.tenant, + ) + .expect_err("reversing a reversal must be rejected"); + assert_eq!( + err, + bss_ledger::domain::invoice::reversal::ReversalError::CannotReverseReversal + ); +} + +/// Concurrency (idempotency #1): two posts of the SAME invoice (same +/// `invoiceId`) racing on two service clones must land EXACTLY ONE ledger +/// effect — the `INVOICE_POST` dedup key `(tenant, source_business_id = +/// invoiceId)` admits one winner; the loser replays the winner's finalized id. +/// No duplicate journal entry, and no absent/nil ref on the replay path. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_same_invoice_posts_exactly_once() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + + // Two services over the SAME provider, posting the SAME invoice concurrently. + let svc_a = svc(&provider, &harness); + let svc_b = svc(&provider, &harness); + let scope_a = AccessScope::for_tenant(s.tenant); + let scope_b = AccessScope::for_tenant(s.tenant); + let ctx_a = SecurityContext::anonymous(); + let ctx_b = SecurityContext::anonymous(); + let inv_a = invoice( + &s, + "INV-CONC", + vec![revenue_item(1000)], + vec![tax_breakdown(200)], + ); + let inv_b = invoice( + &s, + "INV-CONC", + vec![revenue_item(1000)], + vec![tax_breakdown(200)], + ); + + let (ra, rb) = tokio::join!( + async move { svc_a.post_invoice(&ctx_a, &scope_a, &inv_a, true).await }, + async move { svc_b.post_invoice(&ctx_b, &scope_b, &inv_b, true).await }, + ); + + // Exactly one journal entry persists for the shared invoice id. + let entry_count = scalar_i64( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_business_id='INV-CONC'", + s.tenant + ), + ) + .await + .unwrap_or(0); + assert_eq!( + entry_count, 1, + "exactly one ledger effect for the shared invoice id" + ); + + // At least one side succeeds; whichever succeed share the one finalized id + // and never the nil UUID (the loser replays the winner's ref). + let oks = i32::from(ra.is_ok()) + i32::from(rb.is_ok()); + assert!( + oks >= 1, + "at least one concurrent invoice post must succeed: {ra:?} / {rb:?}" + ); + let ids: Vec = [&ra, &rb] + .into_iter() + .filter_map(|r| r.as_ref().ok()) + .map(|p| p.entry_id) + .collect(); + for id in &ids { + assert_ne!( + *id, + Uuid::nil(), + "a successful post/replay must carry a real entry id" + ); + } + if let [a, b] = ids.as_slice() { + assert_eq!( + a, b, + "the fresh post and the replay reference the same entry" + ); + } + + // The single posted effect moved AR exactly once (gross 1200, not 2400). + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(1200), + "AR reflects exactly one post, not a double charge" + ); +} + +/// Concurrency (at-most-once-per-entry reversal): two reversals of the SAME +/// posted entry racing must land EXACTLY ONE reversal — the `REVERSAL` dedup key +/// `(tenant, source_business_id = "reverses=")` admits one winner; +/// the loser replays it (or is rejected). The original nets back to zero exactly +/// once — never double-reversed past zero. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_reversals_of_one_entry_post_exactly_once() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Post an original invoice (DR AR 1200 / CR Revenue 1000 / CR Tax 200). + let inv = invoice( + &s, + "INV-REVRACE", + vec![revenue_item(1000)], + vec![tax_breakdown(200)], + ); + let original = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("original post"); + + // Build TWO reversals of the SAME original: each carries its own fresh + // entry_id but the SAME source_business_id ("reverses="), so the + // dedup key collides — exactly one may persist. + let view = original_view( + &s, + original.entry_id, + &[ + ( + s.ar, + AccountClass::Ar, + Side::Debit, + 1200, + Some("INV-REVRACE"), + None, + ), + ( + s.revenue, + AccountClass::Revenue, + Side::Credit, + 1000, + Some("INV-REVRACE"), + Some("subscription"), + ), + ( + s.tax, + AccountClass::TaxPayable, + Side::Credit, + 200, + Some("INV-REVRACE"), + None, + ), + ], + ); + let reversal_a = build_reversal( + &view, + s.period_id.clone(), + naive(2026, 6, 2), + s.tenant, + s.tenant, + ) + .expect("reversal A builds"); + let reversal_b = build_reversal( + &view, + s.period_id.clone(), + naive(2026, 6, 2), + s.tenant, + s.tenant, + ) + .expect("reversal B builds"); + let business_id = reversal_a.source_business_id.clone(); + assert_eq!( + business_id, reversal_b.source_business_id, + "both reversals key on the same reverses= business id" + ); + + let svc_a = svc(&provider, &harness); + let svc_b = svc(&provider, &harness); + let scope_a = scope.clone(); + let scope_b = scope.clone(); + let ctx_a = SecurityContext::anonymous(); + let ctx_b = SecurityContext::anonymous(); + let (ra, rb) = tokio::join!( + async move { + svc_a + .post_reversal(&ctx_a, &scope_a, reversal_a, None) + .await + }, + async move { + svc_b + .post_reversal(&ctx_b, &scope_b, reversal_b, None) + .await + }, + ); + + // Exactly one REVERSAL entry persists for the shared key (at most once per + // reversed entry). + let reversal_count = scalar_i64( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_business_id='{}'", + s.tenant, business_id + ), + ) + .await + .unwrap_or(0); + assert_eq!( + reversal_count, 1, + "exactly one reversal persists for the reversed entry" + ); + + // At least one side made progress; the loser replayed or was rejected, never + // a second ledger effect. + let oks = i32::from(ra.is_ok()) + i32::from(rb.is_ok()); + assert!( + oks >= 1, + "at least one concurrent reversal must succeed: {ra:?} / {rb:?}" + ); + + // The original nets back to zero EXACTLY once — a double reversal would drive + // the guarded AR balance negative (and be rejected), so a clean zero proves + // the second reversal did not apply a second effect. + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(0), + "AR nets to zero after exactly one reversal" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(0), + "Revenue nets to zero after exactly one reversal" + ); + assert_eq!( + bal(&raw, &s, s.tax).await, + Some(0), + "Tax nets to zero after exactly one reversal" + ); +} + +// ── Test builders ────────────────────────────────────────────────────────── + +/// Synthesize the `EntryView` of a posted entry from the known posted lines +/// (account_id, class, side, amount, invoice_id, revenue_stream). Lets the test +/// drive `build_reversal` end-to-end without a private read-back mapper. +fn original_view( + s: &Seller, + entry_id: Uuid, + lines: &[(Uuid, AccountClass, Side, i64, Option<&str>, Option<&str>)], +) -> EntryView { + let now: DateTime = Utc::now(); + EntryView { + entry_id, + tenant_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "INV".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: now, + effective_at: naive(2026, 6, 1), + posted_by_actor_id: s.tenant, + origin: "SYSTEM".to_owned(), + correlation_id: s.tenant, + created_seq: 1, + lines: lines + .iter() + .map(|(account, class, side, amount, invoice, stream)| { + // Faithful read-back: a posted TAX_PAYABLE line always carries its + // filing dims (the chk_journal_line_tax_dims CHECK guarantees it), + // so the synthesized view must too — build_reversal copies them + // onto the reversing TAX line, which must satisfy the same CHECK. + let is_tax = *class == AccountClass::TaxPayable; + LineView { + line_id: Uuid::now_v7(), + entry_id, + payer_tenant_id: s.payer, + account_id: *account, + account_class: *class, + gl_code: None, + side: *side, + amount_minor: *amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: invoice.map(str::to_owned), + due_date: invoice.map(|_| naive(2026, 7, 1)), + revenue_stream: stream.map(str::to_owned), + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: is_tax.then(|| "US-CA".to_owned()), + tax_filing_period: is_tax.then(|| "2026Q2".to_owned()), + ar_status: None, + } + }) + .collect(), + } +} + +/// A balanced DR AR / CR REVENUE entry whose Revenue line OMITS revenue_stream +/// (to trip the foundation `chk_journal_line_revenue_stream` CHECK). +fn bad_revenue_entry(s: &Seller) -> (NewEntry, Vec) { + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "INV-BAD".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: naive(2026, 6, 1), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + line( + s, + s.ar, + AccountClass::Ar, + Side::Debit, + 1000, + Some("INV-BAD"), + None, + ), + // Revenue line with revenue_stream = None — the CHECK must reject this. + line( + s, + s.revenue, + AccountClass::Revenue, + Side::Credit, + 1000, + None, + None, + ), + ]; + (entry, lines) +} + +/// A balanced MAPPING_CORRECTION re-post (DR AR / CR Revenue), keyed on the +/// supplied business id and pointing back at the reversal it follows. +fn correction_entry( + s: &Seller, + business_id: &str, + original_entry_id: Uuid, + reversal_entry_id: Uuid, +) -> (NewEntry, Vec) { + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::MappingCorrection, + source_business_id: business_id.to_owned(), + reverses_entry_id: Some(reversal_entry_id), + reverses_period_id: Some(s.period_id.clone()), + posted_at_utc: Utc::now(), + effective_at: naive(2026, 6, 2), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: original_entry_id, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + line( + s, + s.ar, + AccountClass::Ar, + Side::Debit, + 1000, + Some("INV-MC"), + None, + ), + line( + s, + s.revenue, + AccountClass::Revenue, + Side::Credit, + 1000, + Some("INV-MC"), + Some("subscription"), + ), + ]; + (entry, lines) +} + +#[allow(clippy::too_many_arguments)] +fn line( + s: &Seller, + account: Uuid, + class: AccountClass, + side: Side, + amount: i64, + invoice_id: Option<&str>, + revenue_stream: Option<&str>, +) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: invoice_id.map(str::to_owned), + due_date: invoice_id.map(|_| naive(2026, 7, 1)), + revenue_stream: revenue_stream.map(str::to_owned), + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} diff --git a/gears/bss/ledger/ledger/tests/postgres_invoice_post_fx.rs b/gears/bss/ledger/ledger/tests/postgres_invoice_post_fx.rs new file mode 100644 index 000000000..86e719a9c --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_invoice_post_fx.rs @@ -0,0 +1,312 @@ +//! Postgres-only integration (Slice 5, D1): a **cross-currency** invoice driven +//! through the REAL foundation engine (`InvoicePostService`) with the S1 FX lock +//! live. +//! +//! A USD-**functional** seller (the functional currency seeded on its +//! `fiscal_calendar`, S5-F3) invoices in **EUR**. With an EUR→USD rate in the +//! local store, the S1 hook resolves + snapshots the rate, stamps the functional +//! translation on every line, and the dual-column commit trigger accepts the post +//! ONLY because the functional column balances. Asserts: every line carries +//! `functional_amount_minor` + `functional_currency = USD` + a non-null +//! `rate_snapshot_ref` (one snapshot per entry), the functional column nets to +//! zero, and the immutable `fx_rate_snapshot` row was frozen. +//! +//! Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{FxRepo, NewFxRate, ReferenceRepo}; +use bss_ledger_sdk::AccountClass; +use chrono::{NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// An EUR chart account (the invoice's transaction currency). +fn eur_account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: &str, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "EUR".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_currency_invoice_stamps_functional_and_balances_both_columns() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let provider = + DBProvider::::new(connect_db(&repo_url, ConnectOpts::default()).await.unwrap()); + + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let ar = Uuid::now_v7(); + let revenue = Uuid::now_v7(); + let tax = Uuid::now_v7(); + let period_id = "202606"; + + let reference = ReferenceRepo::new(provider.clone()); + // Both the transaction (EUR) and functional (USD) scales must be registered. + for ccy in ["EUR", "USD"] { + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: ccy.to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + } + // The S5-F3 functional-currency source: a fiscal_calendar row carrying USD. + // This is what ACTIVATES the S1 FX lock for this tenant (absent → single-ccy). + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_calendar + (tenant_id, legal_entity_id, fiscal_tz, granularity, fy_start_month, functional_currency) + VALUES ('{tenant}','{tenant}','UTC','MONTH',1,'USD')" + ))) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{tenant}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + for row in [ + eur_account(tenant, ar, AccountClass::Ar, "DR", None), + eur_account( + tenant, + revenue, + AccountClass::Revenue, + "CR", + Some("subscription"), + ), + eur_account(tenant, tax, AccountClass::TaxPayable, "CR", None), + ] { + reference.insert_account(row).await.unwrap(); + } + + // Seed the EUR→USD rate (1.10) in the local store — what the RateSyncJob / + // ingest endpoint would refresh; the lock-time RateSource reads it locally. + FxRepo::new(provider.clone()) + .upsert_rate(&NewFxRate { + tenant_id: tenant, + base_currency: "EUR".to_owned(), + quote_currency: "USD".to_owned(), + provider: "ecb".to_owned(), + rate_micro: 1_100_000, + as_of: Utc::now(), + fallback_order: 0, + }) + .await + .unwrap(); + + // FxConfig with "ecb" in the provider order so RateSource resolves it. + let fx_config = FxConfig { + provider_order: vec!["ecb".to_owned()], + ..FxConfig::default() + }; + let harness = MetricsHarness::new(); + let service = InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + RecognitionConfig::default(), + fx_config, + ); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(tenant); + + // An EUR invoice: 10.00 ex-tax (1000 minor) + 2.00 tax (200) = 12.00 gross AR. + let inv = PostedInvoice { + invoice_id: "INV-FX-1".to_owned(), + payer_tenant_id: payer, + resource_tenant_id: None, + seller_tenant_id: tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: period_id.to_owned(), + items: vec![InvoiceItem { + amount_minor_ex_tax: 1000, + deferred_minor: 0, + currency: "EUR".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }], + tax: vec![TaxBreakdown { + amount_minor: 200, + currency: "EUR".to_owned(), + tax_jurisdiction: "US-CA".to_owned(), + tax_filing_period: "2026Q2".to_owned(), + tax_rate_ref: None, + }], + posted_by_actor_id: tenant, + correlation_id: tenant, + }; + + // The post SUCCEEDS only because the functional column balances under the + // dual-column commit trigger — that is itself a core assertion. + let posted = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("cross-currency invoice must post (both columns balanced)"); + assert!(!posted.replayed, "first post is fresh"); + + let where_t = format!("WHERE tenant_id='{tenant}'"); + + // Transaction column unchanged: AR 1200 DR = Revenue 1000 + Tax 200 CR. + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_amount_minor FROM bss.ledger_journal_line {where_t} AND account_id='{ar}'" + )).await, + Some(1_320), + "AR functional = 1200 EUR * 1.10 = 13.20 USD (1320 minor)" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_amount_minor FROM bss.ledger_journal_line {where_t} AND account_id='{revenue}'" + )).await, + Some(1_100), + "Revenue functional = 1000 EUR * 1.10 = 11.00 USD" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_amount_minor FROM bss.ledger_journal_line {where_t} AND account_id='{tax}'" + )).await, + Some(220), + "Tax functional = 200 EUR * 1.10 = 2.20 USD" + ); + + // Every line stamped functional USD; none left NULL. + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_line {where_t} AND functional_currency='USD'" + )).await, + Some(3), + "all three lines carry functional_currency = USD" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_line {where_t} AND functional_amount_minor IS NULL" + )).await, + Some(0), + "no line is left functional-NULL on a cross-currency entry (all-or-nothing)" + ); + + // The functional column nets to zero (DR == CR). + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT COALESCE(SUM(CASE WHEN side='DR' THEN functional_amount_minor ELSE -functional_amount_minor END), 0)::bigint \ + FROM bss.ledger_journal_line {where_t}" + )).await, + Some(0), + "functional column balances (DR == CR)" + ); + + // One rate snapshot per entry, stamped on every line (rate_snapshot_ref). + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_line {where_t} AND rate_snapshot_ref IS NOT NULL" + )).await, + Some(3), + "every line carries the entry's rate_snapshot_ref" + ); + assert_eq!( + scalar_i64( + &raw, + &format!( + "SELECT count(DISTINCT rate_snapshot_ref) FROM bss.ledger_journal_line {where_t}" + ) + ) + .await, + Some(1), + "one rate per entry (§4.3) — all lines share the snapshot" + ); + + // The immutable snapshot was frozen with the locked rate + the EUR→USD pair. + assert_eq!( + scalar_i64( + &raw, + &format!( + "SELECT count(*) FROM bss.ledger_fx_rate_snapshot {where_t} \ + AND base_currency='EUR' AND quote_currency='USD' AND rate_micro=1100000" + ) + ) + .await, + Some(1), + "one immutable EUR→USD snapshot frozen at the locked rate" + ); + + // The same EUR invoice re-posts as an idempotent replay (no second snapshot + // is required for correctness; the post dedups on its business key). + let replay = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("replay must succeed"); + assert!(replay.replayed, "re-post is an idempotent replay"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_journal.rs b/gears/bss/ledger/ledger/tests/postgres_journal.rs new file mode 100644 index 000000000..27465933a --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_journal.rs @@ -0,0 +1,773 @@ +//! Postgres-only integration tests for the journal truth tables. +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. +//! +//! Covers: (a) a balanced entry commits; (b) an unbalanced entry rolls +//! back at COMMIT with `LEDGER_ENTRY_UNBALANCED`; (c) a zero-line header +//! rolls back at COMMIT with `LEDGER_ENTRY_EMPTY`; (d) `UPDATE`/`DELETE` +//! on a committed line is rejected by the append-only trigger. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::too_many_lines +)] + +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement, TransactionTrait}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use uuid::Uuid; + +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::JournalRepo; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, MappingStatus, ODataQuery, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; + +/// Build an `ODataQuery` carrying just a `$filter` expression (parsed from the +/// OData text), the way the REST `OData` extractor would. An empty query +/// (`ODataQuery::default()`) lists everything in scope. +fn odata_filter(expr: &str) -> ODataQuery { + let parsed = toolkit_odata::parse_filter_string(expr) + .expect("test $filter must parse") + .into_expr(); + ODataQuery::default().with_filter(parsed) +} + +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + DatabaseConnection, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let db = Database::connect(&url).await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + (container, db) +} + +fn exec(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Insert a header row inside the open transaction. The balance trigger is +/// DEFERRED, so this succeeds regardless of line state until COMMIT. +async fn insert_entry( + txn: &impl ConnectionTrait, + entry_id: Uuid, + tenant_id: Uuid, + period_id: &str, + currency: &str, +) { + txn.execute(exec(format!( + "INSERT INTO bss.ledger_journal_entry + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, + source_doc_type, source_business_id, posted_at_utc, effective_at, + origin, posted_by_actor_id, correlation_id) + VALUES ('{entry_id}', '{tenant_id}', '{tenant_id}', '{period_id}', '{currency}', + 'MANUAL_ADJUSTMENT', 'biz-1', now(), CURRENT_DATE, + 'SYSTEM', '{tenant_id}', '{tenant_id}')" + ))) + .await + .unwrap(); +} + +#[allow(clippy::too_many_arguments)] +async fn insert_line( + txn: &impl ConnectionTrait, + line_id: Uuid, + entry_id: Uuid, + tenant_id: Uuid, + period_id: &str, + side: &str, + amount: i64, + currency: &str, +) { + txn.execute(exec(format!( + "INSERT INTO bss.ledger_journal_line + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, + account_class, side, amount_minor, currency, currency_scale, mapping_status) + VALUES ('{line_id}', '{entry_id}', '{tenant_id}', '{period_id}', '{tenant_id}', + '{tenant_id}', 'AR', '{side}', {amount}, '{currency}', 2, 'RESOLVED')" + ))) + .await + .unwrap(); +} + +/// Like [`insert_line`] but with a caller-chosen `payer_tenant_id` (for the +/// single-payer trigger arm). +#[allow(clippy::too_many_arguments)] +async fn insert_line_payer( + txn: &impl ConnectionTrait, + entry_id: Uuid, + tenant_id: Uuid, + period_id: &str, + payer_tenant_id: Uuid, + side: &str, + amount: i64, + currency: &str, +) { + txn.execute(exec(format!( + "INSERT INTO bss.ledger_journal_line + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, + account_class, side, amount_minor, currency, currency_scale, mapping_status) + VALUES ('{}', '{entry_id}', '{tenant_id}', '{period_id}', '{payer_tenant_id}', + '{tenant_id}', 'AR', '{side}', {amount}, '{currency}', 2, 'RESOLVED')", + Uuid::new_v4() + ))) + .await + .unwrap(); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn balanced_entry_commits() { + let (_c, db) = boot().await; + let entry_id = Uuid::new_v4(); + let tenant_id = Uuid::new_v4(); + + let txn = db.begin().await.unwrap(); + insert_entry(&txn, entry_id, tenant_id, "202606", "USD").await; + insert_line( + &txn, + Uuid::new_v4(), + entry_id, + tenant_id, + "202606", + "DR", + 1000, + "USD", + ) + .await; + insert_line( + &txn, + Uuid::new_v4(), + entry_id, + tenant_id, + "202606", + "CR", + 1000, + "USD", + ) + .await; + txn.commit().await.expect("balanced entry must commit"); + + let row = db + .query_one(exec(format!( + "SELECT created_seq FROM bss.ledger_journal_entry WHERE entry_id = '{entry_id}'" + ))) + .await + .unwrap() + .expect("committed entry must be visible"); + let created_seq: i64 = row.try_get("", "created_seq").expect("created_seq column"); + assert!( + created_seq > 0, + "created_seq must be a positive DB-generated sequence, got {created_seq}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn unbalanced_entry_rolls_back_at_commit() { + let (_c, db) = boot().await; + let entry_id = Uuid::new_v4(); + let tenant_id = Uuid::new_v4(); + + let txn = db.begin().await.unwrap(); + insert_entry(&txn, entry_id, tenant_id, "202606", "USD").await; + insert_line( + &txn, + Uuid::new_v4(), + entry_id, + tenant_id, + "202606", + "DR", + 1000, + "USD", + ) + .await; + insert_line( + &txn, + Uuid::new_v4(), + entry_id, + tenant_id, + "202606", + "CR", + 700, + "USD", + ) + .await; + let err = txn + .commit() + .await + .expect_err("unbalanced entry must fail at COMMIT"); + assert!( + err.to_string().contains("LEDGER_ENTRY_UNBALANCED"), + "unexpected error: {err}" + ); + + let row = db + .query_one(exec(format!( + "SELECT entry_id FROM bss.ledger_journal_entry WHERE entry_id = '{entry_id}'" + ))) + .await + .unwrap(); + assert!(row.is_none(), "rolled-back entry must not persist"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn zero_line_entry_rolls_back_at_commit() { + let (_c, db) = boot().await; + let entry_id = Uuid::new_v4(); + let tenant_id = Uuid::new_v4(); + + let txn = db.begin().await.unwrap(); + insert_entry(&txn, entry_id, tenant_id, "202606", "USD").await; + let err = txn + .commit() + .await + .expect_err("zero-line entry must fail at COMMIT"); + assert!( + err.to_string().contains("LEDGER_ENTRY_EMPTY"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn append_only_rejects_update_and_delete() { + let (_c, db) = boot().await; + let entry_id = Uuid::new_v4(); + let tenant_id = Uuid::new_v4(); + let line_id = Uuid::new_v4(); + + let txn = db.begin().await.unwrap(); + insert_entry(&txn, entry_id, tenant_id, "202606", "USD").await; + insert_line( + &txn, line_id, entry_id, tenant_id, "202606", "DR", 1000, "USD", + ) + .await; + insert_line( + &txn, + Uuid::new_v4(), + entry_id, + tenant_id, + "202606", + "CR", + 1000, + "USD", + ) + .await; + txn.commit().await.unwrap(); + + let upd = db + .execute(exec(format!( + "UPDATE bss.ledger_journal_line SET amount_minor = 5 WHERE line_id = '{line_id}'" + ))) + .await + .expect_err("UPDATE on append-only line must be rejected"); + assert!(upd.to_string().contains("append-only"), "unexpected: {upd}"); + + let del = db + .execute(exec(format!( + "DELETE FROM bss.ledger_journal_line WHERE line_id = '{line_id}'" + ))) + .await + .expect_err("DELETE on append-only line must be rejected"); + assert!(del.to_string().contains("append-only"), "unexpected: {del}"); +} + +/// Two balanced USD lines carrying DIFFERENT `payer_tenant_id` values trip the +/// single-payer arm of the balance trigger (`MIXED_PAYER_TENANT`) at COMMIT — +/// a cross-payer entry must never persist (cost-attribution / BOLA isolation). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn mixed_payer_entry_rolls_back_at_commit() { + let (_c, db) = boot().await; + let entry_id = Uuid::new_v4(); + let tenant_id = Uuid::new_v4(); + let payer_b = Uuid::new_v4(); + + let txn = db.begin().await.unwrap(); + insert_entry(&txn, entry_id, tenant_id, "202606", "USD").await; + insert_line_payer( + &txn, entry_id, tenant_id, "202606", tenant_id, "DR", 1000, "USD", + ) + .await; + insert_line_payer( + &txn, entry_id, tenant_id, "202606", payer_b, "CR", 1000, "USD", + ) + .await; + let err = txn + .commit() + .await + .expect_err("mixed-payer entry must fail at COMMIT"); + assert!( + err.to_string().contains("MIXED_PAYER_TENANT"), + "unexpected error: {err}" + ); + + let row = db + .query_one(exec(format!( + "SELECT entry_id FROM bss.ledger_journal_entry WHERE entry_id = '{entry_id}'" + ))) + .await + .unwrap(); + assert!( + row.is_none(), + "rolled-back mixed-payer entry must not persist" + ); +} + +/// A line whose currency differs from the entry currency (and is not a +/// zero-amount functional-only line) trips the currency arm of the balance +/// trigger (`LEDGER_ENTRY_CURRENCY_MISMATCH`) at COMMIT, ahead of the +/// unbalanced check. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn currency_mismatch_line_rolls_back_at_commit() { + let (_c, db) = boot().await; + let entry_id = Uuid::new_v4(); + let tenant_id = Uuid::new_v4(); + + let txn = db.begin().await.unwrap(); + insert_entry(&txn, entry_id, tenant_id, "202606", "USD").await; + // Same payer (no mixed-payer); one USD and one EUR line (EUR != entry USD). + insert_line( + &txn, + Uuid::new_v4(), + entry_id, + tenant_id, + "202606", + "DR", + 1000, + "USD", + ) + .await; + insert_line( + &txn, + Uuid::new_v4(), + entry_id, + tenant_id, + "202606", + "CR", + 1000, + "EUR", + ) + .await; + let err = txn + .commit() + .await + .expect_err("currency-mismatch entry must fail at COMMIT"); + assert!( + err.to_string().contains("LEDGER_ENTRY_CURRENCY_MISMATCH"), + "unexpected error: {err}" + ); +} + +// --- A3: PDP-scoped ledger read seam (journal repo) ------------------------ +// +// Provisions a seller, posts ONE balanced invoice entry through the real +// engine, then drives the new scoped repo reads (`find_entry_with_lines`, +// `list_lines`, `list_balances`, `list_ar_invoice_balances`) used by the +// `LedgerClientV1` read methods, plus a cross-tenant BOLA case mirroring +// `postgres_bola.rs`. + +/// Seeded ledger ids for the read tests. +struct ReadFixture { + tenant: Uuid, + payer: Uuid, + ar_account: Uuid, + revenue_account: Uuid, + tax_account: Uuid, + invoice_id: String, + due_date: NaiveDate, +} + +fn read_account( + tenant: Uuid, + account_id: Uuid, + class: AccountClass, + normal: Side, + revenue_stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: revenue_stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +#[allow(clippy::too_many_arguments)] +fn read_line( + f: &ReadFixture, + account: Uuid, + class: AccountClass, + side: Side, + amount: i64, + invoice_id: Option<&str>, + revenue_stream: Option<&str>, + tax: Option<(&str, &str)>, +) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: f.payer, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: invoice_id.map(str::to_owned), + due_date: invoice_id.map(|_| f.due_date), + revenue_stream: revenue_stream.map(str::to_owned), + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: tax.map(|(j, _)| j.to_owned()), + tax_filing_period: tax.map(|(_, p)| p.to_owned()), + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Boot, migrate, seed AR/REVENUE/TAX accounts + USD@2 + an OPEN period, and +/// post ONE balanced invoice entry: DR AR 1200 (gross) / CR REVENUE 1000 / +/// CR TAX 200, AR line carrying `invoice_id` + `due_date`, TAX line carrying +/// the tax dims. Returns the migrate connection, a search_path-scoped provider, +/// and the fixture. +async fn setup_posted_invoice(url: &str) -> (DatabaseConnection, DBProvider, ReadFixture) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let period_id = "202606".to_owned(); + let f = ReadFixture { + tenant, + payer, + ar_account: Uuid::now_v7(), + revenue_account: Uuid::now_v7(), + tax_account: Uuid::now_v7(), + invoice_id: "INV-1".to_owned(), + due_date: NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{tenant}','{period_id}','UTC','OPEN')" + ), + )) + .await + .unwrap(); + reference + .insert_account(read_account( + tenant, + f.ar_account, + AccountClass::Ar, + Side::Debit, + None, + )) + .await + .unwrap(); + reference + .insert_account(read_account( + tenant, + f.revenue_account, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + )) + .await + .unwrap(); + reference + .insert_account(read_account( + tenant, + f.tax_account, + AccountClass::TaxPayable, + Side::Credit, + None, + )) + .await + .unwrap(); + + // Balanced invoice: DR AR 1200 / CR REVENUE 1000 / CR TAX 200. + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + legal_entity_id: tenant, + period_id: period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: f.invoice_id.clone(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: tenant, + correlation_id: tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + read_line( + &f, + f.ar_account, + AccountClass::Ar, + Side::Debit, + 1200, + Some(&f.invoice_id), + None, + None, + ), + read_line( + &f, + f.revenue_account, + AccountClass::Revenue, + Side::Credit, + 1000, + None, + Some("subscription"), + None, + ), + read_line( + &f, + f.tax_account, + AccountClass::TaxPayable, + Side::Credit, + 200, + None, + None, + Some(("US-CA", "2026Q2")), + ), + ]; + let service = PostingService::new( + provider.clone(), + std::sync::Arc::new(bss_ledger::infra::events::publisher::LedgerEventPublisher::noop()), + ); + service + .post( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(tenant), + entry, + lines, + None, + ) + .await + .expect("invoice post must succeed"); + + (raw, provider, f) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reads_return_entry_lines_balances_and_ar_invoice() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, provider, f) = setup_posted_invoice(&url).await; + + let repo = JournalRepo::new(provider); + let scope = AccessScope::for_tenant(f.tenant); + + // get_entry: header + all three lines. + let entry_id = repo + .entry_ids_for_business_id(&scope, f.tenant, &f.invoice_id) + .await + .expect("resolve entry id") + .first() + .copied() + .expect("one entry for the invoice business id"); + let record = repo + .find_entry_with_lines(&scope, f.tenant, entry_id) + .await + .expect("get entry") + .expect("entry must exist"); + assert_eq!(record.source_doc_type, "INVOICE_POST"); + assert_eq!(record.lines.len(), 3, "AR + Revenue + Tax lines"); + + // list_lines filtered by payer (OData `$filter`): all three lines share it. + let by_payer = repo + .list_lines( + &scope, + f.tenant, + &odata_filter(&format!("payer_tenant_id eq {}", f.payer)), + ) + .await + .expect("list lines by payer"); + assert_eq!(by_payer.items.len(), 3, "all three lines share the payer"); + + // list_lines filtered by account_class = REVENUE: exactly the revenue line, + // carrying its revenue_stream. + let revenue_lines = repo + .list_lines( + &scope, + f.tenant, + &odata_filter("account_class eq 'REVENUE'"), + ) + .await + .expect("list revenue lines"); + assert_eq!(revenue_lines.items.len(), 1, "one revenue line"); + assert_eq!( + revenue_lines.items[0].revenue_stream.as_deref(), + Some("subscription"), + "revenue line carries its stream" + ); + + // list_balances (bare query): AR + Revenue + Tax account-balance grains. + let balances = repo + .list_balances(&scope, f.tenant, &ODataQuery::default()) + .await + .expect("list balances"); + assert_eq!(balances.items.len(), 3, "AR + Revenue + Tax balances"); + let ar = balances + .items + .iter() + .find(|b| b.account_id == f.ar_account) + .expect("AR balance present"); + assert_eq!(ar.balance_minor, 1200, "AR balance = gross 1200"); + + // list_balances filtered by class = REVENUE (OData `$filter`). + let rev_only = repo + .list_balances( + &scope, + f.tenant, + &odata_filter("account_class eq 'REVENUE'"), + ) + .await + .expect("list revenue balance"); + assert_eq!(rev_only.items.len(), 1, "only the revenue balance"); + assert_eq!(rev_only.items[0].balance_minor, 1000); + + // list_ar_invoice_balances: the AR-invoice grain with its due_date. + let ar_invoices = repo + .list_ar_invoice_balances(&scope, f.tenant, Some(f.payer)) + .await + .expect("list ar invoice balances"); + assert_eq!(ar_invoices.len(), 1, "one AR-invoice row"); + assert_eq!(ar_invoices[0].invoice_id, f.invoice_id); + assert_eq!(ar_invoices[0].balance_minor, 1200); + // Decision P: the projector now threads the AR LINE's due_date onto the + // ar_invoice_balance cache row (first-write-wins), so the cache read + // surfaces the same date `build_invoice_entry` stamped — closing the latent + // AR-aging gap where the cache used to hardcode NULL. + assert_eq!( + ar_invoices[0].due_date, + Some(NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()), + "the projector threads the line's due_date onto the cache row (decision P)" + ); +} + +/// BOLA (authz #3): a tenant-B scope must see NONE of tenant A's journal data — +/// `get_entry` → `None`, `list_lines` / `list_balances` / +/// `list_ar_invoice_balances` → empty — even though A genuinely has rows. The +/// SecureORM scope predicate overrides the caller-supplied `tenant_id` filter at +/// the SQL layer (mirrors `postgres_bola.rs`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_reads_are_sql_scoped_to_empty() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, provider, f) = setup_posted_invoice(&url).await; + + let repo = JournalRepo::new(provider); + let scope_a = AccessScope::for_tenant(f.tenant); + let scope_b = AccessScope::for_tenant(Uuid::now_v7()); + + // Positive control: A sees its own entry id. + let a_entry = repo + .entry_ids_for_business_id(&scope_a, f.tenant, &f.invoice_id) + .await + .expect("resolve A entry") + .first() + .copied() + .expect("A has an entry"); + + // BOLA: B's scope, querying A's exact tenant + entry id, gets None / empty. + assert!( + repo.find_entry_with_lines(&scope_b, f.tenant, a_entry) + .await + .expect("query ok") + .is_none(), + "B must not read A's entry by id" + ); + assert!( + repo.list_lines(&scope_b, f.tenant, &ODataQuery::default()) + .await + .expect("query ok") + .items + .is_empty(), + "B must not list A's lines" + ); + assert!( + repo.list_balances(&scope_b, f.tenant, &ODataQuery::default()) + .await + .expect("query ok") + .items + .is_empty(), + "B must not list A's balances" + ); + assert!( + repo.list_ar_invoice_balances(&scope_b, f.tenant, None) + .await + .expect("query ok") + .is_empty(), + "B must not list A's AR-invoice rows" + ); + + // Sanity: A's own scope still sees its rows (so empty above = scoped out). + assert!( + repo.list_balances(&scope_a, f.tenant, &ODataQuery::default()) + .await + .expect("query ok") + .items + .len() + == 3, + "A sees its own three balances" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_manual_adjustment.rs b/gears/bss/ledger/ledger/tests/postgres_manual_adjustment.rs new file mode 100644 index 000000000..194e54ddc --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_manual_adjustment.rs @@ -0,0 +1,412 @@ +//! Postgres-only integration: the Slice-3 Phase-3 `ManualAdjustmentHandler` +//! (Group 4), driven through the REAL foundation engine (`PostingService` + the +//! in-txn `ManualAdjustmentPostSidecar`). Asserts the design §4.6 durable effects: +//! +//! - a governed `RoundingCorrection` posts its balanced legs (DR/CR over the +//! parking / clearing classes) through the engine — `PostingRef.replayed == false` +//! on the fresh post — and a re-post of the SAME `(tenant, MANUAL_ADJUSTMENT, +//! adjustment_id)` is an idempotent replay (`replayed == true`, no second entry); +//! - a leg whose class is OUTSIDE the action's allow-list is rejected +//! (`ManualAdjustmentNotAllowed`) with no books effect; +//! - a bare `CONTRA_REVENUE` leg (a disguised bad-debt write-off) is rejected +//! (`ManualAdjustmentNotAllowed`) with no books effect — `govern`'s write-off +//! guard, additionally captured + paged out-of-band (the no-op sink + alarm are +//! not asserted here). +//! +//! The handler is `pub`, so this out-of-crate test drives it directly with a no-op +//! publisher + the no-op secured-audit sink (mirrors `postgres_credit_note.rs`). +//! Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use bss_ledger::domain::adjustment::manual::{ + ManualAdjustmentAction, ManualAdjustmentRequest, ManualLeg, +}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::adjustment::manual_adjustment_service::ManualAdjustmentHandler; +use bss_ledger::infra::audit::secured_audit_sink::{NoopSecuredAuditSink, SecuredAuditSink}; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, Side}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Provisioned seller ids (the parking / clearing / write-off classes a governed +/// manual adjustment may touch). +struct Seller { + tenant: Uuid, + // Used by the deferred payer-gate `#[ignore]` test (TODO below): an AR / + // UNALLOCATED manual leg attributes to this payer. Held now so the seed already + // carries it. + #[allow(dead_code)] + payer: Uuid, + suspense: Uuid, + cash_clearing: Uuid, + ar: Uuid, + unallocated: Uuid, + goodwill: Uuid, + contra_revenue: Uuid, + tax: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot, migrate, seed USD@2 + an OPEN period + the parking/clearing chart. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + suspense: Uuid::now_v7(), + cash_clearing: Uuid::now_v7(), + ar: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + goodwill: Uuid::now_v7(), + contra_revenue: Uuid::now_v7(), + tax: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + // Seed BOTH the invoice's period (`s.period_id`) and the CURRENT period: the + // adjustment handlers post into `Utc::now()`'s period (credit/debit-note + // `eff_date = Utc::now()`), so a fixed historical period alone makes the test + // date-dependent (green only in that calendar month). ON CONFLICT dedups when + // `now` already equals `s.period_id`. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{t}','{t}','{p}','UTC','OPEN'), ('{t}','{t}','{cur}','UTC','OPEN') + ON CONFLICT DO NOTHING", + t = s.tenant, + p = s.period_id, + cur = chrono::Utc::now().format("%Y%m") + ))) + .await + .unwrap(); + + // The parking / clearing classes resolve stream-less (ChartIndex keys non- + // per-stream classes on stream = None). CONTRA_REVENUE is provisioned only so + // the write-off test can resolve a chart account — `govern` rejects it BEFORE + // any post, so the account is never actually debited. + for row in [ + // SUSPENSE is credit-normal by convention — a holding/clearing account + // (mirrors REFUND_CLEARING; the unknown_final refund park books +amount to + // it). Consistent with tests/postgres_refund.rs (the park target). + account(s.tenant, s.suspense, AccountClass::Suspense, Side::Credit), + account( + s.tenant, + s.cash_clearing, + AccountClass::CashClearing, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account(s.tenant, s.goodwill, AccountClass::Goodwill, Side::Debit), + account( + s.tenant, + s.contra_revenue, + AccountClass::ContraRevenue, + Side::Debit, + ), + account(s.tenant, s.tax, AccountClass::TaxPayable, Side::Credit), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +fn handler(provider: &DBProvider) -> ManualAdjustmentHandler { + let audit: Arc = Arc::new(NoopSecuredAuditSink::new()); + ManualAdjustmentHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + audit, + ) +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +async fn entry_count(raw: &DatabaseConnection, s: &Seller, adjustment_id: &str) -> Option { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_doc_type='MANUAL_ADJUSTMENT' \ + AND source_business_id='{adjustment_id}'", + s.tenant + ), + ) + .await +} + +fn leg(class: AccountClass, side: Side, amount_minor: i64) -> ManualLeg { + ManualLeg { + account_class: class, + side, + amount_minor, + revenue_stream: None, + } +} + +fn req( + s: &Seller, + adjustment_id: &str, + action: ManualAdjustmentAction, + payer: Option, + legs: Vec, +) -> ManualAdjustmentRequest { + ManualAdjustmentRequest { + tenant_id: s.tenant, + payer_tenant_id: payer, + adjustment_id: adjustment_id.to_owned(), + action, + currency: "USD".to_owned(), + legs, + reason_code: "ROUNDING_RESIDUE".to_owned(), + preparer_actor_id: Uuid::now_v7(), + approver_actor_id: None, + tax: Vec::new(), + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn rounding_correction_posts_then_replays_idempotently() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A balanced 1-minor rounding correction between two parking/clearing classes: + // DR SUSPENSE 1 / CR CASH_CLEARING 1 (both in the RoundingCorrection allow-list; + // no payer-scoped class, so payer_tenant_id is unnecessary). + let request = req( + &s, + "ADJ-RC-1", + ManualAdjustmentAction::RoundingCorrection, + None, + // DR CASH_CLEARING / CR SUSPENSE: CASH_CLEARING is debit-normal AND guarded + // (must stay >= 0), so it must be DEBITED here (a CR from a zero balance would + // drive it negative → NegativeBalance reject). SUSPENSE is credit-normal and + // NOT guarded, so its CR lands the rounding residue as a clean +1. + vec![ + leg(AccountClass::CashClearing, Side::Debit, 1), + leg(AccountClass::Suspense, Side::Credit, 1), + ], + ); + + let posted = handler(&provider) + .post_manual_adjustment(&ctx, &scope, request.clone()) + .await + .expect("rounding correction posts"); + assert!(!posted.replayed, "the fresh post is not a replay"); + assert_eq!( + entry_count(&raw, &s, "ADJ-RC-1").await, + Some(1), + "exactly one manual-adjustment entry posted" + ); + assert_eq!( + bal(&raw, &s, s.suspense).await, + Some(1), + "SUSPENSE credited +1 (credit-normal holding account)" + ); + assert_eq!( + bal(&raw, &s, s.cash_clearing).await, + Some(1), + "CASH_CLEARING debited 1 (debit-normal)" + ); + + // Re-post the SAME (tenant, MANUAL_ADJUSTMENT, adjustment_id): the engine claim + // makes it an idempotent replay — same entry id, replayed == true, no second + // entry / balance effect. + let replayed = handler(&provider) + .post_manual_adjustment(&ctx, &scope, request) + .await + .expect("replay returns the prior post"); + assert!(replayed.replayed, "the re-post is an idempotent replay"); + assert_eq!( + replayed.entry_id, posted.entry_id, + "the replay returns the original entry id" + ); + assert_eq!( + entry_count(&raw, &s, "ADJ-RC-1").await, + Some(1), + "no second entry on replay" + ); + assert_eq!( + bal(&raw, &s, s.suspense).await, + Some(1), + "SUSPENSE unchanged on replay (still +1)" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn class_outside_allow_list_is_not_allowed() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // TAX_PAYABLE is in NO action's allow-list. A balanced DR SUSPENSE 5 / CR + // TAX_PAYABLE 5 nets to zero + is not a REVENUE/CL/CONTRA shape, so it reaches + // the allow-list check and is rejected there (NotAllowed) — no books effect. + let request = req( + &s, + "ADJ-BAD-CLASS", + ManualAdjustmentAction::RoundingCorrection, + None, + vec![ + leg(AccountClass::Suspense, Side::Debit, 5), + leg(AccountClass::TaxPayable, Side::Credit, 5), + ], + ); + + let err = handler(&provider) + .post_manual_adjustment(&ctx, &scope, request) + .await + .expect_err("a class outside the allow-list must be rejected"); + assert!( + matches!(err, DomainError::ManualAdjustmentNotAllowed(_)), + "expected ManualAdjustmentNotAllowed, got {err:?}" + ); + assert_eq!( + entry_count(&raw, &s, "ADJ-BAD-CLASS").await, + Some(0), + "no entry posted for a rejected class" + ); + assert!( + matches!(bal(&raw, &s, s.suspense).await, None | Some(0)), + "SUSPENSE untouched" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn contra_revenue_write_off_is_not_allowed_and_does_not_post() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A bare CONTRA_REVENUE leg with no paired same-stream recognized-REVENUE + // reduction is the disguised bad-debt write-off shape: DR CONTRA_REVENUE 100 / + // CR SUSPENSE 100. `govern` rejects it as AttemptedWriteOff, mapped to the + // canonical ManualAdjustmentNotAllowed 400 (and captured + paged out-of-band). + // No books effect — the CONTRA_REVENUE account stays untouched. + let request = req( + &s, + "ADJ-WRITEOFF", + ManualAdjustmentAction::SuspenseClear, + None, + vec![ + leg(AccountClass::ContraRevenue, Side::Debit, 100), + leg(AccountClass::Suspense, Side::Credit, 100), + ], + ); + + let err = handler(&provider) + .post_manual_adjustment(&ctx, &scope, request) + .await + .expect_err("an unpaired CONTRA_REVENUE write-off must be rejected"); + assert!( + matches!(err, DomainError::ManualAdjustmentNotAllowed(_)), + "expected ManualAdjustmentNotAllowed, got {err:?}" + ); + assert_eq!( + entry_count(&raw, &s, "ADJ-WRITEOFF").await, + Some(0), + "no entry posted for an attempted write-off" + ); + assert!( + matches!(bal(&raw, &s, s.contra_revenue).await, None | Some(0)), + "CONTRA_REVENUE untouched — the write-off never posted" + ); +} + +// TODO(VHP-1856 Slice 3 Phase 3): a payer-gate `#[ignore]` test — an AR / +// UNALLOCATED leg with `payer_tenant_id = None` must reject with +// `ManualAdjustmentNotAllowed("AR/UNALLOCATED leg requires payer_tenant_id")`, and +// the same legs WITH a payer must post (the AR / UNALLOCATED grain attributing to +// `payer_tenant_id`). Deferred to keep this harness lean; the payer-gate branch is +// a pure pre-post check exercised by the handler unit path. diff --git a/gears/bss/ledger/ledger/tests/postgres_migration_idempotency.rs b/gears/bss/ledger/ledger/tests/postgres_migration_idempotency.rs new file mode 100644 index 000000000..c3703a92f --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_migration_idempotency.rs @@ -0,0 +1,274 @@ +//! C1 regression: the gear's migrations run through the toolkit runner +//! (`run_migrations_for_testing`), whose per-gear bookkeeping table is created +//! *unqualified*, BEFORE our schema migration creates `bss`. The connection +//! carries a `search_path` (prod config), so the schema that the unqualified +//! bookkeeping table resolves into depends on the path ORDER: +//! +//! - `bss,public` (the original, buggy order): on boot 1 `bss` does not exist +//! yet, so bookkeeping lands in `public`; on boot 2 `bss` exists and is first +//! in the path, so a *second* empty bookkeeping table is created in `bss`, +//! the history reads empty, every migration re-runs, and the non-`IF NOT +//! EXISTS` `CREATE TABLE bss.ledger_journal_entry` aborts -> startup crash loop. +//! - `public,bss` (the fix): `public` always exists and is first, so the +//! bookkeeping table is in `public` on every boot; the history is stable and +//! the second boot is a clean no-op. Domain tables are `bss.`-qualified in +//! the migrations, so runtime DML still resolves them in `bss`. +//! +//! Ignored by default (Docker/testcontainers); run with +//! `cargo test -p bss-ledger --test postgres_migration_idempotency -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown +)] + +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::{ConnectOpts, connect_db}; + +use bss_ledger::infra::storage::migrations::Migrator; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// `SELECT count(*) ...` -> the `i64` count. +async fn count(db: &DatabaseConnection, sql: impl Into) -> i64 { + let row = db + .query_one(pg(sql)) + .await + .unwrap() + .expect("count query must return a row"); + row.try_get::("", "count").unwrap() +} + +/// Build a connection URL that sets `search_path` as a libpq option (the prod +/// gear config sets it per-connection the same way). +fn url_with_search_path(port: u16, search_path: &str) -> String { + // `-c search_path=` url-encoded: space -> %20, `=` -> %3D. + format!( + "postgres://postgres:postgres@127.0.0.1:{port}/postgres?options=-c%20search_path%3D{search_path}" + ) +} + +/// The fix: with `public` first the toolkit runner is idempotent across boots, +/// the bookkeeping table lives in `public`, and the domain tables live in +/// `bss` only (so runtime DML resolves them there with no `public` collision). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn migrations_idempotent_under_public_first_search_path() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + + let db = connect_db( + &url_with_search_path(port, "public,bss"), + ConnectOpts::default(), + ) + .await + .expect("connect with public,bss search_path"); + + // Boot 1: a fresh DB applies the whole chain. + let r1 = run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("boot 1 migrations must succeed"); + assert_eq!(r1.applied, 45, "boot 1 applies all 45 migrations"); + + // Boot 2: the same connection must skip everything — no re-create crash. + let r2 = run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("boot 2 migrations must be a clean no-op (this is the C1 fix)"); + assert_eq!(r2.applied, 0, "boot 2 applies nothing"); + assert_eq!(r2.skipped, 45, "boot 2 skips all 45"); + + // Inspect placement on a plain connection (information_schema is global). + let base = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&base).await.unwrap(); + + // Bookkeeping table must be in `public`, and there must be exactly one of + // it (no duplicate in `bss`). + let bk_total = count( + &raw, + "SELECT count(*) AS count FROM information_schema.tables \ + WHERE table_name LIKE 'toolkit_migrations%'", + ) + .await; + assert_eq!(bk_total, 1, "exactly one bookkeeping table"); + let bk_in_public = count( + &raw, + "SELECT count(*) AS count FROM information_schema.tables \ + WHERE table_name LIKE 'toolkit_migrations%' AND table_schema = 'public'", + ) + .await; + assert_eq!(bk_in_public, 1, "bookkeeping table lives in public"); + + // Domain table is in `bss`, and NOT in `public` — so runtime DML (which is + // unqualified) resolves it in `bss`, and the public-collision risk of the + // `public,bss` order is absent in practice. + let je_in_bss = count( + &raw, + "SELECT count(*) AS count FROM information_schema.tables \ + WHERE table_schema = 'bss' AND table_name = 'ledger_journal_entry'", + ) + .await; + assert_eq!(je_in_bss, 1, "ledger_journal_entry is created in bss"); + let je_in_public = count( + &raw, + "SELECT count(*) AS count FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = 'ledger_journal_entry'", + ) + .await; + assert_eq!( + je_in_public, 0, + "ledger_journal_entry must NOT exist in public" + ); +} + +/// Reproduces C1: with the original `bss,public` order the second boot through +/// the toolkit runner crashes. Kept as executable documentation that the fix +/// above is necessary (and that the regression test actually exercises the bug). +#[tokio::test] +#[ignore = "requires Docker (testcontainers); documents the C1 crash"] +async fn bss_first_search_path_crash_loops_on_second_boot() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + + let db = connect_db( + &url_with_search_path(port, "bss,public"), + ConnectOpts::default(), + ) + .await + .expect("connect with bss,public search_path"); + + // Boot 1 succeeds (bookkeeping lands in public, bss does not exist yet). + let r1 = run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("boot 1 succeeds even with the buggy order"); + assert_eq!(r1.applied, 45); + + // Boot 2 finds bss first, creates a second empty bookkeeping table there, + // reads an empty history, and re-runs `CREATE TABLE bss.ledger_journal_entry`, + // which already exists -> error. + let r2 = run_migrations_for_testing(&db, Migrator::migrations()).await; + assert!( + r2.is_err(), + "second boot under bss,public must crash (C1); got {r2:?}" + ); +} + +/// The payment-tables migration (`m20260622_000006`) creates the three counter +/// tables on `up` and drops them on `down`. Asserts each is queryable +/// (`SELECT count(*)` returns 0) after the full chain runs up, and absent (the +/// query errors) after the migration's `down` reverses it. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn payment_tables_created_on_up_and_dropped_on_down() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let db = Database::connect(&url).await.unwrap(); + + // Run the whole chain up; the three payment tables must be queryable+empty. + Migrator::up(&db, None).await.expect("up applies the chain"); + for table in [ + "ledger_payment_settlement", + "ledger_payment_allocation", + "ledger_payment_allocation_refund", + ] { + let n = count(&db, format!("SELECT count(*) AS count FROM bss.{table}")).await; + assert_eq!(n, 0, "{table} is queryable and empty after up"); + } + + // Reverse every migration applied AFTER the payment-tables migration, then the + // payment-tables (000006) migration itself; the three payment tables are gone. + // `Migrator::down` reverts in REVERSE of the `migrations()` Vec order (not name + // order): payment is at Vec position 6 of 45, so reverting down to-and-including + // it is 45 − 6 + 1 = 40 steps. (Magic count — recompute when a migration is + // added: it grew 9 → 24 → 28 → 29 → 31 → 34 → 35 → 36 → 38 → 39 → 40 over the VHP-1858 + // audit block + the S3 adjustment/note migrations + the Slice-5 FX substrate (incl. + // the S5-F3 functional-currency column + the Slice-5 remediation cache-consistency and + // snapshot-identity migrations) + the Slice-7 reconciliation/period-close substrate + // + the exception-queue OPEN-uniq remediation index + the VHP-1853 posting-policy + // table + the VHP-1843 verified-balance baseline + the C3 fx-revaluation-run marker + // + the VHP-1986 fx-revaluation-mode table + the currency-scale immutability trigger, + // all appended AFTER payment in the Vec.) + Migrator::down(&db, Some(40)) + .await + .expect("down reverses the last 40 migrations (to before payment)"); + for table in [ + "ledger_payment_settlement", + "ledger_payment_allocation", + "ledger_payment_allocation_refund", + ] { + let err = db + .query_one(pg(format!("SELECT count(*) AS count FROM bss.{table}"))) + .await + .expect_err("table must be absent after down"); + assert!( + err.to_string().to_lowercase().contains("does not exist") + || err.to_string().to_lowercase().contains(table), + "unexpected error for dropped {table}: {err}" + ); + } +} + +/// The pending-event-queue migration (`m20260623_000008`) creates the durable +/// queue table on `up` and drops it on `down`. Asserts it is queryable +/// (`SELECT count(*)` returns 0) after the full chain runs up, and absent (the +/// query errors) after the migration's `down` reverses just that last step. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn pending_event_queue_created_on_up_and_dropped_on_down() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let db = Database::connect(&url).await.unwrap(); + + // Run the whole chain up; the queue table must be queryable and empty. + Migrator::up(&db, None).await.expect("up applies the chain"); + let n = count( + &db, + "SELECT count(*) AS count FROM bss.ledger_pending_event_queue", + ) + .await; + assert_eq!( + n, 0, + "ledger_pending_event_queue is queryable and empty after up" + ); + + // Reverse every migration applied AFTER the queue migration, then the queue + // (000008) migration itself. `Migrator::down` reverts in REVERSE of the + // `migrations()` Vec order (not name order): the queue is at Vec position 8 of + // 45, so reverting down to-and-including it is 45 − 8 + 1 = 38 steps. (Magic + // count — recompute when a migration is added after the queue in the Vec: it + // grew 7 → 22 → 26 → 27 → 29 → 32 → 33 → 36 → 37 → 38 over the VHP-1858 audit block + the + // S3 adjustment/note migrations + the Slice-5 FX substrate (incl. the S5-F3 + // functional-currency column + the Slice-5 remediation cache-consistency and + // snapshot-identity migrations) + the Slice-7 reconciliation/period-close substrate + // + the exception-queue OPEN-uniq remediation index + the VHP-1853 posting-policy + // table + the VHP-1843 verified-balance baseline + the C3 fx-revaluation-run marker + // + the VHP-1986 fx-revaluation-mode table + the currency-scale immutability trigger.) + Migrator::down(&db, Some(38)) + .await + .expect("down reverses the last 38 migrations (to before the queue)"); + let err = db + .query_one(pg( + "SELECT count(*) AS count FROM bss.ledger_pending_event_queue", + )) + .await + .expect_err("table must be absent after down"); + assert!( + err.to_string().to_lowercase().contains("does not exist") + || err + .to_string() + .to_lowercase() + .contains("ledger_pending_event_queue"), + "unexpected error for dropped ledger_pending_event_queue: {err}" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_payer_state.rs b/gears/bss/ledger/ledger/tests/postgres_payer_state.rs new file mode 100644 index 000000000..9fb8917b7 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_payer_state.rs @@ -0,0 +1,179 @@ +//! Postgres-only repo tests for `PayerStateRepo` (the payer-lifecycle row +//! `bss.ledger_payer_state` + the outstanding-balance check over +//! `bss.ledger_ar_payer_balance`, VHP-1852 Phase 2). Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_payer_state -- --ignored`. +//! +//! Covers: (a) `close` upserts the row to CLOSED, stamping the approver + the +//! open-balance marker, and `read` reads it back; (b) `close` is idempotent — a +//! re-close lands on the same PK and updates the marker; (c) SQL-level BOLA — a +//! foreign-tenant scope cannot read another tenant's payer-state row; (d) +//! `has_outstanding_balance` is false with no AR grain and true once a non-zero +//! `ledger_ar_payer_balance` grain exists (the closure dual-control trigger). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::PayerStateRepo; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Spin up a Postgres container, migrate `bss`, return the raw connection + a +/// `bss`-search-path `DBProvider` (mirrors `postgres_payments.rs`). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// `close` upserts the payer-lifecycle row to CLOSED (stamping the approver + the +/// open-balance marker); `read` reads it back. A re-close is idempotent and +/// updates the marker on the same PK. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn close_upserts_closed_and_is_idempotent() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let approver = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let repo = PayerStateRepo::new(provider.clone()); + + // Absence ⇒ OPEN (no row). + assert!( + repo.read(&scope, tenant, payer) + .await + .expect("read") + .is_none(), + "no row before close ⇒ OPEN" + ); + + // Close with an open balance. + repo.close(&scope, tenant, payer, approver, true) + .await + .expect("close"); + let row = repo + .read(&scope, tenant, payer) + .await + .expect("read") + .expect("row present after close"); + assert_eq!(row.lifecycle_state, "CLOSED"); + assert!(row.closed_with_open_balance); + assert_eq!(row.approved_by, Some(approver)); + + // Re-close (idempotent): same PK, marker updated to false. + let approver2 = Uuid::now_v7(); + repo.close(&scope, tenant, payer, approver2, false) + .await + .expect("re-close"); + let row = repo + .read(&scope, tenant, payer) + .await + .expect("read") + .expect("row present after re-close"); + assert_eq!(row.lifecycle_state, "CLOSED"); + assert!( + !row.closed_with_open_balance, + "the re-close updated the marker" + ); + assert_eq!(row.approved_by, Some(approver2)); +} + +/// SQL-level BOLA: a payer-state row closed for tenant A is invisible to a +/// tenant-B `AccessScope` (own scope sees it ⇒ empty for B means scoped-out). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn payer_state_is_invisible_to_a_foreign_tenant_scope() { + let (_c, _raw, provider) = boot().await; + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let own = AccessScope::for_tenant(tenant_a); + let foreign = AccessScope::for_tenant(tenant_b); + let repo = PayerStateRepo::new(provider.clone()); + + repo.close(&own, tenant_a, payer, Uuid::now_v7(), false) + .await + .expect("close A"); + + assert!( + repo.read(&own, tenant_a, payer) + .await + .expect("read own") + .is_some(), + "tenant A's own scope must see its payer-state row" + ); + assert!( + repo.read(&foreign, tenant_a, payer) + .await + .expect("read foreign") + .is_none(), + "a tenant-B scope must NOT read tenant A's payer-state row (SQL-level BOLA)" + ); +} + +/// `has_outstanding_balance` is false with no AR grain and true once a non-zero +/// `ledger_ar_payer_balance` grain exists — the trigger that routes a payer +/// closure through dual-control. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn has_outstanding_balance_reflects_the_ar_grain() { + let (_c, raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let repo = PayerStateRepo::new(provider.clone()); + + // No grain ⇒ nothing outstanding. + assert!( + !repo + .has_outstanding_balance(&scope, tenant, payer) + .await + .expect("has_outstanding_balance"), + "no AR grain ⇒ no outstanding balance" + ); + + // Seed a non-zero AR grain for the payer. + let acct = Uuid::now_v7(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_ar_payer_balance \ + (tenant_id, payer_tenant_id, account_id, currency, balance_minor, version) \ + VALUES ('{tenant}','{payer}','{acct}','USD',500,0)" + ))) + .await + .expect("seed ar payer balance"); + + assert!( + repo.has_outstanding_balance(&scope, tenant, payer) + .await + .expect("has_outstanding_balance"), + "a non-zero AR grain ⇒ outstanding balance (closure needs dual-control)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_payment_concurrency.rs b/gears/bss/ledger/ledger/tests/postgres_payment_concurrency.rs new file mode 100644 index 000000000..cee9cdf12 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_payment_concurrency.rs @@ -0,0 +1,899 @@ +//! Postgres-only **concurrency** tests for the payment slice (G2): they race +//! real `AllocationService` / `PostingService` orchestrators on a testcontainer +//! Postgres and pin the invariants the per-payment cap, the candidate ceiling, +//! and the payer-grain projector must preserve under contention. Ignored by +//! default; run with +//! `cargo test -p bss-ledger --test postgres_payment_concurrency -- --ignored`. +//! +//! Covers: (1) N concurrent `allocate`s of the SAME payment never push +//! `allocated_minor` past `settled_minor` — the per-payment cap CHECK is the +//! authority even though the shared unallocated pool is positive (every loser +//! is `MoneyOutCapExceeded`); (2) an `allocate` and a concurrent fresh +//! invoice-post for the SAME payer serialize without deadlock and BOTH effects +//! land; (3) an `allocate` whose candidate set exceeds +//! `MAX_INVOICES_PER_ALLOCATION` is rejected `AllocationTooLarge` BEFORE any +//! post (no allocation rows written). +//! +//! Self-contained: re-declares the small `boot` / `setup_seller` / +//! `seed_ar_invoice` helpers it needs (mirrors `postgres_posting.rs`), so it +//! does not depend on `postgres_payments.rs`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::needless_pass_by_value +)] + +use std::fmt::Write as _; +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::payment::settlement_return::SettlementReturnInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::allocate::{ + AllocateRequest, AllocationService, MAX_INVOICES_PER_ALLOCATION, +}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::payment::settlement_return::SettlementReturnService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{PaymentRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot a container, migrate on a raw connection, and return a +/// `bss`-search-path `DBProvider`. Mirrors `postgres_payments::boot`. +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller ids for the concurrency tests (mirrors +/// `postgres_payments::Seller`). +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision a seller: USD@2 scale, an OPEN fiscal period for the current +/// month, and the four payment-flow chart accounts (CASH_CLEARING debit, +/// UNALLOCATED credit, PSP_FEE_EXPENSE debit, AR debit). Mirrors +/// `postgres_payments::setup_seller`. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn allocate_svc(provider: &DBProvider) -> AllocationService { + AllocationService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn settlement_return_svc(provider: &DBProvider) -> SettlementReturnService { + SettlementReturnService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn settlement_input(s: &Seller, payment_id: &str, gross: i64, fee: i64) -> SettlementInput { + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "USD".to_owned(), + effective_at: None, + } +} + +async fn ar_invoice_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND invoice_id='{}'", + s.tenant, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn count_allocations(raw: &sea_orm::DatabaseConnection, s: &Seller, payment_id: &str) -> i64 { + raw.query_one(pg(format!( + "SELECT COUNT(*) FROM bss.ledger_payment_allocation \ + WHERE tenant_id='{}' AND payment_id='{}'", + s.tenant, payment_id + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +/// Client-side retry of a service call on a projector-level serialization +/// conflict. Under `SERIALIZABLE`, two ops racing the same balance grain make +/// one abort with a 40001 that the projector stringifies into +/// `DomainError::Internal("…could not serialize…")` — decision O defers +/// in-service recompute-on-retry to the CALLER, so the test models what the +/// SDK/client does: re-run the WHOLE operation (re-reading open-AR, re-deciding +/// splits) until it commits or hits a real business rejection. A genuine +/// deadlock (40P01) is NOT a serialization conflict and propagates — failing the +/// test loudly, which is exactly the deadlock-freedom guarantee. +async fn retry_on_serialization(mut op: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for _ in 0..20 { + match op().await { + Err(DomainError::Internal(m)) if m.contains("serialize") => {} + other => return other, + } + } + op().await +} + +/// Seed an OPEN AR invoice by posting a balanced `DR AR (invoice_id) / CR +/// PSP_FEE_EXPENSE` directly through the engine (mirrors +/// `postgres_payments::seed_ar_invoice`). PSP_FEE_EXPENSE is unguarded, so this +/// lands a clean `ar_invoice_balance` row with `original_posted_at = posted_at` +/// (the oldest-first sort key). +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Financial #G2: two CONCURRENT partial returns of the SAME payment +/// must BOTH commit — neither legal return may be falsely rejected. settle gross +/// 100 / fee 3 (settled=100, fee=3); two returns of 50 race, barrier-started for +/// maximum overlap. Pre-fix both read the SAME `(settled 100, fee 3)` snapshot +/// out-of-txn, both size `fee_share = 3×50/100 = 1`, and the second to commit +/// trips the `fee_minor <= settled_minor` CHECK (settled → 0 while fee still 1) — +/// a FALSE `SettlementReturnOverAllocated`. With the recompute-on-conflict loop +/// the loser re-reads `(settled 50, fee 2)`, re-sizes `fee_share = 2`, and +/// commits: the settlement drains fully (settled 0, fee 0), the fee reversed +/// exactly once each (1 + 2 = 3). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_partial_returns_both_commit_and_drain_fee() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle gross 100 with a PSP fee 3: settled=100, fee=3 (net 97 in clearing). + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-CR", 100, 3)) + .await + .expect("settle the payment to be returned"); + + // Two partial returns of 50 each, distinct psp_return_id (genuine returns, not + // idempotent replays), barrier-started so they overlap maximally. + let returns = [("RET-A", 50i64), ("RET-B", 50i64)]; + let barrier = Arc::new(tokio::sync::Barrier::new(returns.len())); + let mut handles = Vec::with_capacity(returns.len()); + for (psp_return_id, amount) in returns { + let provider = provider.clone(); + let scope = scope.clone(); + let barrier = Arc::clone(&barrier); + handles.push(tokio::spawn(async move { + let svc = settlement_return_svc(&provider); + let ctx = SecurityContext::anonymous(); + barrier.wait().await; + // The service already recomputes fee_share on a cap-conflict; this + // wraps the projector-level 40001 the engine surfaces, matching how a + // client drives the op. + retry_on_serialization(|| { + svc.return_settlement( + &ctx, + &scope, + SettlementReturnInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CR".to_owned(), + psp_return_id: psp_return_id.to_owned(), + amount_minor: amount, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + }) + .await + })); + } + + for handle in handles { + handle + .await + .expect("return task must not panic") + .expect("both concurrent partial returns must commit — neither falsely rejected"); + } + + // Both returns landed: the settlement drained fully — settled 0, fee 0 — so + // `fee <= settled` held at every step and the fee was reversed exactly once + // each (1 + 2 = 3), neither stranded nor double-counted. + let row = PaymentRepo::new(provider.clone()) + .read_settlement(&scope, s.tenant, "PAY-CR") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!(row.settled_minor, 0, "both returns drained settled to 0"); + assert_eq!(row.fee_minor, 0, "the fee was fully reversed (1 + 2 = 3)"); +} + +/// Financial #G2-1: N concurrent allocates of the SAME payment racing its +/// per-payment money-out cap. The shared UNALLOCATED pool is funded ABOVE the +/// capped payment's settled amount by a SECOND payment (PAY-OTHER @ 500), so the +/// no-negative pool guard is NOT what trips — the per-payment cap CHECK +/// (`allocated_minor <= settled_minor`) is the sole authority. Four allocates of +/// lump=100 each race against PAY-CAP's settled=100: AT MOST one may commit, and +/// `allocated_minor` must NEVER exceed 100. Every loser is `MoneyOutCapExceeded` +/// — the client retries the projector-level serialization conflict (decision O +/// defers recompute-on-retry to the caller), so once the rows serialize a loser +/// re-reads `allocated_minor == 100` and surfaces the cap CHECK. Mirrors +/// `postgres_posting::concurrent_overdraw_of_guarded_account_stays_non_negative`, +/// scaled to a barrier-started N=4 fan-out. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_allocate_respects_per_payment_cap() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle the capped payment at 100, and a second payment at 500 for the SAME + // payer so the shared pool holds 600 — the pool stays positive throughout, so + // any rejection is the per-payment cap, NOT the no-negative pool guard. + let settle = settle_svc(&provider); + settle + .settle(&ctx, &scope, settlement_input(&s, "PAY-CAP", 100, 0)) + .await + .expect("settle the capped payment"); + settle + .settle(&ctx, &scope, settlement_input(&s, "PAY-OTHER", 500, 0)) + .await + .expect("settle a second payment to fund the pool"); + + // Seed open AR summing >= 400 (4 invoices @ 100), so each allocate of 100 has + // candidates to drain and the cap — not an empty candidate set — is what + // bounds the running total. + for (i, invoice) in ["INV-1", "INV-2", "INV-3", "INV-4"].iter().enumerate() { + seed_ar_invoice( + &provider, + &s, + invoice, + 100, + Utc::now() - chrono::Duration::hours(4 - i64::try_from(i).unwrap()), + ) + .await; + } + + // Barrier-start N=4 allocates of PAY-CAP @ lump=100, each a DISTINCT + // allocation_id (so the dedup key never collides — every task is a genuine + // fresh allocate racing the cap, not an idempotent replay). The barrier + // releases all four at once onto the multi-thread runtime. + let n = 4usize; + let barrier = Arc::new(tokio::sync::Barrier::new(n)); + let mut handles = Vec::with_capacity(n); + for _ in 0..n { + let provider = provider.clone(); + let scope = scope.clone(); + let barrier = Arc::clone(&barrier); + handles.push(tokio::spawn(async move { + let svc = allocate_svc(&provider); + let ctx = SecurityContext::anonymous(); + // One fixed allocation_id per task: a retry of a serialization-aborted + // attempt is the SAME logical allocate, never a new one. + let allocation_id = Uuid::now_v7(); + barrier.wait().await; + retry_on_serialization(|| { + svc.allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CAP".to_owned(), + allocation_id, + lump_minor: 100, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }, + ) + }) + .await + })); + } + + let mut oks = 0usize; + for handle in handles { + let result = handle.await.expect("allocate task must not panic"); + match result { + Ok(_) => oks += 1, + Err(DomainError::MoneyOutCapExceeded(_)) => {} + Err(other) => { + panic!("a losing concurrent allocate must be MoneyOutCapExceeded, got: {other:?}") + } + } + } + + // INVARIANT: at least one allocate won, and the per-payment cap was never + // exceeded — `allocated_minor` lands at EXACTLY the settled 100. + assert!(oks >= 1, "at least one concurrent allocate must win"); + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY-CAP") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.allocated_minor, 100, + "the per-payment cap is never exceeded: allocated_minor == settled 100" + ); + assert!( + row.allocated_minor <= row.settled_minor, + "allocated_minor ({}) must never exceed settled_minor ({})", + row.allocated_minor, + row.settled_minor + ); + + // Exactly one allocation's worth of rows persisted (the winner applied 100 to + // a single 100-invoice → one payment_allocation row). + assert_eq!( + count_allocations(&raw, &s, "PAY-CAP").await, + 1, + "only the winning allocate wrote its split row" + ); +} + +/// G2-2: an allocate and a concurrent fresh invoice-post for the SAME payer must +/// serialize WITHOUT deadlock, and BOTH effects must land. The two ops touch the +/// shared payer-grain projection (`ar_payer_balance`) in overlapping order — a +/// reversed lock order would deadlock; the engine's SERIALIZABLE + retry must +/// instead serialize them. Asserts both complete (the `tokio::join!` returns +/// without a runtime timeout) and both effects are visible: INV-A drained to 0 +/// by the allocate, INV-B posted at 500 by the concurrent invoice-post. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_and_invoice_post_serialize_without_deadlock() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle 1000 into the pool and seed one open AR (INV-A @ 300). + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY", 1000, 0)) + .await + .expect("settle"); + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + // Race (a) allocate(PAY, lump=300) draining INV-A to 0 against (b) a fresh + // balanced invoice-post DR AR INV-B 500 / CR PSP_FEE 500 for the SAME payer. + // Both hit the payer-grain projection; SERIALIZABLE + retry must serialize + // them, never deadlock. + let alloc_provider = provider.clone(); + let alloc_scope = scope.clone(); + let post_provider = provider.clone(); + let s_post = Seller { + tenant: s.tenant, + payer: s.payer, + cash: s.cash, + unallocated: s.unallocated, + psp_fee: s.psp_fee, + ar: s.ar, + period_id: s.period_id.clone(), + }; + + let allocation_id = Uuid::now_v7(); + let allocate = tokio::spawn(async move { + let svc = allocate_svc(&alloc_provider); + let ctx = SecurityContext::anonymous(); + // Client retries the projector serialization conflict (decision O): the + // allocate ultimately serializes after the concurrent post and commits. + retry_on_serialization(|| { + svc.allocate( + &ctx, + &alloc_scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY".to_owned(), + allocation_id, + lump_minor: 300, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }, + ) + }) + .await + }); + let post = tokio::spawn(async move { + let posting = PostingService::new( + post_provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + ); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s_post.tenant); + let posted_at = Utc::now() - chrono::Duration::minutes(30); + // The concurrent invoice-post serializes against the allocate at the + // shared payer/AR grain; the same client retry lands it. + retry_on_serialization(|| { + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s_post.tenant, + legal_entity_id: s_post.tenant, + period_id: s_post.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "INV-B".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s_post.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + ar_line(&s_post, "INV-B", 500), + psp_credit_line(&s_post, 500), + ]; + posting.post(&ctx, &scope, entry, lines, None) + }) + .await + }); + + let (alloc_res, post_res) = tokio::join!(allocate, post); + alloc_res + .expect("allocate task must not panic / deadlock") + .expect("allocate must succeed"); + post_res + .expect("invoice-post task must not panic / deadlock") + .expect("invoice-post must succeed"); + + // BOTH effects landed: the allocate drained INV-A to 0, and the concurrent + // invoice-post left an INV-B AR row at 500. + assert_eq!( + ar_invoice_balance(&raw, &s, "INV-A").await, + Some(0), + "allocate drained INV-A to zero" + ); + assert_eq!( + ar_invoice_balance(&raw, &s, "INV-B").await, + Some(500), + "the concurrent invoice-post landed INV-B at 500" + ); +} + +/// G2-3: an allocate whose open-AR candidate set exceeds +/// An allocation whose split **touches** more than `MAX_INVOICES_PER_ALLOCATION` +/// (500) invoices is rejected `AllocationTooLarge` BEFORE any post. 501 open +/// `ar_invoice_balance` rows are seeded by a DIRECT bulk SQL INSERT (NOT 501 +/// engine posts — far too slow), each with balance 100, and a lump large enough +/// to reach EVERY one (501 × 100). The split therefore touches all 501, one over +/// the ceiling, so the guard fires before `build_allocation_entry`. Asserts the +/// error is `AllocationTooLarge` and that NO `payment_allocation` rows were +/// written (the guard precedes the post, so nothing is applied and +/// `allocated_minor` stays 0). Contrast `large_backlog_small_lump_allocates`, +/// which shows the same 501-invoice backlog allocates fine when the lump reaches +/// only a few of them — the bound is on invoices touched, not on the backlog. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_too_large_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle a pool large enough for the lump to reach all 501 invoices (501 × + // 100 = 50_100), so the split touches every candidate and trips the + // touched-invoices ceiling. + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY", 50_100, 0)) + .await + .expect("settle"); + + // Bulk-seed 501 open AR invoices for (tenant, payer, ar, USD) in ONE + // multi-row INSERT. Each row carries a DISTINCT invoice_id (the 4th PK + // column), a positive balance_minor (satisfies chk_ar_invoice_balance_no + // _negative AND the `balance_minor > 0` candidate filter), and the seller's + // AR account_id / payer / USD that `list_open_ar_invoices` filters on. Only + // the NOT-NULL-without-default columns are supplied; original_posted_at / + // due_date / last_entry_seq are nullable, version/ balance default-eligible + // but balance is set explicitly. + let mut sql = String::from( + "INSERT INTO bss.ledger_ar_invoice_balance \ + (tenant_id, payer_tenant_id, account_id, invoice_id, currency, balance_minor) VALUES ", + ); + // One over the ceiling — kept in lockstep with the source constant so the + // test tracks any future change to the candidate cap. + let count = MAX_INVOICES_PER_ALLOCATION + 1; + for i in 0..count { + if i > 0 { + sql.push(','); + } + write!( + sql, + "('{}','{}','{}','INV-{:04}','USD',100)", + s.tenant, s.payer, s.ar, i + ) + .unwrap(); + } + raw.execute(pg(sql)).await.expect("bulk-seed 501 AR rows"); + + // Sanity: exactly 501 open candidates exist for the payer. + let seeded = raw + .query_one(pg(format!( + "SELECT COUNT(*) FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND payer_tenant_id='{}' AND currency='USD' \ + AND balance_minor > 0", + s.tenant, s.payer + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()); + assert_eq!( + seeded, + i64::try_from(count).unwrap(), + "501 open candidates seeded" + ); + + // The split touches all 501 invoices (the lump covers every one) — one over + // the ceiling (500) → AllocationTooLarge, raised before any decision / post. + let err = allocate_svc(&provider) + .allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 50_100, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }, + ) + .await + .expect_err("an over-ceiling allocate must be rejected"); + assert!( + matches!(err, DomainError::AllocationTooLarge(_)), + "expected AllocationTooLarge, got {err:?}" + ); + + // The guard fired BEFORE the post: no allocation rows, allocated_minor still 0. + assert_eq!( + count_allocations(&raw, &s, "PAY").await, + 0, + "the too-large guard precedes the post — no allocation rows written" + ); + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.allocated_minor, 0, + "nothing was applied (the post never ran)" + ); +} + +/// The size bound is on invoices ACTUALLY touched, not on the open-invoice +/// backlog: a payer with 501 open invoices whose payment reaches only a few of +/// them allocates fine. Seeds the same 501-invoice backlog as +/// `allocate_too_large_is_rejected` but a small lump (300 over invoices of 100), +/// so the split touches 3 invoices and posts. Regression guard for the fix that +/// moved the cap off the candidate count onto the split. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn large_backlog_small_lump_allocates() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY", 1000, 0)) + .await + .expect("settle"); + + // Bulk-seed 501 open AR invoices (a large backlog), each balance 100. + let mut sql = String::from( + "INSERT INTO bss.ledger_ar_invoice_balance \ + (tenant_id, payer_tenant_id, account_id, invoice_id, currency, balance_minor) VALUES ", + ); + let count = MAX_INVOICES_PER_ALLOCATION + 1; + for i in 0..count { + if i > 0 { + sql.push(','); + } + write!( + sql, + "('{}','{}','{}','INV-{:04}','USD',100)", + s.tenant, s.payer, s.ar, i + ) + .unwrap(); + } + raw.execute(pg(sql)).await.expect("bulk-seed 501 AR rows"); + + // The invoice-grain rows above are only part of the projection — a real + // posting also maintains the AR account-level and per-payer aggregates, both + // guarded no-negative. Seed them to the backlog total (501 × 100) so the + // CR AR relief has headroom at every guarded grain, not just the invoice one. + let ar_total = i64::try_from(count).unwrap() * 100; + raw.execute(pg(format!( + "INSERT INTO bss.ledger_account_balance \ + (tenant_id, account_id, currency, account_class, normal_side, balance_minor) \ + VALUES ('{}','{}','USD','AR','DR',{ar_total})", + s.tenant, s.ar + ))) + .await + .expect("seed AR account_balance aggregate"); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_ar_payer_balance \ + (tenant_id, payer_tenant_id, account_id, currency, balance_minor) \ + VALUES ('{}','{}','{}','USD',{ar_total})", + s.tenant, s.payer, s.ar + ))) + .await + .expect("seed AR payer_balance aggregate"); + + // A small lump (300) reaches only the 3 oldest invoices (100 each), so the + // split touches 3 — far under the ceiling — and the allocation posts. + allocate_svc(&provider) + .allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 300, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }, + ) + .await + .expect("a small lump against a large backlog must allocate"); + + assert_eq!( + count_allocations(&raw, &s, "PAY").await, + 3, + "300 over invoices of 100 touches exactly 3", + ); + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!(row.allocated_minor, 300, "three invoices × 100 allocated"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_payment_returns.rs b/gears/bss/ledger/ledger/tests/postgres_payment_returns.rs new file mode 100644 index 000000000..a2d904f07 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_payment_returns.rs @@ -0,0 +1,474 @@ +//! Postgres-only service-level tests for the settlement-return flow (Phase 4, +//! Group A + Group D Model N): `SettlementReturnService` posts the symmetric +//! reverse of settle — `DR UNALLOCATED amount / CR CASH_CLEARING (amount − +//! fee_share) / CR PSP_FEE_EXPENSE fee_share` — and decrements BOTH the original +//! payment's `settled_minor` and `fee_minor` in the same txn. Ignored by default; +//! run with +//! `cargo test -p bss-ledger --test postgres_payment_returns -- --ignored`. +//! +//! Covers: (a) a return after a settle decrements `settled_minor` and drains the +//! pool by the returned amount; (b) a re-posted return (same `psp_return_id`) +//! replays idempotently — `settled_minor` decrements exactly once; (c) a return +//! exceeding the still-returnable settled amount trips the per-payment cap CHECK +//! and surfaces as `SettlementReturnOverAllocated`, leaving the row untouched; +//! (d) a fee-bearing FULL return reverses CASH_CLEARING by the NET and +//! PSP_FEE_EXPENSE by the fee (Model N), zeroing both `settled_minor` and +//! `fee_minor`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::payment::settlement_return::SettlementReturnInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::payment::settlement_return::SettlementReturnService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{PaymentRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::{Datelike, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot a container, migrate on a raw connection, and return a `bss`-search-path +/// `DBProvider` (the provisioning-test idiom). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller for the return flow: the classes a settle + return touch +/// (`CASH_CLEARING`, `UNALLOCATED`, and — for the fee-bearing Model-N reverse — +/// `PSP_FEE_EXPENSE`). `cash` / `psp_fee` ids are retained so a test can read +/// their `account_balance`. +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + psp_fee: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision the seller: USD@2 scale, an OPEN fiscal period for the current +/// month, and the `CASH_CLEARING` / `UNALLOCATED` / `PSP_FEE_EXPENSE` chart +/// accounts. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + Uuid::now_v7(), + AccountClass::Unallocated, + Side::Credit, + ), + // PSP_FEE_EXPENSE is debit-normal (the fee is expensed at settle); the + // Model-N symmetric reverse credits it back on a fee-bearing return. + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +/// Read an account's cached `balance_minor` (USD), or `None` when no row exists. +async fn account_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + account: Uuid, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn return_svc(provider: &DBProvider) -> SettlementReturnService { + SettlementReturnService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +/// Settle `gross` (fee 0) into the pool for `payment_id`. +async fn settle(provider: &DBProvider, s: &Seller, payment_id: &str, gross: i64) { + settle_with_fee(provider, s, payment_id, gross, 0).await; +} + +/// Settle `gross` with a PSP `fee` (Model N): `settle` posts `DR CASH_CLEARING +/// (gross − fee) · DR PSP_FEE_EXPENSE (fee) · CR UNALLOCATED (gross)`, so +/// `CASH_CLEARING` holds only **net**; the counter row is seeded +/// `settled_minor = gross`, `fee_minor = fee`. +async fn settle_with_fee( + provider: &DBProvider, + s: &Seller, + payment_id: &str, + gross: i64, + fee: i64, +) { + settle_svc(provider) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle must succeed"); +} + +fn return_input( + s: &Seller, + payment_id: &str, + psp_return_id: &str, + amount: i64, +) -> SettlementReturnInput { + SettlementReturnInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + psp_return_id: psp_return_id.to_owned(), + amount_minor: amount, + currency: "USD".to_owned(), + effective_at: None, + } +} + +/// A return after a settle decrements `settled_minor` and drains the pool by the +/// returned amount (settle 1000, return 400 ⇒ settled 600, pool 600). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn return_decrements_settled_and_pool() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-1", 1000).await; + return_svc(&provider) + .return_settlement( + &SecurityContext::anonymous(), + &scope, + return_input(&s, "PAY-1", "RET-1", 400), + ) + .await + .expect("return must post"); + + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY-1") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!(row.settled_minor, 600, "settled decremented by the return"); + assert_eq!( + repo.read_unallocated(&scope, s.tenant, s.payer, "USD") + .await + .unwrap(), + 600, + "pool drained by the returned amount" + ); +} + +/// A re-posted return (same `psp_return_id`) replays idempotently — the second +/// call returns `replayed = true` and `settled_minor` decrements exactly once. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn return_replay_is_idempotent() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-2", 1000).await; + let svc = return_svc(&provider); + let fresh = svc + .return_settlement( + &SecurityContext::anonymous(), + &scope, + return_input(&s, "PAY-2", "RET-2", 400), + ) + .await + .expect("fresh return"); + assert!(!fresh.replayed, "first return is fresh"); + + let replay = svc + .return_settlement( + &SecurityContext::anonymous(), + &scope, + return_input(&s, "PAY-2", "RET-2", 400), + ) + .await + .expect("replayed return"); + assert!(replay.replayed, "same psp_return_id replays"); + assert_eq!( + replay.entry_id, fresh.entry_id, + "replay returns the prior id" + ); + + // The decrement applied exactly once (settled 1000 - 400 = 600, not 200). + let row = PaymentRepo::new(provider.clone()) + .read_settlement(&scope, s.tenant, "PAY-2") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!(row.settled_minor, 600, "decrement applied once, not twice"); +} + +/// A return exceeding a payment's OWN settled amount trips the per-payment cap +/// CHECK and surfaces as `SettlementReturnOverAllocated`. A second settlement +/// funds the SHARED unallocated pool so the 1500 return of PAY-3 does not +/// underflow it (which would raise `NegativeBalance` first) — isolating PAY-3's +/// `settled_minor` cap (you can't claw back more than this payment settled). +/// Mirrors `rest_payments::allocate_over_cap`. The row is left untouched (the +/// whole post rolled back). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn return_exceeding_settled_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-3", 1000).await; + // Fund the shared pool (pool = 2000) so the 1500 return clears the pool + // no-negative guard and reaches PAY-3's own settled-cap CHECK. + settle(&provider, &s, "PAY-OTHER", 1000).await; + let err = return_svc(&provider) + .return_settlement( + &SecurityContext::anonymous(), + &scope, + return_input(&s, "PAY-3", "RET-3", 1500), + ) + .await + .expect_err("an over-claw must be rejected"); + assert!( + matches!(err, DomainError::SettlementReturnOverAllocated(_)), + "expected SettlementReturnOverAllocated, got {err:?}" + ); + + // The rejected return rolled back: settled_minor is still the full 1000. + let row = PaymentRepo::new(provider.clone()) + .read_settlement(&scope, s.tenant, "PAY-3") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.settled_minor, 1000, + "rejected return left the row untouched" + ); +} + +/// Fee-bearing FULL return (Model N, D1 — the mirror of settle): settle gross 100 +/// with fee 3 ⇒ `DR CASH_CLEARING 97 · DR PSP_FEE_EXPENSE 3 · CR UNALLOCATED +/// 100`, so CASH_CLEARING holds only the net 97. A full return of 100 reverses it +/// symmetrically: `DR UNALLOCATED 100 · CR CASH_CLEARING 97 · CR PSP_FEE_EXPENSE +/// 3` ⇒ CASH_CLEARING drained by the NET 97 (97 → 0, NOT by the gross 100, which +/// would underflow the guarded clearing), PSP_FEE_EXPENSE reversed by 3 (3 → 0), +/// the pool emptied, and BOTH `settled_minor` and `fee_minor` zeroed. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn fee_bearing_full_return_reverses_net_and_fee() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let scope = AccessScope::for_tenant(s.tenant); + + // Settle gross 100 / fee 3 ⇒ CASH_CLEARING = net 97, PSP_FEE_EXPENSE = 3. + settle_with_fee(&provider, &s, "PAY-FEE", 100, 3).await; + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(97), + "settle lands NET 97 in CASH_CLEARING" + ); + assert_eq!( + account_balance(&raw, &s, s.psp_fee).await, + Some(3), + "the 3 fee is expensed to PSP_FEE_EXPENSE" + ); + + // Full return of the gross 100: symmetric reverse of settle. + return_svc(&provider) + .return_settlement( + &SecurityContext::anonymous(), + &scope, + return_input(&s, "PAY-FEE", "RET-FEE", 100), + ) + .await + .expect("fee-bearing full return must post"); + + // CASH_CLEARING reversed by the NET 97 (→ 0), not the gross 100. + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(0), + "CASH_CLEARING reversed by the net 97 (97 → 0)" + ); + // PSP_FEE_EXPENSE reversed by the full fee 3 (→ 0). + assert_eq!( + account_balance(&raw, &s, s.psp_fee).await, + Some(0), + "PSP_FEE_EXPENSE reversed by the fee 3 (3 → 0)" + ); + // The pool is emptied (gross 100 in at settle, 100 out at return). + assert_eq!( + PaymentRepo::new(provider.clone()) + .read_unallocated(&scope, s.tenant, s.payer, "USD") + .await + .unwrap(), + 0, + "the unallocated pool is emptied by the full return" + ); + // BOTH counters zeroed (settled 100 → 0, fee 3 → 0). + let row = PaymentRepo::new(provider.clone()) + .read_settlement(&scope, s.tenant, "PAY-FEE") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!(row.settled_minor, 0, "settled_minor zeroed by the return"); + assert_eq!( + row.fee_minor, 0, + "fee_minor zeroed by the proportional fee reverse" + ); +} + +/// Regression (cross-currency return, H2): a settlement return sizes the +/// proportional fee slice off the SETTLED payment's counters and posts in the +/// request currency, so a return whose currency differs from the settlement (a +/// mistyped or malicious EUR return on a USD payment) must be rejected as +/// `CurrencyMismatch` before the fee share is sized or any leg posts — leaving +/// the settlement counters untouched. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn return_in_wrong_currency_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-XC", 1000).await; // settled in USD + let err = return_svc(&provider) + .return_settlement( + &SecurityContext::anonymous(), + &scope, + SettlementReturnInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-XC".to_owned(), + psp_return_id: "RET-XC".to_owned(), + amount_minor: 400, + currency: "EUR".to_owned(), // != settled USD + effective_at: None, + }, + ) + .await + .expect_err("a cross-currency return must be rejected"); + assert!( + matches!(err, DomainError::CurrencyMismatch(_)), + "expected CurrencyMismatch, got {err:?}" + ); + + // The settlement counters are untouched (nothing decremented). + let row = PaymentRepo::new(provider.clone()) + .read_settlement(&scope, s.tenant, "PAY-XC") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.settled_minor, 1000, + "a rejected return decrements nothing" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_payments.rs b/gears/bss/ledger/ledger/tests/postgres_payments.rs new file mode 100644 index 000000000..1056fba98 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_payments.rs @@ -0,0 +1,1393 @@ +//! Postgres-only repo-level tests for `PaymentRepo` (the payment counter +//! tables + the allocation candidate / view reads). Ignored by default; run +//! with `cargo test -p bss-ledger --test postgres_payments -- --ignored`. +//! +//! Covers: (a) `seed_settlement` then `read_settlement` round-trips +//! `settled_minor`; (b) two `add_allocated` calls net; (c) `add_allocated` +//! past `settled_minor` trips the cap CHECK → `MoneyOutCapExceeded`; +//! (d) `insert_allocation_rows` then `list_payment_allocations` returns N +//! rows; (e) `list_open_ar_invoices` filters `balance_minor > 0` and orders +//! `original_posted_at, invoice_id`; (f) `bump_allocation_refund` nets and its +//! refund-vs-allocated CHECK is enforced. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::needless_pass_by_value +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine, RepoError}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::precedence::Allocated; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::allocate::{ + AllocateRequest, AllocationOutcome, AllocationService, AppliedAllocation, +}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::payment_repo::NewAllocationRow; +use bss_ledger::infra::storage::repo::{PaymentRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DbErr, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Lift a component `RepoError` into a `DbError` so the repo write can be the +/// transaction's typed success value (`T`), surviving COMMIT (mirrors +/// `postgres_idempotency.rs`). +fn lift(e: RepoError) -> DbError { + DbError::Sea(DbErr::Custom(e.to_string())) +} + +/// Boot a container, run the chain on a raw connection, and return a +/// `bss`-search-path `DBProvider` for the repo (the provisioning-test idiom). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn seed_then_read_settlement() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let payment_id = "psp-1"; + let scope = AccessScope::allow_all(); + + provider + .transaction(|txn| { + let scope = scope.clone(); + Box::pin(async move { + PaymentRepo::seed_settlement(txn, &scope, tenant, payment_id, "USD", 1000, 0) + .await + .map_err(lift) + }) + }) + .await + .expect("seed settlement"); + + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, tenant, payment_id) + .await + .expect("read settlement") + .expect("settlement row present"); + assert_eq!(row.settled_minor, 1000); + assert_eq!(row.allocated_minor, 0); + assert_eq!(row.currency, "USD"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn add_allocated_nets_and_caps() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let payment_id = "psp-2"; + let scope = AccessScope::allow_all(); + + // Seed settled=1000. + provider + .transaction(|txn| { + let scope = scope.clone(); + Box::pin(async move { + PaymentRepo::seed_settlement(txn, &scope, tenant, payment_id, "USD", 1000, 0) + .await + .map_err(lift) + }) + }) + .await + .expect("seed"); + + // Two increments net to 500. + for delta in [300_i64, 200_i64] { + provider + .transaction(|txn| { + let scope = scope.clone(); + Box::pin(async move { + PaymentRepo::add_allocated(txn, &scope, tenant, payment_id, delta) + .await + .map_err(lift) + }) + }) + .await + .expect("add allocated"); + } + + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, tenant, payment_id) + .await + .unwrap() + .unwrap(); + assert_eq!(row.allocated_minor, 500, "300 + 200 nets to 500"); + + // A third increment of 600 would push allocated to 1100 > settled 1000 → + // the cap CHECK rejects it; the repo maps it to MoneyOutCapExceeded. + let err = provider + .transaction(|txn| { + let scope = scope.clone(); + Box::pin(async move { + PaymentRepo::add_allocated(txn, &scope, tenant, payment_id, 600) + .await + .map_err(lift) + }) + }) + .await + .expect_err("over-cap allocate must be rejected"); + assert!( + err.to_string().contains("money-out cap exceeded"), + "expected MoneyOutCapExceeded, got: {err}" + ); + + // The rejected increment rolled back: allocated stays 500. + let row = repo + .read_settlement(&scope, tenant, payment_id) + .await + .unwrap() + .unwrap(); + assert_eq!(row.allocated_minor, 500, "over-cap increment rolled back"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn insert_then_list_allocations() { + let (_c, _raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let payment_id = "psp-3"; + let allocation_id = Uuid::now_v7(); + let scope = AccessScope::allow_all(); + + let rows = vec![ + NewAllocationRow { + tenant_id: tenant, + allocation_id, + payer_tenant_id: payer, + payment_id: payment_id.to_owned(), + invoice_id: "inv-a".to_owned(), + amount_minor: 300, + currency: "USD".to_owned(), + precedence_policy_ref: "oldest-first.v1".to_owned(), + allocated_at_utc: chrono::Utc::now(), + }, + NewAllocationRow { + tenant_id: tenant, + allocation_id, + payer_tenant_id: payer, + payment_id: payment_id.to_owned(), + invoice_id: "inv-b".to_owned(), + amount_minor: 200, + currency: "USD".to_owned(), + precedence_policy_ref: "oldest-first.v1".to_owned(), + allocated_at_utc: chrono::Utc::now(), + }, + ]; + + provider + .transaction(|txn| { + let scope = scope.clone(); + let rows = rows; + Box::pin(async move { + PaymentRepo::insert_allocation_rows(txn, &scope, &rows) + .await + .map_err(lift) + }) + }) + .await + .expect("insert allocation rows"); + + let repo = PaymentRepo::new(provider.clone()); + let listed = repo + .list_payment_allocations(&scope, tenant, payment_id) + .await + .expect("list allocations"); + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].invoice_id, "inv-a", "ordered by invoice_id"); + assert_eq!(listed[0].amount_minor, 300); + assert_eq!(listed[1].invoice_id, "inv-b"); + assert_eq!(listed[1].amount_minor, 200); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn list_open_ar_invoices_filters_and_orders() { + let (_c, raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let account = Uuid::now_v7(); + let scope = AccessScope::allow_all(); + + // Seed three AR-invoice cache rows: two open (different posted dates) and + // one fully paid (balance 0, must be filtered out). inv-late posts AFTER + // inv-early, so oldest-first puts inv-early first. + let insert = |invoice: &str, balance: i64, posted: &str| { + pg(format!( + "INSERT INTO bss.ledger_ar_invoice_balance + (tenant_id, payer_tenant_id, account_id, invoice_id, currency, + balance_minor, original_posted_at) + VALUES ('{tenant}','{payer}','{account}','{invoice}','USD', + {balance}, '{posted}')" + )) + }; + raw.execute(insert("inv-late", 800, "2026-02-01T00:00:00Z")) + .await + .unwrap(); + raw.execute(insert("inv-early", 300, "2026-01-01T00:00:00Z")) + .await + .unwrap(); + raw.execute(insert("inv-paid", 0, "2026-01-15T00:00:00Z")) + .await + .unwrap(); + + let repo = PaymentRepo::new(provider.clone()); + let open = repo + .list_open_ar_invoices(&scope, tenant, payer, "USD") + .await + .expect("list open ar invoices"); + assert_eq!( + open.len(), + 2, + "the paid (0-balance) invoice is filtered out" + ); + assert_eq!(open[0].invoice_id, "inv-early", "oldest posted first"); + assert_eq!(open[0].balance_minor, 300); + assert_eq!(open[1].invoice_id, "inv-late"); + assert_eq!(open[1].balance_minor, 800); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn bump_allocation_refund_nets_and_caps() { + let (_c, raw, provider) = boot().await; + let tenant = Uuid::now_v7(); + let payment_id = "psp-4"; + let scope = AccessScope::allow_all(); + + // Two bumps net the allocated counter to 500. + for delta in [300_i64, 200_i64] { + provider + .transaction(|txn| { + let scope = scope.clone(); + Box::pin(async move { + PaymentRepo::bump_allocation_refund( + txn, &scope, tenant, payment_id, "inv-a", delta, + ) + .await + .map_err(lift) + }) + }) + .await + .expect("bump allocation refund"); + } + + let allocated: i64 = { + let row = raw + .query_one(pg(format!( + "SELECT allocated_minor FROM bss.ledger_payment_allocation_refund + WHERE tenant_id='{tenant}' AND payment_id='{payment_id}' AND invoice_id='inv-a'" + ))) + .await + .unwrap() + .expect("refund row present"); + row.try_get("", "allocated_minor").unwrap() + }; + assert_eq!(allocated, 500, "300 + 200 nets to 500"); + + // Drive refunded_minor up to allocated (500) directly, then a further + // refund would break refunded_minor <= allocated_minor — verifying the + // CHECK exists on the table (the refund increment is Slice 3's path). + raw.execute(pg(format!( + "UPDATE bss.ledger_payment_allocation_refund SET refunded_minor = 500 + WHERE tenant_id='{tenant}' AND payment_id='{payment_id}' AND invoice_id='inv-a'" + ))) + .await + .expect("refunded_minor = allocated_minor is allowed"); + let err = raw + .execute(pg(format!( + "UPDATE bss.ledger_payment_allocation_refund SET refunded_minor = 600 + WHERE tenant_id='{tenant}' AND payment_id='{payment_id}' AND invoice_id='inv-a'" + ))) + .await + .expect_err("refunded_minor > allocated_minor must be rejected by CHECK"); + assert!( + err.to_string().contains("chk_par_refunded_le_allocated"), + "unexpected error: {err}" + ); +} + +// ── D2/D3 service-level integration: SettlementService + AllocationService ─── +// +// These drive the REAL payment orchestrators (`infra::payment::settle` / +// `::allocate`) through the foundation `PostingService` against the +// testcontainer Postgres: settle lands cash in UNALLOCATED (no AR move), +// allocate drains the pool into AR oldest-first under the per-payment cap, and +// both replay idempotently. `setup_seller` provisions the chart +// (CASH_CLEARING / UNALLOCATED / PSP_FEE_EXPENSE / AR), USD@2, and an OPEN +// fiscal period for the CURRENT month (settle/allocate derive `period_id` from +// `Utc::now()` when no `effective_at` is supplied). + +/// Provisioned seller ids for the service tests. +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision a seller: USD@2 scale, an OPEN fiscal period for the current +/// month, and the four payment-flow chart accounts (CASH_CLEARING debit, +/// UNALLOCATED credit, PSP_FEE_EXPENSE debit, AR debit). Reuses the file's +/// `boot()` for the container/provider. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn allocate_svc(provider: &DBProvider) -> AllocationService { + AllocationService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +/// Unwrap the inline-post arm of an allocate outcome. Every service-level test +/// here settles BEFORE allocating, so the outcome is always `Applied` (the +/// `Queued` arm is the not-yet-settled §4.7 path, exercised separately); a +/// `Queued` here is a test-setup bug, so panic. +fn applied(outcome: AllocationOutcome) -> AppliedAllocation { + match outcome { + AllocationOutcome::Applied(a) => a, + AllocationOutcome::Queued(q) => { + panic!("expected an inline-posted allocation, got Queued: {q:?}") + } + } +} + +fn settlement_input(s: &Seller, payment_id: &str, gross: i64, fee: i64) -> SettlementInput { + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "USD".to_owned(), + // None ⇒ the orchestrator stamps a current-month effective date / + // period (matching the OPEN period `setup_seller` provisions). + effective_at: None, + } +} + +async fn account_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + account: Uuid, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn ar_invoice_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND invoice_id='{}'", + s.tenant, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn count_allocations(raw: &sea_orm::DatabaseConnection, s: &Seller, payment_id: &str) -> i64 { + raw.query_one(pg(format!( + "SELECT COUNT(*) FROM bss.ledger_payment_allocation \ + WHERE tenant_id='{}' AND payment_id='{}'", + s.tenant, payment_id + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +async fn allocation_refund( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + payment_id: &str, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT allocated_minor FROM bss.ledger_payment_allocation_refund \ + WHERE tenant_id='{}' AND payment_id='{}' AND invoice_id='{}'", + s.tenant, payment_id, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Seed an OPEN AR invoice by posting a balanced `DR AR (invoice_id) / CR +/// PSP_FEE_EXPENSE` directly through the engine. PSP_FEE_EXPENSE is unguarded +/// (a CR from zero is allowed) and carries no per-line CHECK, so this lands a +/// clean `ar_invoice_balance` row with `original_posted_at = posted_at` (the +/// oldest-first sort key). `posted_at` is supplied explicitly so the test +/// controls the ordering deterministically. +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn settle_lands_cash_in_unallocated_and_replays() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let service = settle_svc(&provider); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle gross=1000, fee=30 ⇒ DR CASH 970 / DR PSP_FEE 30 / CR UNALLOCATED 1000. + let posted = service + .settle(&ctx, &scope, settlement_input(&s, "PAY-SET-1", 1000, 30)) + .await + .expect("settle must succeed"); + assert!(!posted.replayed, "first settle is fresh"); + + // The payment_settlement counter row is seeded: settled=1000, allocated=0. + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY-SET-1") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!(row.settled_minor, 1000); + assert_eq!(row.allocated_minor, 0); + assert_eq!(row.currency, "USD"); + + // The whole gross parks in the payer's unallocated pool. + assert_eq!( + repo.read_unallocated(&scope, s.tenant, s.payer, "USD") + .await + .unwrap(), + 1000, + "gross lands in UNALLOCATED" + ); + // Net cash hit clearing; AR was untouched (a receipt does not move AR). + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(970), + "CASH_CLEARING = net (gross - fee)" + ); + assert_eq!( + account_balance(&raw, &s, s.ar).await, + None, + "AR untouched by a settlement" + ); + + // Re-settle the SAME payment_id ⇒ idempotent replay, balances unchanged. + let replay = service + .settle(&ctx, &scope, settlement_input(&s, "PAY-SET-1", 1000, 30)) + .await + .expect("re-settle replays"); + assert!(replay.replayed, "second settle of the same payment replays"); + assert_eq!(replay.entry_id, posted.entry_id, "replay returns prior id"); + assert_eq!( + repo.read_unallocated(&scope, s.tenant, s.payer, "USD") + .await + .unwrap(), + 1000, + "UNALLOCATED unchanged on replay" + ); + assert_eq!( + account_balance(&raw, &s, s.cash).await, + Some(970), + "CASH_CLEARING unchanged on replay" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_oldest_first_drains_unallocated_into_ar() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle 1000 into the pool. + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-ALLOC-1", 1000, 0)) + .await + .expect("settle"); + + // Two open AR invoices: INV-A (300) posted earlier, INV-B (800) later. The + // explicit posted_at + lexical id both put INV-A first under oldest-first. + let earlier = Utc::now() - chrono::Duration::hours(2); + let later = Utc::now() - chrono::Duration::hours(1); + seed_ar_invoice(&provider, &s, "INV-A", 300, earlier).await; + seed_ar_invoice(&provider, &s, "INV-B", 800, later).await; + + // Allocate a lump of 500: INV-A fills (300), INV-B gets the remaining 200. + let outcome = allocate_svc(&provider) + .allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-ALLOC-1".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 500, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }, + ) + .await + .expect("allocate must succeed"); + let outcome = applied(outcome); + assert!(!outcome.posting.replayed, "first allocate is fresh"); + let splits: Vec<(String, i64)> = outcome + .splits + .iter() + .map(|a| (a.invoice_id.clone(), a.amount_minor)) + .collect(); + assert_eq!( + splits, + vec![("INV-A".to_owned(), 300), ("INV-B".to_owned(), 200)], + "oldest-first fills INV-A then INV-B" + ); + + // AR drained: INV-A fully paid (0), INV-B down to 600. + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(0)); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-B").await, Some(600)); + + // allocated_minor netted to 500; two payment_allocation rows; refunds 300/200. + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY-ALLOC-1") + .await + .unwrap() + .unwrap(); + assert_eq!( + row.allocated_minor, 500, + "allocated nets to the applied total" + ); + assert_eq!(count_allocations(&raw, &s, "PAY-ALLOC-1").await, 2); + assert_eq!( + allocation_refund(&raw, &s, "PAY-ALLOC-1", "INV-A").await, + Some(300) + ); + assert_eq!( + allocation_refund(&raw, &s, "PAY-ALLOC-1", "INV-B").await, + Some(200) + ); + + // The pool drained by exactly the applied total (1000 - 500 = 500 left). + assert_eq!( + repo.read_unallocated(&scope, s.tenant, s.payer, "USD") + .await + .unwrap(), + 500, + "UNALLOCATED drained by the allocated total" + ); +} + +/// Mode B (§4.4 F-5): a valid caller-computed split SKIPS precedence and posts +/// the EXACT caller amounts (not the oldest-first decision). INV-A is older but +/// the caller deliberately pays INV-B more — the split is applied verbatim, the +/// named invoices' AR drops by those amounts, and the rows record the +/// `caller-split.v1` policy ref (not `oldest-first.v1`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn caller_split_posts_exact_amounts_and_reduces_ar() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-CS-1", 1000, 0)) + .await + .expect("settle"); + let earlier = Utc::now() - chrono::Duration::hours(2); + let later = Utc::now() - chrono::Duration::hours(1); + seed_ar_invoice(&provider, &s, "INV-A", 300, earlier).await; + seed_ar_invoice(&provider, &s, "INV-B", 800, later).await; + + // Oldest-first would fill INV-A (300) then INV-B (200). The caller instead + // pays INV-B 400 and INV-A 100 — a different, deliberate split. + let outcome = allocate_svc(&provider) + .allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CS-1".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 500, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: Some(vec![ + Allocated { + invoice_id: "INV-B".to_owned(), + amount_minor: 400, + }, + Allocated { + invoice_id: "INV-A".to_owned(), + amount_minor: 100, + }, + ]), + }, + ) + .await + .expect("caller split must succeed"); + let outcome = applied(outcome); + assert!(!outcome.posting.replayed, "first allocate is fresh"); + // The split is the caller's, in the caller's order — not the oldest-first + // decision. + let splits: Vec<(String, i64)> = outcome + .splits + .iter() + .map(|a| (a.invoice_id.clone(), a.amount_minor)) + .collect(); + assert_eq!( + splits, + vec![("INV-B".to_owned(), 400), ("INV-A".to_owned(), 100)], + "caller split applied verbatim" + ); + assert_eq!( + outcome.policy_ref, "caller-split.v1", + "Mode B stamps the caller-split audit ref" + ); + + // AR dropped by exactly the caller amounts: INV-A 300→200, INV-B 800→400. + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(200)); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-B").await, Some(400)); + + // Two rows, both stamped caller-split.v1; allocated nets to 500. + assert_eq!(count_allocations(&raw, &s, "PAY-CS-1").await, 2); + let repo = PaymentRepo::new(provider.clone()); + let rows = repo + .list_payment_allocations(&scope, s.tenant, "PAY-CS-1") + .await + .unwrap(); + assert!( + rows.iter() + .all(|r| r.precedence_policy_ref == "caller-split.v1"), + "persisted rows carry the caller-split ref: {rows:?}" + ); + let settlement = repo + .read_settlement(&scope, s.tenant, "PAY-CS-1") + .await + .unwrap() + .unwrap(); + assert_eq!(settlement.allocated_minor, 500); +} + +/// Mode B: a caller split that over-allocates an invoice past its open balance +/// is rejected `AllocationSplitInvalid` BEFORE any post — no AR change, no rows. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn caller_split_over_open_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-CS-OVR", 1000, 0)) + .await + .expect("settle"); + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + // 400 > INV-A's open 300 ⇒ AllocationSplitInvalid (the lump (1000) is ample, + // so the per-invoice open cap is what trips). + let err = allocate_svc(&provider) + .allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CS-OVR".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 1000, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: Some(vec![Allocated { + invoice_id: "INV-A".to_owned(), + amount_minor: 400, + }]), + }, + ) + .await + .expect_err("an over-open caller split must be rejected"); + assert!( + matches!(err, DomainError::AllocationSplitInvalid(_)), + "expected AllocationSplitInvalid, got {err:?}" + ); + + // Rejected before the post: INV-A untouched, no rows, allocated stays 0. + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(300)); + assert_eq!(count_allocations(&raw, &s, "PAY-CS-OVR").await, 0); + let row = PaymentRepo::new(provider.clone()) + .read_settlement(&scope, s.tenant, "PAY-CS-OVR") + .await + .unwrap() + .unwrap(); + assert_eq!( + row.allocated_minor, 0, + "rejected caller split left no effect" + ); +} + +/// Mode B is idempotent on `allocation_id` just like the precedence path: a +/// replay of the same id returns the prior posting and writes no duplicate rows +/// / no double-counted `allocated_minor`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn caller_split_replay_makes_no_duplicate_rows() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-CS-RPL", 1000, 0)) + .await + .expect("settle"); + // Seed open balance well above 2× the split (250): after the first + // allocation drains it to 750 (>= 250), the replay's re-validation against + // the now-drained candidate still admits the same split, so the request + // reaches the engine and replays cleanly (vs a drained-below replay, which + // is correctly rejected — that path is the over-open test's concern). + seed_ar_invoice( + &provider, + &s, + "INV-A", + 1000, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let allocation_id = Uuid::now_v7(); + let request = || AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CS-RPL".to_owned(), + allocation_id, + lump_minor: 1000, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: Some(vec![Allocated { + invoice_id: "INV-A".to_owned(), + amount_minor: 250, + }]), + }; + + let svc = allocate_svc(&provider); + let first = applied(svc.allocate(&ctx, &scope, request()).await.expect("first")); + assert!(!first.posting.replayed, "first is fresh"); + let second = applied(svc.allocate(&ctx, &scope, request()).await.expect("replay")); + assert!(second.posting.replayed, "second is an idempotent replay"); + assert_eq!( + first.posting.entry_id, second.posting.entry_id, + "replay returns the prior entry" + ); + + // Exactly one allocation's worth of effect: one row, AR 1000→750, allocated 250. + assert_eq!(count_allocations(&raw, &s, "PAY-CS-RPL").await, 1); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(750)); + let row = PaymentRepo::new(provider.clone()) + .read_settlement(&scope, s.tenant, "PAY-CS-RPL") + .await + .unwrap() + .unwrap(); + assert_eq!( + row.allocated_minor, 250, + "allocated not double-counted on replay" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_over_settled_cap_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle PAY-CAP-1 at only 100. Settle a SECOND payment (500) for the same + // payer so the shared UNALLOCATED pool holds 600 — the no-negative guard on + // UNALLOCATED is therefore NOT what trips. An allocate of 200 against + // PAY-CAP-1 pushes ITS allocated_minor (200) past ITS settled_minor (100): + // the per-payment cap CHECK in the sidecar is the authority that rejects it + // with MoneyOutCapExceeded, even though the pool is positive (the design's + // "blocks Σ-allocations > settled even when pooled unallocated is positive + // from another payment" case). The whole post rolls back. + let settle = settle_svc(&provider); + settle + .settle(&ctx, &scope, settlement_input(&s, "PAY-CAP-1", 100, 0)) + .await + .expect("settle the capped payment"); + settle + .settle(&ctx, &scope, settlement_input(&s, "PAY-OTHER", 500, 0)) + .await + .expect("settle a second payment to fund the pool"); + seed_ar_invoice( + &provider, + &s, + "INV-CAP", + 200, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let err = allocate_svc(&provider) + .allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CAP-1".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 200, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }, + ) + .await + .expect_err("an over-cap allocate must be rejected"); + assert!( + matches!(err, DomainError::MoneyOutCapExceeded(_)), + "expected MoneyOutCapExceeded, got {err:?}" + ); + + // The rolled-back post left no effect: allocated stays 0, INV-CAP untouched. + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY-CAP-1") + .await + .unwrap() + .unwrap(); + assert_eq!(row.allocated_minor, 0, "over-cap allocate rolled back"); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-CAP").await, Some(200)); + assert_eq!(count_allocations(&raw, &s, "PAY-CAP-1").await, 0); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_currency_mismatch_is_rejected() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle in USD, then allocate in EUR ⇒ AllocationCurrencyMismatch, before + // any candidate read or post. + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-CCY-1", 1000, 0)) + .await + .expect("settle"); + + let err = allocate_svc(&provider) + .allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CCY-1".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 500, + currency: "EUR".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }, + ) + .await + .expect_err("a currency-mismatched allocate must be rejected"); + assert!( + matches!(err, DomainError::AllocationCurrencyMismatch(_)), + "expected AllocationCurrencyMismatch, got {err:?}" + ); +} + +/// Idempotency: two allocates of the SAME `allocation_id` (same payment, same +/// lump) racing on two service clones must land EXACTLY ONE ledger effect — the +/// `PAYMENT_ALLOCATE` dedup key `(tenant, allocation_id)` admits one winner; the +/// loser replays the winner's finalized entry. Exactly the original N +/// `payment_allocation` rows persist and `allocated_minor` is not double-counted. +/// +/// Why concurrent (not sequential): this exercises the racing-claim / SSI path — +/// a concurrent pair both observe the same pre-allocation AR, freeze identical +/// entries (split derivation is outside the engine's retry loop), and the loser +/// replays the winner — the design's true at-most-once-per-`allocation_id` +/// guarantee (mirrors +/// `postgres_invoice_post::concurrent_same_invoice_posts_exactly_once`). A +/// *sequential* re-issue of the same request would ALSO be safe — the dedup hash +/// is request-based (split-independent), so `replay_short_circuit` returns the +/// prior POSTED entry cleanly — it just wouldn't test the contention. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_replay_makes_no_duplicate_rows() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-RPL-1", 1000, 0)) + .await + .expect("settle"); + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(2), + ) + .await; + seed_ar_invoice( + &provider, + &s, + "INV-B", + 800, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + // The SAME allocation_id on both racing requests ⇒ the dedup key collides. + let allocation_id = Uuid::now_v7(); + let request = || AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-RPL-1".to_owned(), + allocation_id, + lump_minor: 500, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }; + + let svc_a = allocate_svc(&provider); + let svc_b = allocate_svc(&provider); + let scope_a = scope.clone(); + let scope_b = scope.clone(); + let ctx_a = SecurityContext::anonymous(); + let ctx_b = SecurityContext::anonymous(); + let (ra, rb) = tokio::join!( + async move { svc_a.allocate(&ctx_a, &scope_a, request()).await }, + async move { svc_b.allocate(&ctx_b, &scope_b, request()).await }, + ); + + // At least one side made progress; the loser replayed (or was rejected), + // never a second ledger effect. + let oks = i32::from(ra.is_ok()) + i32::from(rb.is_ok()); + assert!( + oks >= 1, + "at least one concurrent allocate must succeed: {ra:?} / {rb:?}" + ); + + // Exactly the original two payment_allocation rows persist for the key. + assert_eq!( + count_allocations(&raw, &s, "PAY-RPL-1").await, + 2, + "the same allocation_id adds no duplicate allocation rows" + ); + + // allocated_minor reflects exactly one allocation (500), not 1000. + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY-RPL-1") + .await + .unwrap() + .unwrap(); + assert_eq!( + row.allocated_minor, 500, + "allocated_minor not double-counted across the racing pair" + ); + + // AR drained by exactly one allocation: INV-A 0, INV-B 600. + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(0)); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-B").await, Some(600)); +} + +/// SQL-level BOLA on the new payment grains: rows seeded for tenant A are +/// invisible to a read carried out under a tenant-B `AccessScope`, EVEN when the +/// caller passes tenant A's id in the filter — the `#[secure(tenant_col)]` +/// predicate ANDs `tenant_id ∈ {B}`, so a foreign scope yields nothing. This is +/// the cross-tenant negative READ test the payment-grain coverage was missing; it +/// locks the isolation wiring against a future `#[secure]` regression. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn payment_grains_are_invisible_to_a_foreign_tenant_scope() { + let (_c, raw, provider) = boot().await; + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let payment_id = "PAY-BOLA"; + let own = AccessScope::for_tenant(tenant_a); + let foreign = AccessScope::for_tenant(tenant_b); + + // Seed a settlement + one allocation row for tenant A. + let alloc_id = Uuid::now_v7(); + provider + .transaction(|txn| { + let own = own.clone(); + Box::pin(async move { + PaymentRepo::seed_settlement(txn, &own, tenant_a, payment_id, "USD", 1000, 0) + .await + .map_err(lift)?; + PaymentRepo::insert_allocation_rows( + txn, + &own, + &[NewAllocationRow { + tenant_id: tenant_a, + allocation_id: alloc_id, + payer_tenant_id: payer, + payment_id: payment_id.to_owned(), + invoice_id: "INV-A".to_owned(), + amount_minor: 1000, + currency: "USD".to_owned(), + precedence_policy_ref: "oldest-first.v1".to_owned(), + allocated_at_utc: Utc::now(), + }], + ) + .await + .map_err(lift) + }) + }) + .await + .expect("seed tenant A"); + + // Seed the other three scoped payment grains for tenant A via raw SQL (these + // are projector caches / a policy table with no repo seed helper). They are + // exactly the grains the first negative test missed; the wallet + // (`reusable_credit_subbalance`) is the most sensitive. + let acct = Uuid::now_v7(); + let ts = Utc::now().to_rfc3339(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_reusable_credit_subbalance \ + (tenant_id, payer_tenant_id, account_id, currency, credit_grant_event_type, \ + first_granted_at, balance_minor, version) \ + VALUES ('{tenant_a}','{payer}','{acct}','USD','promo','{ts}',500,0)" + ))) + .await + .expect("seed wallet sub-grain"); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_unallocated_balance \ + (tenant_id, payer_tenant_id, account_id, currency, balance_minor, version) \ + VALUES ('{tenant_a}','{payer}','{acct}','USD',700,0)" + ))) + .await + .expect("seed unallocated pool"); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_tenant_precedence_policy \ + (tenant_id, version, effective_from, strategy, created_at_utc) \ + VALUES ('{tenant_a}',1,'{ts}','oldest-first.v1','{ts}')" + ))) + .await + .expect("seed precedence policy"); + + let repo = PaymentRepo::new(provider.clone()); + + // Tenant A sees its own rows. + assert!( + repo.read_settlement(&own, tenant_a, payment_id) + .await + .unwrap() + .is_some(), + "tenant A reads its own settlement" + ); + assert_eq!( + repo.list_payment_allocations(&own, tenant_a, payment_id) + .await + .unwrap() + .len(), + 1, + "tenant A reads its own allocation" + ); + + // A tenant-B scope sees NOTHING — even passing tenant A's id in the filter. + assert!( + repo.read_settlement(&foreign, tenant_a, payment_id) + .await + .unwrap() + .is_none(), + "a foreign scope cannot read tenant A's settlement (SQL-level BOLA)" + ); + assert!( + repo.list_payment_allocations(&foreign, tenant_a, payment_id) + .await + .unwrap() + .is_empty(), + "a foreign scope cannot read tenant A's allocations (SQL-level BOLA)" + ); + + // The other three grains: own scope sees them; a foreign scope sees nothing. + assert_eq!( + repo.list_credit_subgrains(&own, tenant_a, payer, "USD") + .await + .unwrap() + .len(), + 1, + "tenant A reads its own reusable-credit wallet sub-grain" + ); + assert!( + repo.list_credit_subgrains(&foreign, tenant_a, payer, "USD") + .await + .unwrap() + .is_empty(), + "a foreign scope cannot read tenant A's wallet (SQL-level BOLA)" + ); + assert_eq!( + repo.read_unallocated(&own, tenant_a, payer, "USD") + .await + .unwrap(), + 700, + "tenant A reads its own unallocated pool" + ); + assert_eq!( + repo.read_unallocated(&foreign, tenant_a, payer, "USD") + .await + .unwrap(), + 0, + "a foreign scope reads zero unallocated for tenant A (SQL-level BOLA)" + ); + assert!( + repo.read_effective_policy(&own, tenant_a, Utc::now()) + .await + .unwrap() + .is_some(), + "tenant A reads its own precedence policy" + ); + assert!( + repo.read_effective_policy(&foreign, tenant_a, Utc::now()) + .await + .unwrap() + .is_none(), + "a foreign scope cannot read tenant A's precedence policy (SQL-level BOLA)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_period_close.rs b/gears/bss/ledger/ledger/tests/postgres_period_close.rs new file mode 100644 index 000000000..0d307de8e --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_period_close.rs @@ -0,0 +1,555 @@ +//! Postgres-only integration tests for `PeriodCloseService::close` — the +//! minimal `OPEN→CLOSED` transition gated by a synchronous pre-close tie-out. +//! Boots a container, migrates, seeds reference data + an OPEN period, posts +//! ONE balanced entry through the real `PostingService` (no-op publisher), then: +//! +//! * a cache drift blocks the close (`PeriodCloseBlocked`, tie-out reason) and the +//! period stays `OPEN`; +//! * a clean close succeeds (`already_closed = false`) and is idempotent on a +//! re-close (`already_closed = true`), with the row left `CLOSED`; +//! * closing an unknown period is `PeriodNotFound`. +//! +//! The seed/post harness is copied from `tests/postgres_posting.rs` (each +//! integration test is its own binary, so the helpers can't be shared). +//! Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_period_close -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::period_close::PeriodCloseService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Read the `status` of a `(tenant, le, period)` fiscal-period row. +async fn period_status( + db: &DatabaseConnection, + tenant: Uuid, + legal_entity: Uuid, + period_id: &str, +) -> String { + let row = db + .query_one(pg(format!( + "SELECT status FROM bss.ledger_fiscal_period \ + WHERE tenant_id='{tenant}' AND legal_entity_id='{legal_entity}' \ + AND period_id='{period_id}'" + ))) + .await + .unwrap() + .expect("fiscal_period row must exist"); + row.try_get::("", "status").unwrap() +} + +struct Fixture { + tenant: Uuid, + ar_account: Uuid, + cash_account: Uuid, + legal_entity: Uuid, + period_id: String, +} + +/// Boot, migrate, seed USD@2 + OPEN period + AR/CASH accounts; return the +/// migrate connection, the posting service, the provider, and the fixture ids. +/// (Copied from `tests/postgres_posting.rs::setup`.) +async fn setup( + container_url: &str, +) -> ( + DatabaseConnection, + PostingService, + DBProvider, + Fixture, +) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = "202606".to_owned(); + let ar_account = Uuid::now_v7(); + let cash_account = Uuid::now_v7(); + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{legal_entity}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + + reference + .insert_account(AccountRow { + account_id: ar_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + reference + .insert_account(AccountRow { + account_id: cash_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "CASH_CLEARING".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "CR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + let service = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + ( + raw, + service, + provider, + Fixture { + tenant, + ar_account, + cash_account, + legal_entity, + period_id, + }, + ) +} + +/// Build a balanced entry for `fixture`: DR AR / CR CASH, each `amount`. +/// (Copied from `tests/postgres_posting.rs::balanced_entry`.) +fn balanced_entry(f: &Fixture, business_id: &str, amount: i64) -> (NewEntry, Vec) { + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: f.tenant, + legal_entity_id: f.legal_entity, + period_id: f.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: business_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: f.tenant, + correlation_id: f.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + line(f, f.ar_account, AccountClass::Ar, Side::Debit, amount), + line( + f, + f.cash_account, + AccountClass::CashClearing, + Side::Credit, + amount, + ), + ]; + (entry, lines) +} + +fn line(f: &Fixture, account: Uuid, class: AccountClass, side: Side, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: f.tenant, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Boot + seed + post ONE balanced entry; return the raw conn, the provider, +/// and the fixture. +async fn setup_with_one_balanced_post( + url: &str, +) -> (DatabaseConnection, DBProvider, Fixture) { + let (raw, service, provider, f) = setup(url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + let (entry, lines) = balanced_entry(&f, "biz-1", 1000); + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("balanced post must succeed"); + (raw, provider, f) +} + +fn noop_publisher() -> Arc { + Arc::new(LedgerEventPublisher::noop()) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn close_blocked_by_tieout_variance() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, f) = setup_with_one_balanced_post(&url).await; + + // Drift one cached grain so the pre-close tie-out fails. + raw.execute(pg(format!( + "UPDATE bss.ledger_account_balance SET balance_minor = balance_minor + 1 \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.ar_account + ))) + .await + .unwrap(); + + let err = PeriodCloseService::new( + provider, + noop_publisher(), + std::sync::Arc::new( + bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new(), + ), + ) + .close( + &SecurityContext::anonymous(), + f.tenant, + f.legal_entity, + f.period_id.clone(), + ) + .await + .expect_err("a drifted tenant must block the close"); + // Group B unified the gate: a tie-out defect is one accumulated blocked reason + // on `PeriodCloseBlocked` (design §4.5 blocked_reasons), not a bare + // `PreCloseTieOutFailed`. + assert!( + matches!(&err, DomainError::PeriodCloseBlocked(d) if d.contains("tie-out")), + "got {err:?}" + ); + + // The period must remain OPEN (the flip never ran). + let status = period_status(&raw, f.tenant, f.legal_entity, &f.period_id).await; + assert_eq!(status, "OPEN", "blocked close leaves the period OPEN"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn clean_close_succeeds_and_is_idempotent() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, f) = setup_with_one_balanced_post(&url).await; + let service = PeriodCloseService::new( + provider, + noop_publisher(), + std::sync::Arc::new( + bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new(), + ), + ); + + // --- First close: clean books -> OPEN→CLOSED. --- + let outcome = service + .close( + &SecurityContext::anonymous(), + f.tenant, + f.legal_entity, + f.period_id.clone(), + ) + .await + .expect("a clean close must succeed"); + assert!(!outcome.already_closed, "first close is a fresh close"); + assert_eq!(outcome.period_id, f.period_id); + let status = period_status(&raw, f.tenant, f.legal_entity, &f.period_id).await; + assert_eq!(status, "CLOSED", "the period is now CLOSED"); + + // --- Re-close: idempotent no-op on the already-CLOSED period. --- + let outcome2 = service + .close( + &SecurityContext::anonymous(), + f.tenant, + f.legal_entity, + f.period_id.clone(), + ) + .await + .expect("a re-close must succeed (idempotent)"); + assert!(outcome2.already_closed, "re-close reports already_closed"); + let status2 = period_status(&raw, f.tenant, f.legal_entity, &f.period_id).await; + assert_eq!(status2, "CLOSED", "the period stays CLOSED"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn close_unknown_period_is_not_found() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (_raw, provider, f) = setup_with_one_balanced_post(&url).await; + + let err = PeriodCloseService::new( + provider, + noop_publisher(), + std::sync::Arc::new( + bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new(), + ), + ) + .close( + &SecurityContext::anonymous(), + f.tenant, + f.legal_entity, + "209901".to_owned(), + ) + .await + .expect_err("an unknown period must not be found"); + assert!(matches!(err, DomainError::PeriodNotFound(_)), "got {err:?}"); +} + +/// Group D: an OPEN close-blocking `exception_queue` row for the period blocks the +/// close (the books are clean, so the exception is the ONLY blocker — the gate must +/// surface it as `PeriodCloseBlocked` and leave the period OPEN). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn close_blocked_by_open_exception() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, f) = setup_with_one_balanced_post(&url).await; + + // Seed one OPEN close-blocking exception bound to the period. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_exception_queue \ + (tenant_id, exception_id, exception_type, business_ref, status, period_id, opened_at) \ + VALUES ('{}','{}','RECON_MISMATCH','inv-x','OPEN','{}', now())", + f.tenant, + Uuid::now_v7(), + f.period_id + ))) + .await + .unwrap(); + + let err = PeriodCloseService::new( + provider, + noop_publisher(), + std::sync::Arc::new( + bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new(), + ), + ) + .close( + &SecurityContext::anonymous(), + f.tenant, + f.legal_entity, + f.period_id.clone(), + ) + .await + .expect_err("an OPEN exception must block the close"); + assert!( + matches!(err, DomainError::PeriodCloseBlocked(_)), + "got {err:?}" + ); + + let status = period_status(&raw, f.tenant, f.legal_entity, &f.period_id).await; + assert_eq!(status, "OPEN", "a blocked close leaves the period OPEN"); + + // The blocked close records `period_close = CLOSING` + the reasons (dashboard). + let close_status: String = raw + .query_one(pg(format!( + "SELECT status FROM bss.ledger_period_close \ + WHERE tenant_id='{}' AND legal_entity_id='{}' AND period_id='{}'", + f.tenant, f.legal_entity, f.period_id + ))) + .await + .unwrap() + .expect("period_close row written on a blocked close") + .try_get::("", "status") + .unwrap(); + assert_eq!( + close_status, "CLOSING", + "a blocked close parks period_close=CLOSING" + ); +} + +/// Group D: a recognition segment due `<=` the closing period and not `DONE` +/// blocks the close (design §4.5 — the period-N gate waits for the recognition +/// run). Clean books, so the due segment is the only blocker. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn close_blocked_by_due_recognition_segment() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, f) = setup_with_one_balanced_post(&url).await; + + // Seed one due-but-not-DONE recognition segment in the closing period. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_recognition_segment \ + (tenant_id, schedule_id, segment_no, period_id, amount_minor, status) \ + VALUES ('{}','SCH-D',1,'{}',1000,'PENDING')", + f.tenant, f.period_id + ))) + .await + .unwrap(); + + let err = PeriodCloseService::new( + provider, + noop_publisher(), + std::sync::Arc::new( + bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new(), + ), + ) + .close( + &SecurityContext::anonymous(), + f.tenant, + f.legal_entity, + f.period_id.clone(), + ) + .await + .expect_err("a due-not-DONE recognition segment must block the close"); + assert!( + matches!(err, DomainError::PeriodCloseBlocked(_)), + "got {err:?}" + ); + + let status = period_status(&raw, f.tenant, f.legal_entity, &f.period_id).await; + assert_eq!(status, "OPEN", "a blocked close leaves the period OPEN"); +} + +/// Group D: the dual-control reopen replay (what the executor calls on an approved +/// `PeriodReopen`) flips a CLOSED period back to OPEN, lands `period_close` = +/// REOPENED, and is idempotent on an already-OPEN period. Reopen is ALWAYS +/// dual-control (no inline path), so this models the post-approval executor replay. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reopen_after_close_flips_to_open_and_records_reopened() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, f) = setup_with_one_balanced_post(&url).await; + let service = PeriodCloseService::new( + provider, + noop_publisher(), + std::sync::Arc::new( + bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new(), + ), + ); + + // Clean close → CLOSED. + service + .close( + &SecurityContext::anonymous(), + f.tenant, + f.legal_entity, + f.period_id.clone(), + ) + .await + .expect("a clean close must succeed"); + assert_eq!( + period_status(&raw, f.tenant, f.legal_entity, &f.period_id).await, + "CLOSED" + ); + + // Reopen (the executor replays this on an approved PeriodReopen; `approver` is + // the distinct second actor the dual-control flow enforced upstream). + let approver = Uuid::now_v7(); + service + .reopen(f.tenant, f.legal_entity, &f.period_id, approver) + .await + .expect("reopen flips CLOSED→OPEN"); + + assert_eq!( + period_status(&raw, f.tenant, f.legal_entity, &f.period_id).await, + "OPEN", + "reopen flips the fiscal period back to OPEN" + ); + let close_status: String = raw + .query_one(pg(format!( + "SELECT status FROM bss.ledger_period_close \ + WHERE tenant_id='{}' AND legal_entity_id='{}' AND period_id='{}'", + f.tenant, f.legal_entity, f.period_id + ))) + .await + .unwrap() + .expect("period_close row present after reopen") + .try_get::("", "status") + .unwrap(); + assert_eq!(close_status, "REOPENED", "period_close lands REOPENED"); + + // Idempotent: a second reopen on the already-OPEN period is a no-op success. + service + .reopen(f.tenant, f.legal_entity, &f.period_id, approver) + .await + .expect("reopen is idempotent on an already-OPEN period"); + assert_eq!( + period_status(&raw, f.tenant, f.legal_entity, &f.period_id).await, + "OPEN", + "a re-reopen leaves the period OPEN" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_period_guard.rs b/gears/bss/ledger/ledger/tests/postgres_period_guard.rs new file mode 100644 index 000000000..484779c01 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_period_guard.rs @@ -0,0 +1,92 @@ +//! Postgres-only: the fiscal-period guard. An `OPEN` period pins clean; a +//! `CLOSED` period and a missing period both yield `PeriodError::Closed`. +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown +)] + +use bss_ledger::infra::posting::period::{FiscalPeriodGuard, PeriodError}; +use bss_ledger::infra::storage::migrations::Migrator; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn pin_open_admits_open_rejects_closed_and_missing() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = "202606"; + let guard = FiscalPeriodGuard::new(); + + // Seed an OPEN period. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{legal_entity}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + + // OPEN → Ok. + let open = run_pin(&provider, &guard, tenant, legal_entity, period_id).await; + assert!(open.is_ok(), "open period must pin: {open:?}"); + + // Flip to CLOSED → Closed. + raw.execute(pg(format!( + "UPDATE bss.ledger_fiscal_period SET status='CLOSED' + WHERE tenant_id='{tenant}' AND legal_entity_id='{legal_entity}' AND period_id='{period_id}'" + ))) + .await + .unwrap(); + let closed = run_pin(&provider, &guard, tenant, legal_entity, period_id).await; + assert_eq!(closed, Err(PeriodError::Closed)); + + // Missing period → Closed. + let missing = run_pin(&provider, &guard, tenant, legal_entity, "209912").await; + assert_eq!(missing, Err(PeriodError::Closed)); +} + +/// Pin a period inside one transaction, returning the guard's typed result +/// (carried out as the transaction's success value so it survives COMMIT). +async fn run_pin( + provider: &DBProvider, + guard: &FiscalPeriodGuard, + tenant: Uuid, + legal_entity: Uuid, + period_id: &str, +) -> Result<(), PeriodError> { + let guard = guard.clone(); + let period_id = period_id.to_owned(); + provider + .transaction(move |txn| { + Box::pin(async move { + Ok::<_, DbError>(guard.pin_open(txn, tenant, legal_entity, &period_id).await) + }) + }) + .await + .unwrap() +} diff --git a/gears/bss/ledger/ledger/tests/postgres_period_open.rs b/gears/bss/ledger/ledger/tests/postgres_period_open.rs new file mode 100644 index 000000000..5945c0c6e --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_period_open.rs @@ -0,0 +1,131 @@ +//! Postgres-only integration test for `PeriodOpenJob` — fiscal-period-open +//! automation. Boots a container, migrates, seeds a `fiscal_calendar` row via +//! raw SQL, then runs the job and asserts it created the current + next +//! `YYYYMM` periods (`status='OPEN'`) and is idempotent on a re-run. +//! Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_period_open -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use bss_ledger::infra::jobs::period_open::PeriodOpenJob; +use bss_ledger::infra::storage::migrations::Migrator; +use chrono::Utc; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Run `SELECT count(*) ...` and return the `i64` count (the reference-test +/// extraction idiom: `row.try_get::("", "count")`). +async fn count(db: &DatabaseConnection, sql: impl Into) -> i64 { + let row = db + .query_one(pg(sql)) + .await + .unwrap() + .expect("count query must return a row"); + row.try_get::("", "count").unwrap() +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn period_open_creates_current_and_next_and_is_idempotent() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + // Raw sea-orm connection for the migrator + bss-qualified setup/assertions. + let db = Database::connect(&url).await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + + // The job connection sets search_path=bss (as the gear config does in prod) + // so its unqualified entity queries resolve into the bss schema. + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let legal_entity = Uuid::now_v7(); + let cur = Utc::now().format("%Y%m").to_string(); + let next = { + // Local YYYYMM +1-month (mirrors `domain::period::next_period_id`) so + // the assertion is independent of the job's own logic. + let year: i32 = cur[0..4].parse().unwrap(); + let month: u32 = cur[4..6].parse().unwrap(); + if month == 12 { + format!("{:04}01", year + 1) + } else { + format!("{year:04}{:02}", month + 1) + } + }; + + // Seed a MONTH calendar for (tenant, le) via raw, bss-qualified SQL. + db.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_calendar (tenant_id, legal_entity_id, fiscal_tz, granularity, fy_start_month) + VALUES ('{tenant}','{legal_entity}','UTC','MONTH',1)" + ))) + .await + .unwrap(); + + // --- Run #1: fresh -> current + next created. --- + let report = PeriodOpenJob::new(provider.clone()) + .run() + .await + .expect("period-open run must succeed"); + assert_eq!(report.periods_created, 2, "current + next period created"); + + // Both periods exist, OPEN, for this (tenant, le). + let open_count = count( + &db, + format!( + "SELECT count(*) FROM bss.ledger_fiscal_period \ + WHERE tenant_id='{tenant}' AND legal_entity_id='{legal_entity}' \ + AND period_id IN ('{cur}','{next}') AND status='OPEN'" + ), + ) + .await; + assert_eq!(open_count, 2, "current + next periods are OPEN"); + + let total_before = count( + &db, + format!( + "SELECT count(*) FROM bss.ledger_fiscal_period \ + WHERE tenant_id='{tenant}' AND legal_entity_id='{legal_entity}'" + ), + ) + .await; + assert_eq!(total_before, 2, "exactly two period rows"); + + // --- Run #2: idempotent -> nothing new. --- + let report2 = PeriodOpenJob::new(provider) + .run() + .await + .expect("second period-open run must succeed"); + assert_eq!(report2.periods_created, 0, "re-run creates no periods"); + + let total_after = count( + &db, + format!( + "SELECT count(*) FROM bss.ledger_fiscal_period \ + WHERE tenant_id='{tenant}' AND legal_entity_id='{legal_entity}'" + ), + ) + .await; + assert_eq!( + total_after, 2, + "row count unchanged after idempotent re-run" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_pii.rs b/gears/bss/ledger/ledger/tests/postgres_pii.rs new file mode 100644 index 000000000..0355c34d5 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_pii.rs @@ -0,0 +1,637 @@ +//! Postgres-only end-to-end: the PII erasure + re-identification path +//! (`ErasureService`, Slice 6 Phase 3 Group 3A, architecture §4.5 / AC #22). +//! Boots a container, migrates, seeds reference data, posts ONE balanced entry +//! for a payer, seeds that payer's `payer_pii_map` row, then drives the erasure / +//! re-identify writes and asserts: +//! * `erase` flips the map tombstone (`erased = true`) and writes ONE +//! `erasure` secured-audit record, while the posted `journal_entry` / +//! `journal_line` `row_hash` is byte-unchanged (the financial truth + its +//! chain are untouched); +//! * `reidentify` returns the `pii_ref` (even of the tombstoned payer) and +//! writes ONE `re-identification` secured-audit record; +//! * `reidentify` without a reason is rejected `MISSING_INVESTIGATION_REASON`, +//! writing NO record. +//! +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::pii::ErasureService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Hex string of a single-column, single-row SELECT, `None` when absent/NULL. +async fn scalar_hex(conn: &DatabaseConnection, sql: &str) -> Option { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.and_then(|r| r.try_get_by_index::>(0).unwrap()) +} + +/// Count rows matching a bss-qualified predicate. +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +/// Read a boolean scalar (`erased`), `None` when absent. +async fn scalar_bool(conn: &DatabaseConnection, sql: &str) -> Option { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.map(|r| r.try_get_by_index::(0).unwrap()) +} + +struct Fixture { + tenant: Uuid, + ar_account: Uuid, + cash_account: Uuid, + legal_entity: Uuid, + period_id: String, +} + +/// Boot, migrate, seed USD@2 + OPEN period + AR/CASH accounts. (Mirrors +/// `postgres_metadata.rs::setup`.) +async fn setup( + container_url: &str, +) -> ( + DatabaseConnection, + PostingService, + DBProvider, + Fixture, +) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = "202606".to_owned(); + let ar_account = Uuid::now_v7(); + let cash_account = Uuid::now_v7(); + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{legal_entity}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + + reference + .insert_account(AccountRow { + account_id: ar_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + reference + .insert_account(AccountRow { + account_id: cash_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "CASH_CLEARING".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "CR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + let service = PostingService::new( + provider.clone(), + std::sync::Arc::new(bss_ledger::infra::events::publisher::LedgerEventPublisher::noop()), + ); + ( + raw, + service, + provider, + Fixture { + tenant, + ar_account, + cash_account, + legal_entity, + period_id, + }, + ) +} + +/// Build a balanced entry for `payer`: DR AR / CR CASH, each `amount`. +fn balanced_entry( + f: &Fixture, + payer: Uuid, + business_id: &str, + amount: i64, +) -> (NewEntry, Vec) { + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: f.tenant, + legal_entity_id: f.legal_entity, + period_id: f.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: business_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: f.tenant, + correlation_id: f.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + line( + f, + payer, + f.ar_account, + AccountClass::Ar, + Side::Debit, + amount, + ), + line( + f, + payer, + f.cash_account, + AccountClass::CashClearing, + Side::Credit, + amount, + ), + ]; + (entry, lines) +} + +fn line( + f: &Fixture, + payer: Uuid, + account: Uuid, + class: AccountClass, + side: Side, + amount: i64, +) -> NewLine { + let _ = f; + NewLine { + line_id: Uuid::now_v7(), + ar_status: None, + payer_tenant_id: payer, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + } +} + +/// The full Group 3A path against Postgres: erase tombstones the map + writes +/// one `erasure` record while the posted journal rows stay byte-unchanged; +/// reidentify returns the ref + writes one `re-identification` record; a +/// reason-less reidentify is rejected and writes nothing. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn erase_tombstones_and_leaves_journal_unchanged_then_reidentify() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + let payer = Uuid::now_v7(); + let pii_ref = "pii-store://payer/abc-123"; + + // Post one balanced entry for the payer; capture the entry + AR-line row_hash + // so we can prove they are byte-unchanged after the erasure. + let (entry, lines) = balanced_entry(&f, payer, "biz-pii", 1000); + let entry_id = entry.entry_id; + let ar_line_id = lines[0].line_id; + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("post must succeed"); + + let entry_hash_before = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.ledger_journal_entry WHERE entry_id='{entry_id}'" + ), + ) + .await + .expect("entry row_hash before"); + + // Seed the payer_pii_map row (raw insert — the upsert path is unit-covered). + raw.execute(pg(format!( + "INSERT INTO bss.payer_pii_map (tenant_id, payer_tenant_id, pii_ref, erased) \ + VALUES ('{}','{payer}','{pii_ref}', false)", + f.tenant + ))) + .await + .unwrap(); + + let svc = ErasureService::new(); + + // S-1: the caller's correlation/trace id must reach the `erasure` record so + // it can be cross-traced to the journal (which carries the same id). + let corr = Uuid::now_v7(); + + // (1) erase tombstones the map + writes ONE `erasure` record. + svc.erase( + &provider, + &ctx, + &scope, + f.tenant, + &scope, + f.tenant, + payer, + "actor-dpo".to_owned(), + "gdpr-erasure-request".to_owned(), + Some(corr), + ) + .await + .expect("erase must succeed"); + + let erased = scalar_bool( + &raw, + &format!( + "SELECT erased FROM bss.payer_pii_map WHERE tenant_id='{}' AND payer_tenant_id='{payer}'", + f.tenant + ), + ) + .await + .expect("map row present"); + assert!(erased, "erase must flip the tombstone to true"); + + let erasure_records = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='erasure'", + f.tenant + ), + ) + .await; + assert_eq!( + erasure_records, 1, + "exactly one erasure secured-audit record" + ); + + // S-1: that record carries the caller's correlation id (not NULL) — the + // cross-trace key back to the journal. + let stored_corr = scalar_hex( + &raw, + &format!( + "SELECT correlation_id::text FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='erasure'", + f.tenant + ), + ) + .await; + assert_eq!( + stored_corr, + Some(corr.to_string()), + "the erasure record must carry the request correlation id (S-1)" + ); + + // (2) The posted journal_entry / journal_line row_hash is byte-unchanged. + let entry_hash_after = scalar_hex( + &raw, + &format!( + "SELECT encode(row_hash,'hex') FROM bss.ledger_journal_entry WHERE entry_id='{entry_id}'" + ), + ) + .await + .expect("entry row_hash after"); + assert_eq!( + entry_hash_before, entry_hash_after, + "journal_entry row_hash must be byte-unchanged by an erasure" + ); + // journal_line carries no row_hash (the entry hash above already covers every + // line field); assert the line row is intact and still carries the un-erased + // internal payer_tenant_id — erasure tombstones only payer_pii_map. + let line_intact = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_line \ + WHERE line_id='{ar_line_id}' AND payer_tenant_id='{payer}'" + ), + ) + .await; + assert_eq!( + line_intact, 1, + "the journal_line still carries the un-erased internal payer_tenant_id" + ); + + // (3) reidentify returns the pii_ref (even of the tombstoned payer) and + // writes ONE `re-identification` record. + let got = svc + .reidentify( + &provider, + &ctx, + &scope, + f.tenant, + &scope, + f.tenant, + payer, + "actor-investigator".to_owned(), + "subpoena 2026-06".to_owned(), + "LEGAL_HOLD".to_owned(), + Some(corr), + ) + .await + .expect("reidentify must succeed"); + assert_eq!(got, pii_ref, "reidentify returns the stored pii_ref"); + + let reid_records = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='re-identification'", + f.tenant + ), + ) + .await; + assert_eq!( + reid_records, 1, + "exactly one re-identification secured-audit record" + ); + + // S-1: the re-identification record also carries the request correlation id. + let stored_reid_corr = scalar_hex( + &raw, + &format!( + "SELECT correlation_id::text FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='re-identification'", + f.tenant + ), + ) + .await; + assert_eq!( + stored_reid_corr, + Some(corr.to_string()), + "the re-identification record must carry the request correlation id (S-1)" + ); + + // (4) reidentify WITHOUT a reason is rejected MISSING_INVESTIGATION_REASON, + // and writes NO additional record. + let err = svc + .reidentify( + &provider, + &ctx, + &scope, + f.tenant, + &scope, + f.tenant, + payer, + "actor-investigator".to_owned(), + " ".to_owned(), + String::new(), + None, + ) + .await + .expect_err("a reason-less reidentify must be rejected"); + assert!( + matches!(err, DomainError::MissingInvestigationReason(_)), + "reason-less reidentify must be MissingInvestigationReason, got: {err:?}" + ); + let reid_after = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='re-identification'", + f.tenant + ), + ) + .await; + assert_eq!( + reid_after, 1, + "a rejected reidentify must write NO additional re-identification record" + ); + + // (5) erase WITHOUT a reason is rejected MISSING_INVESTIGATION_REASON + // (matches reidentify), and writes NO erasure record. + let erasure_before = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='erasure'", + f.tenant + ), + ) + .await; + let err = svc + .erase( + &provider, + &ctx, + &scope, + f.tenant, + &scope, + f.tenant, + payer, + "actor-dpo".to_owned(), + " ".to_owned(), + None, + ) + .await + .expect_err("a reason-less erasure must be rejected"); + assert!( + matches!(err, DomainError::MissingInvestigationReason(_)), + "reason-less erase must be MissingInvestigationReason, got: {err:?}" + ); + let erasure_after = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='erasure'", + f.tenant + ), + ) + .await; + assert_eq!( + erasure_before, erasure_after, + "a rejected erasure must write NO additional erasure record" + ); +} + +/// Z7-4: backfill the thin pii branch coverage at the integration layer — +/// erase is idempotent + non-leaking (a repeat and an unmapped payer both +/// succeed and still record the event, never a 404 that would reveal mapping +/// state), and reidentify of an unmapped payer is `PayerPiiNotFound` (404). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn erase_is_idempotent_and_unmapped_noleak_and_reidentify_404() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, _service, provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + let svc = ErasureService::new(); + + // (1) Idempotency: a mapped payer erased TWICE — both succeed and the + // tombstone stays set (a repeat erase is not an error). + let mapped = Uuid::now_v7(); + raw.execute(pg(format!( + "INSERT INTO bss.payer_pii_map (tenant_id, payer_tenant_id, pii_ref, erased) \ + VALUES ('{}','{mapped}','pii://m', false)", + f.tenant + ))) + .await + .unwrap(); + for _ in 0..2 { + svc.erase( + &provider, + &ctx, + &scope, + f.tenant, + &scope, + f.tenant, + mapped, + "actor-dpo".to_owned(), + "gdpr".to_owned(), + None, + ) + .await + .expect("erase must be idempotent (a repeat still succeeds)"); + } + let tomb = scalar_bool( + &raw, + &format!( + "SELECT erased FROM bss.payer_pii_map WHERE tenant_id='{}' AND payer_tenant_id='{mapped}'", + f.tenant + ), + ) + .await + .expect("map row present"); + assert!(tomb, "the tombstone stays set after a repeat erase"); + + // (2) No-leak: erasing a NEVER-mapped payer still SUCCEEDS (not 404) and + // records the event — so it cannot reveal whether the payer was mapped. + let unmapped = Uuid::now_v7(); + let erasures_before = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='erasure'", + f.tenant + ), + ) + .await; + svc.erase( + &provider, + &ctx, + &scope, + f.tenant, + &scope, + f.tenant, + unmapped, + "actor-dpo".to_owned(), + "gdpr".to_owned(), + None, + ) + .await + .expect("erase of an unmapped payer must succeed (no 404 leak)"); + let erasures_after = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.secured_audit_record \ + WHERE tenant_id='{}' AND event_type='erasure'", + f.tenant + ), + ) + .await; + assert_eq!( + erasures_after, + erasures_before + 1, + "erasing an unmapped payer still records exactly one erasure event" + ); + + // (3) reidentify of a payer with NO map row → PayerPiiNotFound (404). + let err = svc + .reidentify( + &provider, + &ctx, + &scope, + f.tenant, + &scope, + f.tenant, + Uuid::now_v7(), + "actor-inv".to_owned(), + "subpoena".to_owned(), + "LEGAL_HOLD".to_owned(), + None, + ) + .await + .expect_err("reidentify of an unmapped payer must 404"); + assert!( + matches!(err, DomainError::PayerPiiNotFound(_)), + "unmapped reidentify must be PayerPiiNotFound, got: {err:?}" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_policy_version.rs b/gears/bss/ledger/ledger/tests/postgres_policy_version.rs new file mode 100644 index 000000000..ab3f14a75 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_policy_version.rs @@ -0,0 +1,400 @@ +//! Postgres-only integration: the §4.6 (AC #15) `PolicyVersionGuard` — a +//! correction must REUSE the original posting's pinned evidence refs. +//! +//! Drives the REAL foundation engine (`PostingService` + `InvoicePostService`): +//! 1. posts an original entry carrying a `pricing_snapshot_ref` on a line; +//! 2. posts a REVERSAL of it via the real `build_reversal` + `post_reversal` +//! flow — it succeeds, because the reversal's lines carry no pinned refs (all +//! all-NULL tuples, which the guard ignores), so reuse is structural; +//! 3. crafts a correction whose `reverses_entry_id` is the original but whose +//! line carries a DIFFERENT `pricing_snapshot_ref` — the guard rejects it +//! with `PolicyVersionViolation`. +//! +//! Mirrors `postgres_chain.rs` / `postgres_invoice_post.rs`. Ignored by default; +//! run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::similar_names +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::invoice::reversal::build_reversal; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, EntryView, LineView, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Provisioned seller ids (AR + CASH chart, one OPEN period, USD@2). +struct Fixture { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + cash: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot, migrate, seed USD@2 + an OPEN period + AR/CASH accounts. Returns the +/// migrate connection, the search_path-scoped provider, and the fixture ids. +async fn setup( + url: &str, +) -> ( + DatabaseConnection, + PostingService, + InvoicePostService, + Fixture, +) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let f = Fixture { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + cash: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: f.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + f.tenant, f.tenant, f.period_id + ))) + .await + .unwrap(); + reference + .insert_account(account(f.tenant, f.ar, AccountClass::Ar, Side::Debit)) + .await + .unwrap(); + reference + .insert_account(account( + f.tenant, + f.cash, + AccountClass::CashClearing, + Side::Credit, + )) + .await + .unwrap(); + + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let harness = MetricsHarness::new(); + let invoice = InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + bss_ledger::config::RecognitionConfig::default(), + bss_ledger::config::FxConfig::default(), + ); + (raw, posting, invoice, f) +} + +/// One DR AR / CR CASH line pair for `amount`; the AR line carries +/// `pricing_snapshot_ref = snapshot` (the original's pinned evidence). The +/// `reverses_*` header is supplied so the same builder serves the original +/// (None) and the crafted correction (Some). +#[allow(clippy::too_many_arguments)] +fn entry( + f: &Fixture, + entry_id: Uuid, + business_id: &str, + doc_type: SourceDocType, + reverses_entry_id: Option, + snapshot: Option<&str>, +) -> (NewEntry, Vec) { + let header = NewEntry { + entry_id, + tenant_id: f.tenant, + legal_entity_id: f.tenant, + period_id: f.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: doc_type, + source_business_id: business_id.to_owned(), + reverses_entry_id, + reverses_period_id: reverses_entry_id.map(|_| f.period_id.clone()), + posted_at_utc: Utc::now(), + effective_at: naive(2026, 6, 1), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: f.tenant, + correlation_id: f.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + line(f, f.ar, AccountClass::Ar, Side::Debit, 1000, snapshot), + line( + f, + f.cash, + AccountClass::CashClearing, + Side::Credit, + 1000, + None, + ), + ]; + (header, lines) +} + +fn line( + f: &Fixture, + account: Uuid, + class: AccountClass, + side: Side, + amount: i64, + pricing_snapshot_ref: Option<&str>, +) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + ar_status: None, + payer_tenant_id: f.payer, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: pricing_snapshot_ref.map(str::to_owned), + po_allocation_group: None, + credit_grant_event_type: None, + } +} + +/// Synthesize the `EntryView` of the posted original so `build_reversal` can run +/// end-to-end without a private read-back mapper (mirrors +/// `postgres_invoice_post.rs::original_view`). The read-back `LineView` does NOT +/// carry pinned evidence-ref columns, so the reversal it builds has no pinned +/// refs — exactly the production behaviour the guard tolerates. +fn original_view(f: &Fixture, entry_id: Uuid) -> EntryView { + let now: DateTime = Utc::now(); + EntryView { + entry_id, + tenant_id: f.tenant, + period_id: f.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: "orig".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: now, + effective_at: naive(2026, 6, 1), + posted_by_actor_id: f.tenant, + origin: "SYSTEM".to_owned(), + correlation_id: f.tenant, + created_seq: 1, + lines: vec![ + view_line(f, entry_id, f.ar, AccountClass::Ar, Side::Debit, 1000), + view_line( + f, + entry_id, + f.cash, + AccountClass::CashClearing, + Side::Credit, + 1000, + ), + ], + } +} + +fn view_line( + f: &Fixture, + entry_id: Uuid, + account: Uuid, + class: AccountClass, + side: Side, + amount: i64, +) -> LineView { + LineView { + line_id: Uuid::now_v7(), + ar_status: None, + entry_id, + payer_tenant_id: f.payer, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + } +} + +/// The full AC #15 flow end-to-end: original (pinned ref) → real reversal +/// (passes, no pinned refs) → crafted correction with a DIFFERENT pinned ref +/// (rejected `PolicyVersionViolation`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn correction_must_reuse_original_evidence_refs() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, posting, invoice, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // 1. Post the ORIGINAL — its AR line carries a pinned `pricing_snapshot_ref`. + let original_id = Uuid::now_v7(); + let (e0, l0) = entry( + &f, + original_id, + "orig", + SourceDocType::ManualAdjustment, + None, + Some("snap-ORIGINAL"), + ); + posting + .post(&ctx, &scope, e0, l0, None) + .await + .expect("original post must succeed"); + + // 2. Post a REVERSAL via the REAL reversal flow. `build_reversal` copies the + // original's read-back lines (which carry NO pinned refs), so the reversal + // has all-NULL evidence tuples — the guard ignores them and the reversal + // posts. Reuse is structural here; the guard never fires on the real flow. + let view = original_view(&f, original_id); + let reversal = build_reversal( + &view, + f.period_id.clone(), + naive(2026, 6, 1), + f.tenant, + f.tenant, + ) + .expect("reversal must build"); + invoice + .post_reversal(&ctx, &scope, reversal, None) + .await + .expect("the reversal must post (it reuses no pinned refs)"); + + // 3. Craft a buggy correction: it points back at the original + // (`reverses_entry_id`) but its AR line carries a DIFFERENT pinned + // `pricing_snapshot_ref` the original never had. The guard must reject it. + let (e_bad, l_bad) = entry( + &f, + Uuid::now_v7(), + "correction-bad", + SourceDocType::MappingCorrection, + Some(original_id), + Some("snap-DIFFERENT"), + ); + let err = posting + .post(&ctx, &scope, e_bad, l_bad, None) + .await + .expect_err("a correction inventing a new pinned ref must be rejected"); + assert!( + matches!(err, DomainError::PolicyVersionViolation(_)), + "expected PolicyVersionViolation, got: {err:?}" + ); + + // A SECOND original (un-reversed): uq_journal_entry_reversal allows only ONE + // entry to reverse a given original, and step 2 already reversed the first — + // so the good correction below needs a fresh target to actually post. + let original2_id = Uuid::now_v7(); + let (e2, l2) = entry( + &f, + original2_id, + "orig-2", + SourceDocType::ManualAdjustment, + None, + Some("snap-ORIGINAL"), + ); + posting + .post(&ctx, &scope, e2, l2, None) + .await + .expect("second original post must succeed"); + + // 4. A correction that REUSES the original's pinned ref passes the guard. + // (It points back at the un-reversed second original; the AR line carries + // the SAME `snap-ORIGINAL`.) + let (e_ok, l_ok) = entry( + &f, + Uuid::now_v7(), + "correction-ok", + SourceDocType::MappingCorrection, + Some(original2_id), + Some("snap-ORIGINAL"), + ); + posting + .post(&ctx, &scope, e_ok, l_ok, None) + .await + .expect("a correction reusing the original's pinned ref must post"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_posting.rs b/gears/bss/ledger/ledger/tests/postgres_posting.rs new file mode 100644 index 000000000..3aaaef503 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_posting.rs @@ -0,0 +1,1224 @@ +//! Postgres-only end-to-end: the `PostingService` ACID transaction. Boots +//! a container, migrates, seeds reference data via repos + raw SQL, then +//! drives the full post sequence: a balanced post updates the truth tables +//! and derived caches and stamps the dedup row; a re-post replays; a closed +//! period and a negative-balance post are rejected with the right codes. +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, EntryKey, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::jobs::tieout::TieOutJob; +use bss_ledger::infra::period_close::PeriodCloseService; +use bss_ledger::infra::posting::service::{PostSidecar, PostedFacts, PostingService}; +use bss_ledger::infra::storage::entity::unallocated_balance; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{JournalRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ + ActiveValue::Set, ConnectionTrait, Database, DatabaseConnection, EntityTrait, Statement, +}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::{AccessScope, SecureInsertExt, SecureOnConflict}; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Scalar i64 read of a single-column, single-row SELECT (bss-qualified). +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Count rows matching a bss-qualified predicate. +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + scalar_i64(conn, sql).await.unwrap_or(0) +} + +struct Fixture { + tenant: Uuid, + ar_account: Uuid, + cash_account: Uuid, + legal_entity: Uuid, + period_id: String, +} + +/// Boot, migrate, seed USD@2 + OPEN period + AR/CASH accounts; return the +/// migrate connection, the service over a search_path-scoped provider, and +/// the fixture ids. +async fn setup( + container_url: &str, +) -> ( + DatabaseConnection, + PostingService, + DBProvider, + Fixture, +) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = "202606".to_owned(); + let ar_account = Uuid::now_v7(); + let cash_account = Uuid::now_v7(); + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + + // OPEN fiscal period (raw, bss-qualified). + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{legal_entity}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + + reference + .insert_account(AccountRow { + account_id: ar_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + reference + .insert_account(AccountRow { + account_id: cash_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "CASH_CLEARING".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "CR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + let service = PostingService::new( + provider.clone(), + std::sync::Arc::new(bss_ledger::infra::events::publisher::LedgerEventPublisher::noop()), + ); + ( + raw, + service, + provider, + Fixture { + tenant, + ar_account, + cash_account, + legal_entity, + period_id, + }, + ) +} + +/// Build a balanced entry for `fixture` with `business_id`: DR AR / CR CASH, +/// each `amount`. Pass `swap` to flip the sides (DR CASH / CR AR) so the +/// entry nets AR negative. +fn balanced_entry( + f: &Fixture, + business_id: &str, + amount: i64, + swap: bool, +) -> (NewEntry, Vec) { + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: f.tenant, + legal_entity_id: f.legal_entity, + period_id: f.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: business_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: f.tenant, + correlation_id: f.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let (ar_side, cash_side) = if swap { + (Side::Credit, Side::Debit) + } else { + (Side::Debit, Side::Credit) + }; + let lines = vec![ + line(f, f.ar_account, AccountClass::Ar, ar_side, amount), + line( + f, + f.cash_account, + AccountClass::CashClearing, + cash_side, + amount, + ), + ]; + (entry, lines) +} + +fn line(f: &Fixture, account: Uuid, class: AccountClass, side: Side, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: f.tenant, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn post_balanced_replay_period_and_negative() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // --- 1. Balanced post: DR AR 1000 / CR CASH 1000 --- + let (entry, lines) = balanced_entry(&f, "biz-1", 1000, false); + let first_entry_id = entry.entry_id; + let posted = service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("post must succeed"); + assert!(!posted.replayed, "first post is not a replay"); + assert!(posted.created_seq > 0, "created_seq must be positive"); + assert_eq!(posted.entry_id, first_entry_id); + + // account_balance: AR=1000 (DR-normal, DR delta), CASH=1000 (CR-normal, CR delta). + let ar_bal = scalar_i64( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.ar_account + ), + ) + .await; + assert_eq!(ar_bal, Some(1000), "AR balance"); + let cash_bal = scalar_i64( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.cash_account + ), + ) + .await; + assert_eq!(cash_bal, Some(1000), "CASH balance"); + + // ar_payer_balance = 1000. + let payer_bal = scalar_i64( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_ar_payer_balance \ + WHERE tenant_id='{}' AND payer_tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.tenant, f.ar_account + ), + ) + .await; + assert_eq!(payer_bal, Some(1000), "ar_payer_balance"); + + // idempotency_dedup.result_entry_id populated. + let dedup_rows = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_idempotency_dedup \ + WHERE tenant_id='{}' AND business_id='biz-1' AND result_entry_id IS NOT NULL", + f.tenant + ), + ) + .await; + assert_eq!(dedup_rows, 1, "dedup row finalized"); + + // 2 journal_line rows for the entry. + let line_count = count( + &raw, + &format!("SELECT COUNT(*) FROM bss.ledger_journal_line WHERE entry_id='{first_entry_id}'"), + ) + .await; + assert_eq!(line_count, 2, "two journal lines"); + + // --- 2. Replay: same business key + payload --- + let (entry2, lines2) = balanced_entry(&f, "biz-1", 1000, false); + let replay = service + .post(&ctx, &scope, entry2, lines2, None) + .await + .expect("replay must succeed"); + assert!(replay.replayed, "second post is a replay"); + assert_eq!( + replay.entry_id, first_entry_id, + "replay returns the prior entry id" + ); + + // Still exactly one journal_entry + two lines for biz-1 (no duplicate). + let entry_count = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_business_id='biz-1'", + f.tenant + ), + ) + .await; + assert_eq!(entry_count, 1, "no duplicate journal entry on replay"); + let line_count2 = count( + &raw, + &format!("SELECT COUNT(*) FROM bss.ledger_journal_line WHERE entry_id='{first_entry_id}'"), + ) + .await; + assert_eq!(line_count2, 2, "no duplicate journal lines on replay"); + + // --- 3. Closed period --- + raw.execute(pg(format!( + "UPDATE bss.ledger_fiscal_period SET status='CLOSED' \ + WHERE tenant_id='{}' AND legal_entity_id='{}' AND period_id='{}'", + f.tenant, f.legal_entity, f.period_id + ))) + .await + .unwrap(); + let (entry3, lines3) = balanced_entry(&f, "biz-closed", 500, false); + let closed_err = service + .post(&ctx, &scope, entry3, lines3, None) + .await + .expect_err("closed period rejects"); + assert!( + matches!(closed_err, DomainError::PeriodClosed(_)), + "got {closed_err:?}" + ); + + // Re-open for the next case. + raw.execute(pg(format!( + "UPDATE bss.ledger_fiscal_period SET status='OPEN' \ + WHERE tenant_id='{}' AND legal_entity_id='{}' AND period_id='{}'", + f.tenant, f.legal_entity, f.period_id + ))) + .await + .unwrap(); + + // --- 4. Negative balance: DR CASH 1500 / CR AR 1500 drives AR to -500 --- + let (entry4, lines4) = balanced_entry(&f, "biz-neg", 1500, true); + let neg_err = service + .post(&ctx, &scope, entry4, lines4, None) + .await + .expect_err("negative balance rejects"); + assert!( + matches!(neg_err, DomainError::NegativeBalance(_)), + "got {neg_err:?}" + ); + + // AR balance unchanged (the negative post rolled back). + let ar_after = scalar_i64( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.ar_account + ), + ) + .await; + assert_eq!(ar_after, Some(1000), "AR balance unchanged after rollback"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_same_key_posts_once() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + + // Two posts of the SAME business key, run concurrently on two service + // clones sharing the provider. + let svc_a = service.clone(); + let svc_b = service.clone(); + let scope_a = scope.clone(); + let scope_b = scope.clone(); + let ctx_a = SecurityContext::anonymous(); + let ctx_b = SecurityContext::anonymous(); + let (e_a, l_a) = balanced_entry(&f, "biz-conc", 1000, false); + let (e_b, l_b) = balanced_entry(&f, "biz-conc", 1000, false); + + let (ra, rb) = tokio::join!( + async move { svc_a.post(&ctx_a, &scope_a, e_a, l_a, None).await }, + async move { svc_b.post(&ctx_b, &scope_b, e_b, l_b, None).await }, + ); + + // Both succeed (one fresh, one replay) OR one succeeds + one is a + // conflict; in every case at most one journal_entry must persist. + let entry_count = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_business_id='biz-conc'", + f.tenant + ), + ) + .await; + assert_eq!( + entry_count, 1, + "exactly one entry persisted for the shared key" + ); + + let oks = i32::from(ra.is_ok()) + i32::from(rb.is_ok()); + assert!( + oks >= 1, + "at least one concurrent post must succeed: {ra:?} / {rb:?}" + ); + + // Every successful result (fresh OR replay) must carry a real, shared entry + // id: a concurrent replay returns the winner's finalized id, never the nil + // UUID (regression for the replay-nil-id path). + let ids: Vec = [&ra, &rb] + .into_iter() + .filter_map(|r| r.as_ref().ok()) + .map(|p| p.entry_id) + .collect(); + for id in &ids { + assert_ne!( + *id, + Uuid::nil(), + "a successful post/replay must carry a real entry id" + ); + } + if let [a, b] = ids.as_slice() { + assert_eq!(a, b, "fresh post and replay must reference the same entry"); + } +} + +/// SecureORM SQL-level isolation (authz #3): an entry posted under tenant A is +/// invisible to a tenant-B scope, even though the key triple names A's row — +/// the tenant predicate is enforced in the query, not just at the PEP gate. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn secure_orm_isolates_cross_tenant_entry_reads() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (_raw, service, provider, f) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope_a = AccessScope::for_tenant(f.tenant); + + let (entry, lines) = balanced_entry(&f, "biz-iso", 1000, false); + let entry_id = entry.entry_id; + let period_id = entry.period_id.clone(); + service + .post(&ctx, &scope_a, entry, lines, None) + .await + .expect("post under A"); + + let journal = JournalRepo::new(provider.clone()); + let key = |tenant| EntryKey { + entry_id, + tenant_id: tenant, + period_id: period_id.clone(), + }; + + // A foreign tenant B (its own scope) reading A's exact key gets nothing. + let tenant_b = Uuid::now_v7(); + let found_b = journal + .find_entry(&AccessScope::for_tenant(tenant_b), key(f.tenant)) + .await + .expect("query ok"); + assert!( + found_b.is_none(), + "tenant B must not read tenant A's entry through SecureORM" + ); + + // Sanity: A sees its own entry under its own scope. + let found_a = journal + .find_entry(&scope_a, key(f.tenant)) + .await + .expect("query ok"); + assert!(found_a.is_some(), "tenant A must see its own entry"); +} + +/// A1 seam: a `REVERSAL` post carrying `reverses_entry_id` / `reverses_period_id` +/// persists those header columns. Posts an original `INVOICE_POST`-style entry, +/// then a balanced reversal (flipped sides) whose header points back at the +/// original via the `reverses_*` fields, and reads the reversal row back through +/// the repo to assert the columns round-trip. (The SDK `PostEntry.reverses_*` → +/// gear `NewEntry.reverses_*` copy is threaded in `LedgerLocalClient`; this +/// proves the gear-internal + storage half of that seam.) +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reverses_fields_persist_on_a_reversal_post() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(f.tenant); + + // Original entry: DR AR 2000 / CR CASH 2000. This is an A1 SEAM test — it + // asserts the `reverses_*` header columns round-trip, NOT balance mechanics. + // The reversal below is a full (2000) flip that nets both guarded grains back + // to exactly zero — the zero-boundary upsert path a guarded net-down must + // survive (Postgres checks the no-negative CHECK against the INSERT arbiter + // tuple, so the projector seeds the projected post-state, not the bare delta). + let (orig, orig_lines) = balanced_entry(&f, "inv-rev-1", 2000, false); + let original_entry_id = orig.entry_id; + service + .post(&ctx, &scope, orig, orig_lines, None) + .await + .expect("original post must succeed"); + + // Reversal: flipped sides (DR CASH / CR AR 2000), header points back at the + // original via reverses_entry_id / reverses_period_id; nets AR/CASH to 0. + let (mut reversal, reversal_lines) = balanced_entry(&f, "reverses=inv-rev-1", 2000, true); + reversal.source_doc_type = SourceDocType::Reversal; + reversal.reverses_entry_id = Some(original_entry_id); + reversal.reverses_period_id = Some(f.period_id.clone()); + let reversal_entry_id = reversal.entry_id; + service + .post(&ctx, &scope, reversal, reversal_lines, None) + .await + .expect("reversal post must succeed"); + + // The guarded balances net back to exactly zero (the zero-boundary the + // arbiter-tuple seed must permit). + let ar_bal = scalar_i64( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.ar_account + ), + ) + .await; + assert_eq!(ar_bal, Some(0), "AR nets to zero after the full reversal"); + let cash_bal = scalar_i64( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.cash_account + ), + ) + .await; + assert_eq!( + cash_bal, + Some(0), + "CASH nets to zero after the full reversal" + ); + + // Read the reversal entry back and assert the reverses_* columns. + let record = JournalRepo::new(provider) + .find_entry( + &scope, + EntryKey { + entry_id: reversal_entry_id, + tenant_id: f.tenant, + period_id: f.period_id.clone(), + }, + ) + .await + .expect("read reversal entry") + .expect("reversal entry must exist"); + assert_eq!( + record.reverses_entry_id, + Some(original_entry_id), + "reverses_entry_id must persist the original entry id" + ); + assert_eq!( + record.reverses_period_id, + Some(f.period_id.clone()), + "reverses_period_id must persist the original period" + ); + assert_eq!(record.source_doc_type, "REVERSAL"); +} + +/// Concurrent overdraw (financial #3): two posts racing the same guarded +/// (`may_go_negative = false`) AR account that together would drive it +/// negative. At most one may commit; the DB no-negative CHECK must abort the +/// other so the balance never goes below zero — proving the app-level guard's +/// lockless read is backstopped at the database. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_overdraw_of_guarded_account_stays_non_negative() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(f.tenant); + + // Seed a positive AR balance: DR AR 1000. + let (e0, l0) = balanced_entry(&f, "seed", 1000, false); + service + .post(&ctx, &scope, e0, l0, None) + .await + .expect("seed post"); + + // Two concurrent posts, each CR AR 600 (swap) — together -1200 would take + // AR to -200. + let svc_a = service.clone(); + let svc_b = service.clone(); + let sa = scope.clone(); + let sb = scope.clone(); + let ca = SecurityContext::anonymous(); + let cb = SecurityContext::anonymous(); + let (ea, la) = balanced_entry(&f, "ov-a", 600, true); + let (eb, lb) = balanced_entry(&f, "ov-b", 600, true); + let (ra, rb) = tokio::join!( + async move { svc_a.post(&ca, &sa, ea, la, None).await }, + async move { svc_b.post(&cb, &sb, eb, lb, None).await }, + ); + + let oks = i32::from(ra.is_ok()) + i32::from(rb.is_ok()); + assert!( + oks <= 1, + "at most one overdraw may commit against the guarded account: {ra:?} / {rb:?}" + ); + let ar = scalar_i64( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}'", + f.tenant, f.ar_account + ), + ) + .await; + assert!( + ar.unwrap_or(0) >= 0, + "guarded AR balance must never be negative, got {ar:?}" + ); +} + +/// #4(A): a post and a period close racing the SAME period must not leave a +/// CLOSED period that an entry slipped into unseen. Both run SERIALIZABLE+retry, +/// so Postgres SSI aborts the loser; whatever the interleaving, a CLOSED period +/// must tie out clean (close's pre-close tie-out saw every committed entry). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn concurrent_close_never_certifies_a_period_a_post_landed_in() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + let publisher = Arc::new(LedgerEventPublisher::noop()); + let close_svc = PeriodCloseService::new( + provider.clone(), + Arc::clone(&publisher), + std::sync::Arc::new( + bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new(), + ), + ); + + // Race: post a balanced entry INTO the period, and close the period. + let svc = service.clone(); + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + let (entry, lines) = balanced_entry(&f, "race", 1000, false); + let (tenant, legal_entity, period) = (f.tenant, f.legal_entity, f.period_id.clone()); + + let (post_res, close_res) = tokio::join!( + async move { svc.post(&ctx, &scope, entry, lines, None).await }, + async move { + close_svc + .close(&SecurityContext::anonymous(), tenant, legal_entity, period) + .await + }, + ); + + // At least one side must make progress. + assert!( + post_res.is_ok() || close_res.is_ok(), + "post={post_res:?} / close={close_res:?}" + ); + + let status = raw + .query_one(pg(format!( + "SELECT status FROM bss.ledger_fiscal_period \ + WHERE tenant_id='{}' AND period_id='{}'", + f.tenant, f.period_id + ))) + .await + .unwrap() + .expect("period row exists"); + let status: String = status.try_get("", "status").unwrap(); + + // THE INVARIANT: a CLOSED period must tie out clean. If the post committed + // into it, close's SERIALIZABLE pre-close tie-out must have seen the entry + // (else the race certified a period with an unverified entry). + if status == "CLOSED" { + let report = TieOutJob::new(provider.clone(), Arc::clone(&publisher)) + .tie_out_tenant(f.tenant) + .await + .unwrap(); + assert!( + report.is_clean(), + "a CLOSED period must tie out clean (post={post_res:?} / close={close_res:?}); \ + report={}", + report.summary() + ); + } +} + +/// B1 sidecar — writes a marker row through SecureORM inside the post txn, +/// stamping the posted sequence into an `unallocated_balance` counter row keyed +/// by the posted tenant/payer/account. Proves the sidecar's SecureORM write +/// commits atomically with the journal entry. +struct MarkerSidecar { + tenant: Uuid, + payer: Uuid, + account: Uuid, +} + +#[async_trait::async_trait] +impl PostSidecar for MarkerSidecar { + async fn run( + &self, + txn: &toolkit_db::secure::DbTx<'_>, + scope: &AccessScope, + posted: &PostedFacts, + ) -> Result<(), DomainError> { + // Stamp the posted sequence into a counter row (balance_minor) so the + // test can read it back and confirm atomic commit with the entry. + let am = unallocated_balance::ActiveModel { + tenant_id: Set(self.tenant), + payer_tenant_id: Set(self.payer), + account_id: Set(self.account), + currency: Set("USD".to_owned()), + balance_minor: Set(posted.created_seq), + functional_balance_minor: Set(None), + functional_currency: Set(None), + last_entry_seq: Set(Some(posted.created_seq)), + version: Set(0), + }; + // ON CONFLICT must carry an action (a bare conflict target emits + // invalid SQL before RETURNING); a retry re-stamps the counter row. + let on_conflict = SecureOnConflict::::columns([ + // `ledger_unallocated_balance` PK = (tenant_id, payer_tenant_id, + // currency); account_id is NOT a key column (the unallocated pool is + // per payer+currency), so it must not be in the ON CONFLICT target. + unallocated_balance::Column::TenantId, + unallocated_balance::Column::PayerTenantId, + unallocated_balance::Column::Currency, + ]) + .value( + unallocated_balance::Column::BalanceMinor, + Expr::value(posted.created_seq), + ) + .and_then(|oc| { + oc.value( + unallocated_balance::Column::LastEntrySeq, + Expr::value(Some(posted.created_seq)), + ) + }) + .map_err(|e| DomainError::Internal(format!("marker on_conflict: {e}")))?; + unallocated_balance::Entity::insert(am.clone()) + .secure() + .scope_with_model(scope, &am) + .map_err(|e| DomainError::Internal(format!("marker scope: {e}")))? + .on_conflict(on_conflict) + .exec_with_returning(txn) + .await + .map_err(|e| DomainError::Internal(format!("marker insert: {e}")))?; + Ok(()) + } +} + +/// B1 sidecar — always rejects, so the whole post (entry + lines + projection) +/// must roll back and the error surfaces to the caller. +struct RejectingSidecar; + +#[async_trait::async_trait] +impl PostSidecar for RejectingSidecar { + async fn run( + &self, + _txn: &toolkit_db::secure::DbTx<'_>, + _scope: &AccessScope, + _posted: &PostedFacts, + ) -> Result<(), DomainError> { + Err(DomainError::InvalidRequest("sidecar rejected".to_owned())) + } +} + +/// B1: a post carrying an `Ok` sidecar commits the sidecar's marker row +/// atomically with the journal entry; a post carrying an `Err` sidecar rolls +/// the entire entry back and surfaces the rejection. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn post_sidecar_commits_with_entry_and_rolls_back_on_err() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // The marker sidecar stamps a counter row into unallocated_balance keyed by + // a fresh account id (cache rows carry no FK to the chart). + let marker_account = Uuid::now_v7(); + let marker = || { + Arc::new(MarkerSidecar { + tenant: f.tenant, + payer: f.tenant, + account: marker_account, + }) + }; + + // --- 1. Ok sidecar: the marker row commits with the entry --- + let (entry, lines) = balanced_entry(&f, "sc-ok", 1000, false); + let posted = service + .post(&ctx, &scope, entry, lines, Some(marker())) + .await + .expect("post with ok sidecar must succeed"); + + let marker_seq = scalar_i64( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_unallocated_balance \ + WHERE tenant_id='{}' AND payer_tenant_id='{}' AND account_id='{marker_account}' \ + AND currency='USD'", + f.tenant, f.tenant + ), + ) + .await; + assert_eq!( + marker_seq, + Some(posted.created_seq), + "sidecar marker row visible after commit, stamping the posted seq" + ); + + // --- 2. Err sidecar: the entry rolls back and the error surfaces --- + let (entry2, lines2) = balanced_entry(&f, "sc-err", 500, false); + let entry2_id = entry2.entry_id; + let err = service + .post( + &ctx, + &scope, + entry2, + lines2, + Some(Arc::new(RejectingSidecar)), + ) + .await + .expect_err("rejecting sidecar must roll the post back"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "sidecar rejection surfaces as its DomainError: {err:?}" + ); + + // The journal entry did NOT persist (rolled back with the sidecar Err). + let entry_rows = count( + &raw, + &format!("SELECT COUNT(*) FROM bss.ledger_journal_entry WHERE entry_id='{entry2_id}'"), + ) + .await; + assert_eq!(entry_rows, 0, "rejected post must not persist its entry"); +} + +/// The same DR AR / CR CASH balanced entry as `balanced_entry`, but with a +/// functional amount on BOTH lines (a cross-currency post): it balances in the +/// transaction column (`amount`) AND the functional column (`functional`). +fn cross_currency_entry( + f: &Fixture, + business_id: &str, + amount: i64, + functional: i64, +) -> (NewEntry, Vec) { + let (entry, mut lines) = balanced_entry(f, business_id, amount, false); + for l in &mut lines { + l.functional_amount_minor = Some(functional); + l.functional_currency = Some("USD".to_owned()); + } + (entry, lines) +} + +/// Slice 5 B1: a cross-currency post (a functional amount on every line) projects +/// `functional_balance_minor` / `functional_currency` onto the balance caches. The +/// entry balances in BOTH columns; the projector mirrors the transaction sign onto +/// the functional column (AR is DR-normal, CASH is CR-normal, both +functional). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_currency_post_populates_functional_balance() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // DR AR 1000 (func 1100) / CR CASH 1000 (func 1100): balances in both columns. + let (entry, lines) = cross_currency_entry(&f, "fx-1", 1000, 1100); + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("cross-currency post must succeed (balances in both columns)"); + + for (label, account) in [("AR", f.ar_account), ("CASH", f.cash_account)] { + let func = scalar_i64( + &raw, + &format!( + "SELECT functional_balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, account + ), + ) + .await; + assert_eq!( + func, + Some(1100), + "{label} functional_balance_minor populated" + ); + let ccy = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND functional_currency='USD'", + f.tenant, account + ), + ) + .await; + assert_eq!(ccy, 1, "{label} functional_currency stamped"); + } + + // ar_payer_balance carries the functional value too. + let payer_func = scalar_i64( + &raw, + &format!( + "SELECT functional_balance_minor FROM bss.ledger_ar_payer_balance \ + WHERE tenant_id='{}' AND payer_tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.tenant, f.ar_account + ), + ) + .await; + assert_eq!( + payer_func, + Some(1100), + "ar_payer functional_balance_minor populated" + ); +} + +/// `post_with_request_hash` is the orchestrator entry point that keys the dedup +/// claim on an externally-computed REQUEST hash (stable across the +/// state-dependent entry rebuild) instead of the entry-derived payload hash. The +/// FIRST post under `(flow, business_id)` + request-hash `A` finalizes; a SECOND +/// post that REUSES the same `(flow, business_id)` but supplies a DIFFERENT +/// request-hash `B` must be rejected `IdempotencyConflict` — the +/// same-key/different-payload guard a payment orchestrator's replay short-circuit +/// relies on (the in-txn `row.payload_hash != payload_hash` arm). A genuine +/// reuse-with-changed-payload is a real client error, not a replay. +/// +/// The two posts carry DIFFERENT entries (distinct `entry_id`s and amounts), so +/// the entry-derived hash would differ too — but it is the REQUEST hash that is +/// authoritative here, so the conflict is driven purely by `A != B` under the +/// shared business key, and the rejected second post leaves no second entry. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn post_with_request_hash_rejects_same_key_different_payload() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // First post under business key "biz-rh" keyed on request-hash A. + let (entry_a, lines_a) = balanced_entry(&f, "biz-rh", 1000, false); + let first_entry_id = entry_a.entry_id; + let posted = service + .post_with_request_hash(&ctx, &scope, entry_a, lines_a, None, "A".repeat(64)) + .await + .expect("first request-hash post must succeed"); + assert!(!posted.replayed, "first post is fresh"); + + // Second post REUSES the same (flow, business_id) "biz-rh" but a DIFFERENT + // request-hash B ⇒ IdempotencyConflict (same key, different payload). + let (entry_b, lines_b) = balanced_entry(&f, "biz-rh", 500, false); + let second_entry_id = entry_b.entry_id; + let err = service + .post_with_request_hash(&ctx, &scope, entry_b, lines_b, None, "B".repeat(64)) + .await + .expect_err("same key + different request hash must conflict"); + assert!( + matches!(err, DomainError::IdempotencyConflict(_)), + "expected IdempotencyConflict, got {err:?}" + ); + + // Exactly one journal_entry for the shared key — the conflicting post never + // persisted its (distinct) entry. + let entry_count = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_business_id='biz-rh'", + f.tenant + ), + ) + .await; + assert_eq!(entry_count, 1, "the conflicting post added no second entry"); + let first_rows = count( + &raw, + &format!("SELECT COUNT(*) FROM bss.ledger_journal_entry WHERE entry_id='{first_entry_id}'"), + ) + .await; + assert_eq!(first_rows, 1, "the original entry is intact"); + let second_rows = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry WHERE entry_id='{second_entry_id}'" + ), + ) + .await; + assert_eq!(second_rows, 0, "the rejected post's entry never landed"); +} + +/// A re-post via `post_with_request_hash` that reuses the same `(flow, +/// business_id)` AND the same request-hash is an idempotent REPLAY (not a +/// conflict): it returns the prior finalized entry id and writes no second +/// entry. This pins the matching-hash replay arm of the request-hash path (the +/// sibling of the conflict arm above) — the orchestrator's safe retry. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn post_with_request_hash_same_hash_replays() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + let hash = "c".repeat(64); + let (entry, lines) = balanced_entry(&f, "biz-rh-replay", 1000, false); + let first_entry_id = entry.entry_id; + let first = service + .post_with_request_hash(&ctx, &scope, entry, lines, None, hash.clone()) + .await + .expect("first request-hash post must succeed"); + assert!(!first.replayed, "first is fresh"); + + // Re-issue with the SAME key and SAME request-hash (the entry is rebuilt with + // a fresh entry_id, exactly as an orchestrator retry would) ⇒ replay. + let (entry2, lines2) = balanced_entry(&f, "biz-rh-replay", 1000, false); + let replay = service + .post_with_request_hash(&ctx, &scope, entry2, lines2, None, hash) + .await + .expect("same key + same request hash replays"); + assert!(replay.replayed, "second is an idempotent replay"); + assert_eq!( + replay.entry_id, first_entry_id, + "replay returns the prior finalized entry id" + ); + + let entry_count = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_business_id='biz-rh-replay'", + f.tenant + ), + ) + .await; + assert_eq!(entry_count, 1, "replay adds no second entry"); +} + +/// Slice 5 B1: a single-currency post leaves `functional_balance_minor` NULL — +/// the plain `+ functional_delta` on conflict keeps NULL = NULL (no COALESCE), so +/// existing single-currency grains never spuriously gain a functional value. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn single_currency_post_leaves_functional_null() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + let (entry, lines) = balanced_entry(&f, "sc-only", 1000, false); + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("single-currency post must succeed"); + + // The AR account_balance row exists (balance 1000) but its functional column + // is NULL — a row with a non-NULL functional would NOT match this predicate. + let ar_func_null = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND functional_balance_minor IS NULL", + f.tenant, f.ar_account + ), + ) + .await; + assert_eq!(ar_func_null, 1, "single-currency AR functional stays NULL"); +} + +/// A held `tenant_posting_lock` (design §3.2 pre-transaction gate) refuses every +/// post for the tenant with `TenantPostingLocked`, before any write. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn tenant_posting_lock_blocks_posting() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + // Set the kill switch for the tenant. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_tenant_posting_lock (tenant_id, locked, reason_code, set_at) \ + VALUES ('{}', true, 'TENANT_TERMINATED', now())", + f.tenant + ))) + .await + .unwrap(); + + // A balanced post that would otherwise succeed is refused. + let (entry, lines) = balanced_entry(&f, "biz-locked", 1000, false); + let err = service + .post(&ctx, &scope, entry, lines, None) + .await + .expect_err("a locked tenant must be refused"); + assert!( + matches!(err, DomainError::TenantPostingLocked(_)), + "got {err:?}" + ); + + // No entry was written (the gate is pre-transaction). + let entries = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry WHERE tenant_id='{}'", + f.tenant + ), + ) + .await; + assert_eq!(entries, 0, "a refused post writes nothing"); + + // Clearing the lock lets the same tenant post again. + raw.execute(pg(format!( + "UPDATE bss.ledger_tenant_posting_lock SET locked = false, cleared_at = now() \ + WHERE tenant_id = '{}'", + f.tenant + ))) + .await + .unwrap(); + let (entry, lines) = balanced_entry(&f, "biz-unlocked", 1000, false); + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("post must succeed once the lock is cleared"); +} + +/// A post whose `posted_at_utc` is skewed beyond ±24 h from the server clock is +/// quarantined with `ClockSkewQuarantine` (design §3.2), before any write. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn clock_skew_beyond_24h_is_quarantined() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, _provider, f) = setup(&url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + + let (mut entry, lines) = balanced_entry(&f, "biz-skewed", 1000, false); + entry.posted_at_utc = Utc::now() - chrono::Duration::hours(48); + let err = service + .post(&ctx, &scope, entry, lines, None) + .await + .expect_err("a >±24h skewed post must be quarantined"); + assert!( + matches!(err, DomainError::ClockSkewQuarantine(_)), + "got {err:?}" + ); + + let entries = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry WHERE tenant_id='{}'", + f.tenant + ), + ) + .await; + assert_eq!(entries, 0, "a quarantined post writes nothing"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_precedence_policy.rs b/gears/bss/ledger/ledger/tests/postgres_precedence_policy.rs new file mode 100644 index 000000000..7f3828358 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_precedence_policy.rs @@ -0,0 +1,560 @@ +//! Postgres-only integration tests for Group B — effective-dated precedence +//! policy versioning. Drives the REAL `AllocationService` through the +//! foundation `PostingService` against a testcontainer Postgres, exercising +//! `PaymentRepo::read_effective_policy` + the `allocate_inner` wiring: +//! +//! - a seeded `HighestAmountFirst` policy row makes the split follow +//! largest-open-first AND stamps the real `highest-amount-first.v1#` +//! ref onto every `payment_allocation` row; +//! - with NO policy row the allocator falls back to oldest-first and the ref +//! stays `DEFAULT_PRECEDENCE_POLICY` (byte-stable with 2a); +//! - with two versions at different `effective_from`, the one in effect now +//! (latest `effective_from <= now`) is the one chosen. +//! +//! Ignored by default (Docker/testcontainers); run with +//! `cargo test -p bss-ledger --test postgres_precedence_policy -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::precedence::DEFAULT_PRECEDENCE_POLICY; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::allocate::{ + AllocateRequest, AllocationOutcome, AllocationService, AppliedAllocation, +}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot a container, run the chain on a raw connection, and return a +/// `bss`-search-path `DBProvider` for the services (the payments-test idiom). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller ids for the service tests (mirrors `postgres_payments`). +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision a seller: USD@2 scale, an OPEN fiscal period for the current +/// month, and the four payment-flow chart accounts (CASH_CLEARING / UNALLOCATED +/// / PSP_FEE_EXPENSE / AR). +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn allocate_svc(provider: &DBProvider) -> AllocationService { + AllocationService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +/// Unwrap the inline-post arm of an allocate outcome. Every test here settles +/// before allocating, so the outcome is always `Applied`; a `Queued` (the +/// not-yet-settled §4.7 path) is a test-setup bug, so panic. +fn applied(outcome: AllocationOutcome) -> AppliedAllocation { + match outcome { + AllocationOutcome::Applied(a) => a, + AllocationOutcome::Queued(q) => { + panic!("expected an inline-posted allocation, got Queued: {q:?}") + } + } +} + +fn settlement_input(s: &Seller, payment_id: &str, gross: i64, fee: i64) -> SettlementInput { + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "USD".to_owned(), + effective_at: None, + } +} + +/// Insert a precedence-policy version row directly (the write path is admin / +/// out of 2b's scope; the read + wiring is what Group B delivers). +async fn seed_policy( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + version: i64, + strategy: &str, + effective_from: DateTime, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_tenant_precedence_policy + (tenant_id, version, effective_from, strategy, created_at_utc) + VALUES ('{}', {version}, '{}', '{strategy}', '{}')", + s.tenant, + effective_from.to_rfc3339(), + Utc::now().to_rfc3339() + ))) + .await + .expect("seed precedence policy row"); +} + +async fn ar_invoice_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND invoice_id='{}'", + s.tenant, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// The distinct `precedence_policy_ref`s stamped on `(tenant, payment_id)`'s +/// allocation rows (deduplicated; one per ref). +async fn allocation_policy_refs( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + payment_id: &str, +) -> Vec { + let rows = raw + .query_all(pg(format!( + "SELECT DISTINCT precedence_policy_ref FROM bss.ledger_payment_allocation \ + WHERE tenant_id='{}' AND payment_id='{}' ORDER BY precedence_policy_ref", + s.tenant, payment_id + ))) + .await + .unwrap(); + rows.into_iter() + .map(|r| r.try_get_by_index::(0).unwrap()) + .collect() +} + +/// Seed an OPEN AR invoice by posting `DR AR (invoice_id) / CR PSP_FEE_EXPENSE` +/// directly through the engine, with an explicit `posted_at` (the oldest-first +/// sort key). Mirrors `postgres_payments::seed_ar_invoice`. +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Standard 2-invoice setup: settle 1000 into the pool, then seed INV-A (300, +/// posted EARLIER) and INV-B (800, posted LATER). Under oldest-first INV-A is +/// paid first; under highest-amount-first INV-B (the larger open) is paid first +/// — so the two policies produce visibly different splits of a lump of 500. +async fn settle_and_seed_two_invoices( + provider: &DBProvider, + s: &Seller, + ctx: &SecurityContext, + scope: &AccessScope, + payment_id: &str, +) { + settle_svc(provider) + .settle(ctx, scope, settlement_input(s, payment_id, 1000, 0)) + .await + .expect("settle"); + seed_ar_invoice( + provider, + s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(2), + ) + .await; + seed_ar_invoice( + provider, + s, + "INV-B", + 800, + Utc::now() - chrono::Duration::hours(1), + ) + .await; +} + +fn allocate_request(s: &Seller, payment_id: &str) -> AllocateRequest { + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 500, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + } +} + +/// A seeded `HighestAmountFirst` policy steers the split largest-open-first +/// (INV-B before INV-A) AND its real `highest-amount-first.v1#` ref is +/// stamped on every allocation row. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn effective_policy_steers_split_and_stamps_real_ref() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Policy version 3, effective an hour ago ⇒ in effect now. + seed_policy( + &raw, + &s, + 3, + "highest-amount-first.v1", + Utc::now() - chrono::Duration::hours(1), + ) + .await; + settle_and_seed_two_invoices(&provider, &s, &ctx, &scope, "PAY-HAF-1").await; + + let outcome = applied( + allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_request(&s, "PAY-HAF-1")) + .await + .expect("allocate must succeed"), + ); + + // Highest-amount-first: INV-B (800 open) fills the whole 500; INV-A gets 0. + let splits: Vec<(String, i64)> = outcome + .splits + .iter() + .map(|a| (a.invoice_id.clone(), a.amount_minor)) + .collect(); + assert_eq!( + splits, + vec![("INV-B".to_owned(), 500)], + "highest-amount-first pays the largest open balance (INV-B) first" + ); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-B").await, Some(300)); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(300)); + + // The REAL effective ref (strategy#version) is stamped on the rows. + assert_eq!( + allocation_policy_refs(&raw, &s, "PAY-HAF-1").await, + vec!["highest-amount-first.v1#3".to_owned()], + "the stamped ref is the effective strategy + version" + ); +} + +/// With NO policy row the allocator falls back to oldest-first and stamps the +/// unchanged `DEFAULT_PRECEDENCE_POLICY` ref (byte-stable with 2a behaviour). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn no_policy_row_defaults_to_oldest_first_and_default_ref() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // No seed_policy call ⇒ read_effective_policy returns None. + settle_and_seed_two_invoices(&provider, &s, &ctx, &scope, "PAY-DEF-1").await; + + let outcome = applied( + allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_request(&s, "PAY-DEF-1")) + .await + .expect("allocate must succeed"), + ); + + // Oldest-first: INV-A (300) fills, INV-B takes the remaining 200. + let splits: Vec<(String, i64)> = outcome + .splits + .iter() + .map(|a| (a.invoice_id.clone(), a.amount_minor)) + .collect(); + assert_eq!( + splits, + vec![("INV-A".to_owned(), 300), ("INV-B".to_owned(), 200)], + "no policy ⇒ oldest-first fills INV-A then INV-B" + ); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(0)); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-B").await, Some(600)); + + // The default ref is stamped verbatim (NOT a strategy#version form). + assert_eq!( + allocation_policy_refs(&raw, &s, "PAY-DEF-1").await, + vec![DEFAULT_PRECEDENCE_POLICY.to_owned()], + "the fallback stamps DEFAULT_PRECEDENCE_POLICY unchanged" + ); +} + +/// Two versions at different `effective_from`: the LATEST one whose +/// `effective_from <= now` wins. An older highest-amount-first (effective 2h +/// ago) is superseded by a newer oldest-first (effective 1h ago); a third +/// future row (effective tomorrow) is NOT yet in effect. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn latest_effective_version_in_effect_is_chosen() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // v1: highest-amount-first, effective 2h ago (superseded). + seed_policy( + &raw, + &s, + 1, + "highest-amount-first.v1", + Utc::now() - chrono::Duration::hours(2), + ) + .await; + // v2: oldest-first, effective 1h ago (the one in effect now). + seed_policy( + &raw, + &s, + 2, + "oldest-first.v1", + Utc::now() - chrono::Duration::hours(1), + ) + .await; + // v3: highest-amount-first, effective TOMORROW (not yet in effect). + seed_policy( + &raw, + &s, + 3, + "highest-amount-first.v1", + Utc::now() + chrono::Duration::days(1), + ) + .await; + settle_and_seed_two_invoices(&provider, &s, &ctx, &scope, "PAY-VER-1").await; + + let outcome = applied( + allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_request(&s, "PAY-VER-1")) + .await + .expect("allocate must succeed"), + ); + + // v2 (oldest-first) is in effect ⇒ INV-A then INV-B; v3's future + // highest-amount-first must NOT apply. + let splits: Vec<(String, i64)> = outcome + .splits + .iter() + .map(|a| (a.invoice_id.clone(), a.amount_minor)) + .collect(); + assert_eq!( + splits, + vec![("INV-A".to_owned(), 300), ("INV-B".to_owned(), 200)], + "the latest effective-now version (v2 oldest-first) decides the split" + ); + assert_eq!( + allocation_policy_refs(&raw, &s, "PAY-VER-1").await, + vec!["oldest-first.v1#2".to_owned()], + "the stamped ref names the chosen version (v2)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_projector.rs b/gears/bss/ledger/ledger/tests/postgres_projector.rs new file mode 100644 index 000000000..f206b99e5 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_projector.rs @@ -0,0 +1,616 @@ +//! Postgres-only: the balance projector. A balanced DR AR / CR CASH entry +//! projects normal-side-positive deltas into `account_balance` and +//! `ar_payer_balance`; a follow-up CR AR beyond the AR balance trips the +//! no-negative guard (`ProjectError::NegativeBalance`). Ignored by default; +//! run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown +)] + +use std::collections::HashMap; + +use bss_ledger::domain::model::{AccountRow, NewEntry, NewLine}; +use bss_ledger::infra::posting::projector::{BalanceProjector, ProjectError}; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +fn account(tenant: Uuid, account_id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +fn entry(tenant: Uuid) -> NewEntry { + NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + legal_entity_id: tenant, + period_id: "202606".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: "biz-1".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: tenant, + correlation_id: tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + } +} + +#[allow(clippy::too_many_arguments)] +fn line(account: Uuid, class: AccountClass, side: Side, amount: i64, payer: Uuid) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: payer, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +async fn balance(raw: &sea_orm::DatabaseConnection, sql: &str) -> Option { + raw.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|row| row.try_get_by_index::(0).unwrap()) +} + +/// Read `(original_posted_at, due_date)` for an `ar_invoice_balance` row typed, +/// so the test compares the stamped post date / due date without text parsing. +async fn ar_invoice_stamps( + raw: &sea_orm::DatabaseConnection, + account: Uuid, + invoice_id: &str, +) -> (Option>, Option) { + let row = raw + .query_one(pg(format!( + "SELECT original_posted_at, due_date FROM bss.ledger_ar_invoice_balance \ + WHERE account_id='{account}' AND invoice_id='{invoice_id}'" + ))) + .await + .unwrap() + .expect("ar_invoice_balance row must exist"); + ( + row.try_get_by_index::>>(0).unwrap(), + row.try_get_by_index::>(1).unwrap(), + ) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn projects_deltas_and_enforces_no_negative() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let payer = tenant; + let ar = Uuid::now_v7(); + let cash = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // Provision accounts: AR normal DR, CASH_CLEARING normal CR. + let reference = ReferenceRepo::new(provider.clone()); + reference + .insert_account(account(tenant, ar, AccountClass::Ar, Side::Debit)) + .await + .unwrap(); + reference + .insert_account(account( + tenant, + cash, + AccountClass::CashClearing, + Side::Credit, + )) + .await + .unwrap(); + + let mut normal_sides = HashMap::new(); + normal_sides.insert(ar, Side::Debit); + normal_sides.insert(cash, Side::Credit); + + let projector = BalanceProjector::new(); + + // Project DR AR 1000 / CR CASH 1000 → both normal-side-positive (+1000). + let lines1 = vec![ + line(ar, AccountClass::Ar, Side::Debit, 1000, payer), + line(cash, AccountClass::CashClearing, Side::Credit, 1000, payer), + ]; + let e1 = entry(tenant); + let r1 = run_project( + &provider, + &projector, + &scope, + &e1, + &lines1, + &normal_sides, + 1, + ) + .await; + assert!(r1.is_ok(), "first projection must succeed: {r1:?}"); + + assert_eq!( + balance( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance WHERE account_id='{ar}'" + ) + ) + .await, + Some(1000) + ); + assert_eq!( + balance( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance WHERE account_id='{cash}'" + ) + ) + .await, + Some(1000) + ); + assert_eq!( + balance( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_ar_payer_balance WHERE account_id='{ar}'" + ) + ) + .await, + Some(1000) + ); + + // Project CR AR 1500 (overpay) → AR account_balance would go -500 → guard. + let lines2 = vec![ + line(ar, AccountClass::Ar, Side::Credit, 1500, payer), + line(cash, AccountClass::CashClearing, Side::Debit, 1500, payer), + ]; + let e2 = entry(tenant); + let r2 = run_project( + &provider, + &projector, + &scope, + &e2, + &lines2, + &normal_sides, + 2, + ) + .await; + assert!( + matches!(r2, Err(ProjectError::NegativeBalance { .. })), + "overpay must trip the no-negative guard: {r2:?}" + ); +} + +#[allow(clippy::too_many_arguments)] +async fn run_project( + provider: &DBProvider, + projector: &BalanceProjector, + scope: &AccessScope, + entry: &NewEntry, + lines: &[NewLine], + normal_sides: &HashMap, + seq: i64, +) -> Result<(), ProjectError> { + let projector = projector.clone(); + let scope = scope.clone(); + let entry = entry.clone(); + let lines = lines.to_vec(); + let normal_sides = normal_sides.clone(); + provider + .transaction(move |txn| { + Box::pin(async move { + Ok::<_, DbError>( + projector + .project(txn, &scope, &entry, &lines, &normal_sides, seq) + .await, + ) + }) + }) + .await + .unwrap() +} + +/// An AR line carrying an `invoice_id` fans out to the `ar_invoice_balance` +/// grain, and a `TAX_PAYABLE` line carrying both tax dims fans out to the +/// `tax_subbalance` grain — the two derived caches the first test does not +/// exercise. Both deltas are normal-side-positive, so no guard trips. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn projects_ar_invoice_and_tax_subgrains() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let payer = tenant; + let ar = Uuid::now_v7(); + let tax = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // AR normal DR (guarded); TAX_PAYABLE normal CR (not guarded). + let reference = ReferenceRepo::new(provider.clone()); + reference + .insert_account(account(tenant, ar, AccountClass::Ar, Side::Debit)) + .await + .unwrap(); + reference + .insert_account(account(tenant, tax, AccountClass::TaxPayable, Side::Credit)) + .await + .unwrap(); + + let mut normal_sides = HashMap::new(); + normal_sides.insert(ar, Side::Debit); + normal_sides.insert(tax, Side::Credit); + + // DR AR 1000 with an invoice → account + ar_payer + ar_invoice grains. + let mut ar_line = line(ar, AccountClass::Ar, Side::Debit, 1000, payer); + ar_line.invoice_id = Some("INV-1".to_owned()); + // CR TAX 200 with both tax dims → account + tax grains. + let mut tax_line = line(tax, AccountClass::TaxPayable, Side::Credit, 200, payer); + tax_line.tax_jurisdiction = Some("US-CA".to_owned()); + tax_line.tax_filing_period = Some("2026Q2".to_owned()); + + let e = entry(tenant); + let r = run_project( + &provider, + &BalanceProjector::new(), + &scope, + &e, + &[ar_line, tax_line], + &normal_sides, + 1, + ) + .await; + assert!(r.is_ok(), "projection must succeed: {r:?}"); + + // ar_invoice_balance grain populated for (ar, INV-1). + assert_eq!( + balance( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance WHERE account_id='{ar}' AND invoice_id='INV-1'" + ) + ) + .await, + Some(1000) + ); + // tax_subbalance grain populated for (tax, US-CA, 2026Q2). + assert_eq!( + balance( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_tax_subbalance WHERE account_id='{tax}'" + ) + ) + .await, + Some(200) + ); + // The TAX line also writes its account_balance grain (+200, CR on CR-normal). + assert_eq!( + balance( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance WHERE account_id='{tax}'" + ) + ) + .await, + Some(200) + ); +} + +/// B2: a `CR UNALLOCATED` line projects into `unallocated_balance` (+gross); a +/// `DR UNALLOCATED` line nets it down; an over-draw beyond the balance trips +/// the no-negative guard (`ProjectError::NegativeBalance`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn projects_unallocated_balance_and_enforces_no_negative() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let payer = tenant; + let cash = Uuid::now_v7(); + let unalloc = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // CASH_CLEARING normal DR (a settlement debits cash); UNALLOCATED normal CR + // (the unapplied-cash credit), guarded. + let reference = ReferenceRepo::new(provider.clone()); + reference + .insert_account(account( + tenant, + cash, + AccountClass::CashClearing, + Side::Debit, + )) + .await + .unwrap(); + reference + .insert_account(account( + tenant, + unalloc, + AccountClass::Unallocated, + Side::Credit, + )) + .await + .unwrap(); + + let mut normal_sides = HashMap::new(); + normal_sides.insert(cash, Side::Debit); + normal_sides.insert(unalloc, Side::Credit); + + let projector = BalanceProjector::new(); + + // Settlement: DR CASH_CLEARING 1000 / CR UNALLOCATED 1000 → +1000 unapplied. + let lines1 = vec![ + line(cash, AccountClass::CashClearing, Side::Debit, 1000, payer), + line( + unalloc, + AccountClass::Unallocated, + Side::Credit, + 1000, + payer, + ), + ]; + let e1 = entry(tenant); + let r1 = run_project( + &provider, + &projector, + &scope, + &e1, + &lines1, + &normal_sides, + 1, + ) + .await; + assert!(r1.is_ok(), "settlement projection must succeed: {r1:?}"); + assert_eq!( + balance( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_unallocated_balance \ + WHERE payer_tenant_id='{payer}' AND account_id='{unalloc}' AND currency='USD'" + ) + ) + .await, + Some(1000), + "unallocated_balance rises by gross" + ); + + // Allocation: DR UNALLOCATED 400 (apply 400) → nets unapplied to 600. + let lines2 = vec![ + line(unalloc, AccountClass::Unallocated, Side::Debit, 400, payer), + line(cash, AccountClass::CashClearing, Side::Credit, 400, payer), + ]; + let e2 = entry(tenant); + let r2 = run_project( + &provider, + &projector, + &scope, + &e2, + &lines2, + &normal_sides, + 2, + ) + .await; + assert!(r2.is_ok(), "allocation net-down must succeed: {r2:?}"); + assert_eq!( + balance( + &raw, + &format!( + "SELECT balance_minor FROM bss.ledger_unallocated_balance \ + WHERE payer_tenant_id='{payer}' AND account_id='{unalloc}' AND currency='USD'" + ) + ) + .await, + Some(600), + "DR UNALLOCATED nets the unapplied-cash balance down" + ); + + // Over-draw: DR UNALLOCATED 1000 against a 600 balance → would go -400. + let lines3 = vec![ + line(unalloc, AccountClass::Unallocated, Side::Debit, 1000, payer), + line(cash, AccountClass::CashClearing, Side::Credit, 1000, payer), + ]; + let e3 = entry(tenant); + let r3 = run_project( + &provider, + &projector, + &scope, + &e3, + &lines3, + &normal_sides, + 3, + ) + .await; + assert!( + matches!(r3, Err(ProjectError::NegativeBalance { .. })), + "over-draw must trip the no-negative guard: {r3:?}" + ); +} + +/// B3 (decision P): the projector stamps `ar_invoice_balance.original_posted_at` +/// + `due_date` from the entry's posted-at + the line's due date on the FIRST +/// write, and a later net-down (a payment-allocation CR AR) leaves both +/// UNCHANGED (first-write-wins; `on_conflict` omits the two columns). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn stamps_ar_invoice_original_posted_at_and_due_date_first_write_wins() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let payer = tenant; + let ar = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + let reference = ReferenceRepo::new(provider.clone()); + reference + .insert_account(account(tenant, ar, AccountClass::Ar, Side::Debit)) + .await + .unwrap(); + let mut normal_sides = HashMap::new(); + normal_sides.insert(ar, Side::Debit); + + let projector = BalanceProjector::new(); + + // First write: INVOICE_POST DR AR 1000 with due_date d at posted_at t1. + let t1 = DateTime::parse_from_rfc3339("2026-06-01T10:00:00Z") + .unwrap() + .with_timezone(&Utc); + let due = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(); + let mut e1 = entry(tenant); + e1.source_doc_type = SourceDocType::InvoicePost; + e1.posted_at_utc = t1; + let mut ar_line = line(ar, AccountClass::Ar, Side::Debit, 1000, payer); + ar_line.invoice_id = Some("INV-P".to_owned()); + ar_line.due_date = Some(due); + let r1 = run_project( + &provider, + &projector, + &scope, + &e1, + &[ar_line], + &normal_sides, + 1, + ) + .await; + assert!( + r1.is_ok(), + "first AR-invoice projection must succeed: {r1:?}" + ); + + let (posted_at, due_date) = ar_invoice_stamps(&raw, ar, "INV-P").await; + assert_eq!( + posted_at, + Some(t1), + "original_posted_at stamped on first write" + ); + assert_eq!(due_date, Some(due), "due_date stamped on first write"); + + // Second write: a payment-allocation CR AR 400 net-down at t2 > t1. + let t2 = DateTime::parse_from_rfc3339("2026-06-15T10:00:00Z") + .unwrap() + .with_timezone(&Utc); + let mut e2 = entry(tenant); + e2.source_doc_type = SourceDocType::PaymentAllocate; + e2.posted_at_utc = t2; + let mut net_line = line(ar, AccountClass::Ar, Side::Credit, 400, payer); + net_line.invoice_id = Some("INV-P".to_owned()); + // A different (later) due_date on the net-down must NOT overwrite the stamp. + net_line.due_date = Some(NaiveDate::from_ymd_opt(2026, 9, 1).unwrap()); + let r2 = run_project( + &provider, + &projector, + &scope, + &e2, + &[net_line], + &normal_sides, + 2, + ) + .await; + assert!(r2.is_ok(), "net-down projection must succeed: {r2:?}"); + + let (posted_at2, due_date2) = ar_invoice_stamps(&raw, ar, "INV-P").await; + assert_eq!( + posted_at2, + Some(t1), + "original_posted_at UNCHANGED after net-down" + ); + assert_eq!(due_date2, Some(due), "due_date UNCHANGED after net-down"); + // And the balance did net down (sanity). + assert_eq!( + balance( + &raw, + &format!("SELECT balance_minor FROM bss.ledger_ar_invoice_balance WHERE account_id='{ar}' AND invoice_id='INV-P'") + ) + .await, + Some(600), + "AR invoice balance nets to 600" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_provisioning.rs b/gears/bss/ledger/ledger/tests/postgres_provisioning.rs new file mode 100644 index 000000000..5843a46dd --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_provisioning.rs @@ -0,0 +1,321 @@ +//! Postgres-only integration tests for the transactional seller-provisioning +//! seed (`ProvisioningService::provision`). Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_provisioning -- --ignored`. +//! +//! Covers: (a) the seed is idempotent + additive across repeated calls +//! (created-vs-existing counts + raw row counts hold); (b) a scale exceeding +//! `i64` headroom rolls back the WHOLE transaction (the account seeded earlier +//! in the same call is gone). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use bss_ledger::domain::error::DomainError; +use bss_ledger::infra::provisioning::service::ProvisioningService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{ + AccountClass, FiscalCalendarSpec, Granularity, ODataQuery, ProvisionAccount, + ProvisionCurrencyScale, ProvisionRequest, Side, +}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Run `SELECT count(*) ...` and return the `i64` count (the reference-test +/// extraction idiom: `row.try_get::("", "count")`). +async fn count(db: &DatabaseConnection, sql: impl Into) -> i64 { + let row = db + .query_one(pg(sql)) + .await + .unwrap() + .expect("count query must return a row"); + row.try_get::("", "count").unwrap() +} + +fn account( + class: AccountClass, + currency: &str, + revenue_stream: Option<&str>, + side: Side, +) -> ProvisionAccount { + ProvisionAccount { + account_class: class, + currency: currency.to_owned(), + revenue_stream: revenue_stream.map(str::to_owned), + normal_side: side, + may_go_negative: false, + } +} + +// A non-ISO currency scale within the default headroom (`plausible_max_major` +// omitted -> 10^12, max scale 6: the guard rejects 10^12 * 10^scale > i64). A +// higher-precision currency (e.g. BTC=8) registers a smaller per-currency max. +fn noniso_scale() -> ProvisionCurrencyScale { + ProvisionCurrencyScale { + currency: "QQQ".to_owned(), + minor_units: 4, + plausible_max_major: None, + source: "TENANT".to_owned(), + } +} + +fn utc_calendar() -> FiscalCalendarSpec { + FiscalCalendarSpec { + timezone: "UTC".to_owned(), + granularity: Granularity::Month, + fy_start_month: 1, + // S5-F3: seed the legal-entity functional currency so the round-trip read + // (asserted below) is exercised; an unset value would be a single-currency + // tenant. + functional_currency: Some("USD".to_owned()), + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn provision_is_idempotent_and_additive() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + // Raw sea-orm connection for the migrator + bss-qualified assertions. + let db = Database::connect(&url).await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + + // The service connection sets search_path=bss (as the gear config does in + // prod) so its unqualified entity queries resolve into the bss schema. + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + let service = ProvisioningService::new(provider.clone()); + + let tenant_id = Uuid::new_v4(); + let expected_period = chrono::Utc::now().format("%Y%m").to_string(); + + // --- Call #1: seed everything fresh. --- + let req1 = ProvisionRequest { + tenant_id, + accounts: vec![ + account(AccountClass::Ar, "USD", None, Side::Debit), + account(AccountClass::Revenue, "USD", Some("compute"), Side::Credit), + ], + currency_scales: vec![noniso_scale()], + fiscal_calendar: utc_calendar(), + }; + let out1 = service.provision(req1).await.expect("first provision ok"); + assert_eq!(out1.accounts_created, 2, "first call: 2 new accounts"); + assert_eq!(out1.accounts_existing, 0); + assert_eq!(out1.scales_created, 1); + assert_eq!(out1.scales_existing, 0); + assert!(out1.calendar_created); + assert!(out1.period_created); + assert_eq!(out1.period_id, expected_period); + // (1) provision returns the accounts it created, each with a non-nil id. + assert_eq!(out1.accounts.len(), 2); + assert!( + out1.accounts.iter().all(|a| !a.account_id.is_nil()), + "first call: created accounts carry real ids" + ); + + // Rows landed. + assert_eq!( + count( + &db, + format!("SELECT count(*) FROM bss.ledger_tenant_account WHERE tenant_id='{tenant_id}'") + ) + .await, + 2 + ); + assert_eq!( + count( + &db, + format!( + "SELECT count(*) FROM bss.ledger_currency_scale_registry \ + WHERE tenant_id='{tenant_id}' AND currency='QQQ'" + ) + ) + .await, + 1 + ); + assert_eq!( + count( + &db, + format!( + "SELECT count(*) FROM bss.ledger_fiscal_calendar \ + WHERE tenant_id='{tenant_id}' AND legal_entity_id='{tenant_id}'" + ) + ) + .await, + 1 + ); + let status_row = db + .query_one(pg(format!( + "SELECT status FROM bss.ledger_fiscal_period \ + WHERE tenant_id='{tenant_id}' AND legal_entity_id='{tenant_id}' \ + AND period_id='{expected_period}'" + ))) + .await + .unwrap() + .expect("fiscal_period row must exist"); + let status: String = status_row.try_get("", "status").unwrap(); + assert_eq!(status, "OPEN"); + + // --- Call #2: identical request -> everything additive no-op. --- + let req2 = ProvisionRequest { + tenant_id, + accounts: vec![ + account(AccountClass::Ar, "USD", None, Side::Debit), + account(AccountClass::Revenue, "USD", Some("compute"), Side::Credit), + ], + currency_scales: vec![noniso_scale()], + fiscal_calendar: utc_calendar(), + }; + let out2 = service.provision(req2).await.expect("second provision ok"); + assert_eq!(out2.accounts_created, 0, "re-call: no new accounts"); + assert_eq!(out2.accounts_existing, 2); + assert_eq!(out2.scales_created, 0); + assert_eq!(out2.scales_existing, 1); + assert!(!out2.calendar_created); + assert!(!out2.period_created); + // (1) re-call creates nothing → returns no accounts (the full chart, with the + // SAME ids, is read via list_accounts below). + assert!(out2.accounts.is_empty(), "re-call creates nothing"); + // list_accounts returns the full chart with the SAME ids provision created. + let repo = ReferenceRepo::new(provider.clone()); + let listed = repo + .list_accounts( + &AccessScope::for_tenant(tenant_id), + tenant_id, + &ODataQuery::default(), + ) + .await + .expect("list_accounts ok"); + let mut created_ids: Vec<_> = out1.accounts.iter().map(|a| a.account_id).collect(); + let mut listed_ids: Vec<_> = listed.items.iter().map(|a| a.account_id).collect(); + created_ids.sort(); + listed_ids.sort(); + assert_eq!( + listed_ids, created_ids, + "list returns the same ids provision created" + ); + + // S5-F3: provisioning seeded the legal-entity functional currency onto the + // fiscal-calendar row; it reads back via the dedicated scoped lookup. + assert_eq!( + repo.functional_currency(&AccessScope::for_tenant(tenant_id), tenant_id) + .await + .expect("functional_currency read ok"), + Some("USD".to_owned()), + "the seeded functional currency round-trips" + ); + + // Counts unchanged (no duplicates against uq_tenant_account_coa + PKs). + assert_eq!( + count( + &db, + format!("SELECT count(*) FROM bss.ledger_tenant_account WHERE tenant_id='{tenant_id}'") + ) + .await, + 2 + ); + + // --- Call #3: add one new account; resubmit existing scale + calendar. --- + let req3 = ProvisionRequest { + tenant_id, + accounts: vec![ + account(AccountClass::CashClearing, "USD", None, Side::Debit), + account(AccountClass::Ar, "USD", None, Side::Debit), + account(AccountClass::Revenue, "USD", Some("compute"), Side::Credit), + ], + currency_scales: vec![noniso_scale()], + fiscal_calendar: utc_calendar(), + }; + let out3 = service.provision(req3).await.expect("third provision ok"); + assert_eq!(out3.accounts_created, 1, "third call: 1 new account"); + assert_eq!(out3.accounts_existing, 2); + assert_eq!( + count( + &db, + format!("SELECT count(*) FROM bss.ledger_tenant_account WHERE tenant_id='{tenant_id}'") + ) + .await, + 3 + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn provision_rolls_back_on_out_of_range_scale() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let db = Database::connect(&url).await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + let service = ProvisioningService::new(provider); + + let tenant_id = Uuid::new_v4(); + + // One valid account FOLLOWED BY a scale that exceeds i64 headroom: the + // whole transaction must roll back, so the account is gone too. + let req = ProvisionRequest { + tenant_id, + accounts: vec![account(AccountClass::Ar, "USD", None, Side::Debit)], + currency_scales: vec![ProvisionCurrencyScale { + currency: "BIG".to_owned(), + minor_units: 18, + plausible_max_major: None, + source: "TENANT".to_owned(), + }], + fiscal_calendar: utc_calendar(), + }; + + let err = service + .provision(req) + .await + .expect_err("out-of-headroom scale must be rejected"); + match err { + DomainError::ScaleOutOfRange(c) => assert!(c.contains("BIG"), "got {c}"), + other => panic!("expected ScaleOutOfRange(BIG), got {other:?}"), + } + + // Rollback: the account seeded earlier in the SAME txn is gone, and no + // fiscal period was committed. + assert_eq!( + count( + &db, + format!("SELECT count(*) FROM bss.ledger_tenant_account WHERE tenant_id='{tenant_id}'") + ) + .await, + 0, + "the account from the rolled-back txn must not persist" + ); + assert_eq!( + count( + &db, + format!("SELECT count(*) FROM bss.ledger_fiscal_period WHERE tenant_id='{tenant_id}'") + ) + .await, + 0 + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_queue.rs b/gears/bss/ledger/ledger/tests/postgres_queue.rs new file mode 100644 index 000000000..fd138eb06 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_queue.rs @@ -0,0 +1,914 @@ +//! Postgres-only integration tests for the §4.7 **allocation-before-settlement +//! queue** — the deferred-apply mechanism end-to-end: intake (a not-yet-settled +//! allocate durably enqueues + dedups `QUEUED` instead of posting), queued +//! replay (a second allocate of the same id returns the handle without a second +//! row), drain-on-settle (a `settle` applies the tenant's queue), the +//! cap-re-evaluation at apply (an over-cap queued allocation stays `QUEUED` + +//! bumps attempts), and the cross-tenant sweep job (`QueueApplierJob`) as the +//! restart/backstop path. Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_queue -- --ignored`. +//! +//! Run discipline (controller): these are testcontainer `#[ignore]` tests; run +//! the bin sequentially (each test boots its own container). +//! +//! Self-contained: re-declares the small `boot` / `setup_seller` / `settle_svc` +//! / `allocate_svc` / `seed_ar_invoice` / `pg` helpers it needs (the templates +//! duplicate per-file), plus raw-SQL readers for the queue + dedup tables +//! (`queue_status` / `dedup_status`) keyed on the `PAYMENT_ALLOCATE` flow. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::needless_pass_by_value +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine, RepoError}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::jobs::queue_applier::QueueApplierJob; +use bss_ledger::infra::payment::allocate::{AllocateRequest, AllocationOutcome, AllocationService}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{PaymentRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DbErr, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Lift a component `RepoError` into a `DbError` so a repo write can be the +/// transaction's typed success value (`T`), surviving COMMIT (mirrors +/// `postgres_payments.rs`). Used by `sweep_applies_seeded_settlement` to seed a +/// settlement DIRECTLY in a `db.transaction`. +fn lift(e: RepoError) -> DbError { + DbError::Sea(DbErr::Custom(e.to_string())) +} + +/// Boot a container, run the chain on a raw connection, and return a +/// `bss`-search-path `DBProvider`. Mirrors `postgres_payments::boot`. +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller ids (mirrors `postgres_payments::Seller`). +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision a seller: USD@2 scale, an OPEN fiscal period for the current +/// month, and the four payment-flow chart accounts (CASH_CLEARING debit, +/// UNALLOCATED credit, PSP_FEE_EXPENSE debit, AR debit). Mirrors +/// `postgres_payments::setup_seller`. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn allocate_svc(provider: &DBProvider) -> AllocationService { + AllocationService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn settlement_input(s: &Seller, payment_id: &str, gross: i64, fee: i64) -> SettlementInput { + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "USD".to_owned(), + effective_at: None, + } +} + +/// An allocate request builder for the queue tests: lump in USD, no hint, no +/// caller split (the precedence/oldest-first path). `allocation_id` is supplied +/// explicitly so a test can replay the SAME id. +fn allocate_req(s: &Seller, payment_id: &str, allocation_id: Uuid, lump: i64) -> AllocateRequest { + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + allocation_id, + lump_minor: lump, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + } +} + +async fn ar_invoice_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND invoice_id='{}'", + s.tenant, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn count_allocations(raw: &sea_orm::DatabaseConnection, s: &Seller, payment_id: &str) -> i64 { + raw.query_one(pg(format!( + "SELECT COUNT(*) FROM bss.ledger_payment_allocation \ + WHERE tenant_id='{}' AND payment_id='{}'", + s.tenant, payment_id + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +/// The work-state queue-row status for an allocation (its `business_id` is the +/// `allocation_id` string, its `flow` the `PAYMENT_ALLOCATE` literal). `None` +/// when no queue row exists. The drain flips this `QUEUED → APPLIED`. +async fn queue_status( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + allocation_id: Uuid, +) -> Option { + raw.query_one(pg(format!( + "SELECT status FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='PAYMENT_ALLOCATE' AND business_id='{allocation_id}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Count the queue rows for one allocation (to prove a replay adds no second +/// row). Keyed identically to [`queue_status`]. +async fn queue_row_count( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + allocation_id: Uuid, +) -> i64 { + raw.query_one(pg(format!( + "SELECT COUNT(*) FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='PAYMENT_ALLOCATE' AND business_id='{allocation_id}'", + s.tenant + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +/// One queue row's `attempts` counter (bumped when a queued allocation is +/// `Blocked` at apply by a re-evaluated cap). `None` when no row exists. +async fn queue_attempts( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + allocation_id: Uuid, +) -> Option { + raw.query_one(pg(format!( + "SELECT attempts FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='PAYMENT_ALLOCATE' AND business_id='{allocation_id}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// One queue row's `apply_after` (the backoff deferral instant set when a queued +/// allocation is `Blocked` at apply). `None` when the column is NULL (immediately +/// eligible) or no row exists. +async fn queue_apply_after( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + allocation_id: Uuid, +) -> Option> { + raw.query_one(pg(format!( + "SELECT apply_after FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='PAYMENT_ALLOCATE' AND business_id='{allocation_id}'", + s.tenant + ))) + .await + .unwrap() + .and_then(|r| r.try_get_by_index::>>(0).unwrap()) +} + +/// The dedup-row `(status, result_entry_id)` for an allocation (the +/// `PAYMENT_ALLOCATE` dedup key). A queued allocation is `("QUEUED", None)`; an +/// applied one is `("POSTED", Some(entry_id))`. `None` when no dedup row exists. +async fn dedup_status( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + allocation_id: Uuid, +) -> Option<(String, Option)> { + raw.query_one(pg(format!( + "SELECT status, result_entry_id FROM bss.ledger_idempotency_dedup \ + WHERE tenant_id='{}' AND flow='PAYMENT_ALLOCATE' AND business_id='{allocation_id}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| { + ( + r.try_get_by_index::(0).unwrap(), + r.try_get_by_index::>(1).unwrap(), + ) + }) +} + +/// Seed an OPEN AR invoice by posting `DR AR (invoice_id) / CR PSP_FEE_EXPENSE` +/// directly through the engine (mirrors `postgres_payments::seed_ar_invoice`). +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// §4.7 intake: an allocate of a payment that was NEVER settled does NOT post — +/// it durably enqueues. The outcome is `Queued`, a `QUEUED` queue row exists, the +/// dedup is `("QUEUED", None)` (no `result_entry_id` — nothing posted), and there +/// are NO `payment_allocation` rows and the AR is untouched. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn unsettled_allocate_queues() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Seed an open AR invoice, but DO NOT settle PAY-Q1 — the allocate must queue + // (the §4.7 allocation-before-settlement path), not reject or post. + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let allocation_id = Uuid::now_v7(); + let outcome = allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_req(&s, "PAY-Q1", allocation_id, 300)) + .await + .expect("an unsettled allocate must queue (not error)"); + assert!( + matches!(outcome, AllocationOutcome::Queued(_)), + "an unsettled allocate must be Queued, got {outcome:?}" + ); + + // Durable QUEUED row + QUEUED dedup with NO result_entry_id (nothing posted). + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("QUEUED") + ); + assert_eq!( + dedup_status(&raw, &s, allocation_id).await, + Some(("QUEUED".to_owned(), None)), + "dedup is QUEUED with no result entry id" + ); + + // Nothing posted: no allocation rows, AR untouched. + assert_eq!(count_allocations(&raw, &s, "PAY-Q1").await, 0); + assert_eq!( + ar_invoice_balance(&raw, &s, "INV-A").await, + Some(300), + "AR untouched by a queued allocate" + ); +} + +/// §4.7 queued replay: a second allocate of the SAME `allocation_id` while still +/// unsettled returns the same `Queued` handle — it does NOT enqueue a second row. +/// Exactly ONE queue row exists for the key, and the dedup stays `QUEUED`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn queued_replay_returns_handle() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let svc = allocate_svc(&provider); + let allocation_id = Uuid::now_v7(); + + // First allocate enqueues. + let first = svc + .allocate(&ctx, &scope, allocate_req(&s, "PAY-Q2", allocation_id, 300)) + .await + .expect("first unsettled allocate queues"); + assert!(matches!(first, AllocationOutcome::Queued(_))); + + // Second allocate of the SAME id (still unsettled) replays the handle. + let second = svc + .allocate(&ctx, &scope, allocate_req(&s, "PAY-Q2", allocation_id, 300)) + .await + .expect("queued replay returns the handle"); + assert!( + matches!(second, AllocationOutcome::Queued(_)), + "a queued replay is still Queued, got {second:?}" + ); + + // Exactly one queue row + still-QUEUED dedup — no double-enqueue. + assert_eq!( + queue_row_count(&raw, &s, allocation_id).await, + 1, + "the same allocation_id enqueues exactly one queue row" + ); + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("QUEUED") + ); + assert_eq!( + dedup_status(&raw, &s, allocation_id).await, + Some(("QUEUED".to_owned(), None)) + ); +} + +/// Drain-on-settle: a queued allocate (lump 300) is applied when the payment is +/// later settled — `SettlementService::settle` drains the tenant's queue after +/// committing. The queue row flips `→APPLIED`, the dedup flips `→POSTED` with a +/// `result_entry_id`, a `payment_allocation` row exists, and the AR drains to 0. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn settle_drains_queued_allocation() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Seed AR (inv=300) and queue an allocate of 300 on PAY-DRAIN BEFORE settling. + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + let allocation_id = Uuid::now_v7(); + let queued = allocate_svc(&provider) + .allocate( + &ctx, + &scope, + allocate_req(&s, "PAY-DRAIN", allocation_id, 300), + ) + .await + .expect("unsettled allocate queues"); + assert!(matches!(queued, AllocationOutcome::Queued(_))); + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("QUEUED") + ); + + // Settle PAY-DRAIN (gross 1000 funds the pool) — the drain-on-settle hook then + // applies the queued allocation in a second txn. + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-DRAIN", 1000, 0)) + .await + .expect("settle must succeed (and drain the queue)"); + + // The queued allocation applied: queue → APPLIED, dedup → POSTED (+ entry id). + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("APPLIED"), + "the drain flipped the queue row to APPLIED" + ); + let (status, entry_id) = dedup_status(&raw, &s, allocation_id) + .await + .expect("dedup row present after apply"); + assert_eq!(status, "POSTED", "dedup flipped to POSTED on apply"); + assert!( + entry_id.is_some(), + "a POSTED dedup carries the result entry id" + ); + + // The ledger effect landed: one allocation row, AR drained to 0. + assert_eq!(count_allocations(&raw, &s, "PAY-DRAIN").await, 1); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(0)); + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY-DRAIN") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.allocated_minor, 300, + "the drained allocation netted 300" + ); +} + +/// Cap re-evaluation at apply (§4.7): the per-payment money-out cap is re-checked +/// when the queued allocation is applied, NOT trusted from intake. Queue an +/// allocate of lump=1000 on PAY-CAP with AR inv=1000 (so the AR is NOT the +/// binding constraint), then settle PAY-CAP at only gross=500. At apply the split +/// re-derives to 1000 against the still-open 1000 AR, but bumping `allocated_minor` +/// to 1000 > settled 500 trips the per-payment cap ⇒ the row is `Blocked`: it +/// stays `QUEUED` with `attempts >= 1`, the dedup stays `QUEUED`, the AR is +/// unchanged, and NO `payment_allocation` row is written (the apply rolled back). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cap_reevaluated_at_apply_blocks() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // AR inv=1000 (>= lump), so the per-payment cap — not the AR open balance — is + // the binding constraint at apply (the prompt's AR >= lump > settled shape). + seed_ar_invoice( + &provider, + &s, + "INV-A", + 1000, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + // Queue an allocate of the WHOLE 1000 on PAY-CAP while unsettled. + let allocation_id = Uuid::now_v7(); + let queued = allocate_svc(&provider) + .allocate( + &ctx, + &scope, + allocate_req(&s, "PAY-CAP", allocation_id, 1000), + ) + .await + .expect("unsettled allocate queues"); + assert!(matches!(queued, AllocationOutcome::Queued(_))); + + // Settle PAY-CAP at only 500 ⇒ settled_minor=500 < the queued lump 1000. The + // drain-on-settle applies the queue; at apply the cap (allocated 1000 > settled + // 500) blocks it. The settle itself still succeeds (the drain swallows errors). + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-CAP", 500, 0)) + .await + .expect("settle succeeds even though the queued allocation is blocked"); + + // The over-cap apply was blocked: the row stays QUEUED with attempts bumped. + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("QUEUED"), + "a cap-blocked queued allocation stays QUEUED" + ); + assert!( + queue_attempts(&raw, &s, allocation_id).await.unwrap_or(0) >= 1, + "a blocked apply bumps attempts" + ); + assert!( + queue_apply_after(&raw, &s, allocation_id).await.is_some(), + "a blocked apply defers the row via apply_after (backoff), so a poison \ + row is not re-claimed on every drain pass" + ); + assert_eq!( + dedup_status(&raw, &s, allocation_id).await, + Some(("QUEUED".to_owned(), None)), + "the dedup stays QUEUED (nothing posted)" + ); + + // Nothing applied: AR unchanged, no allocation row, allocated_minor still 0. + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(1000)); + assert_eq!(count_allocations(&raw, &s, "PAY-CAP").await, 0); + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY-CAP") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.allocated_minor, 0, + "the blocked apply left allocated at 0" + ); +} + +/// Idempotency-key reuse with a DIFFERENT payload is a conflict, not a silent +/// replay (Codex P3). Settle PAY-FP (gross 1000) with AR inv=1000, allocate +/// `alloc_id` lump=600 (posts inline), then re-send the SAME `alloc_id`: an +/// IDENTICAL request replays cleanly, but a different lump is rejected as +/// `IdempotencyConflict` — the replay short-circuit compares the request-based +/// dedup hash instead of blindly returning the prior result. The original 600 +/// allocation is left untouched. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_idempotency_key_reuse_with_different_payload_conflicts() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + seed_ar_invoice( + &provider, + &s, + "INV-FP", + 1000, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + settle_svc(&provider) + .settle(&ctx, &scope, settlement_input(&s, "PAY-FP", 1000, 0)) + .await + .expect("settle"); + + let alloc_id = Uuid::now_v7(); + let applied = allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_req(&s, "PAY-FP", alloc_id, 600)) + .await + .expect("first allocate posts"); + assert!(matches!(applied, AllocationOutcome::Applied(_))); + + // Same id, IDENTICAL payload ⇒ a clean replay (no conflict). + let replay = allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_req(&s, "PAY-FP", alloc_id, 600)) + .await + .expect("identical replay is accepted"); + assert!( + matches!(replay, AllocationOutcome::Applied(ref a) if a.posting.replayed), + "an identical re-send replays the prior posting" + ); + + // Same id, DIFFERENT lump ⇒ idempotency conflict, not a silent replay. + let err = allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_req(&s, "PAY-FP", alloc_id, 400)) + .await + .expect_err("reused id with a different payload is rejected"); + assert!( + matches!(err, DomainError::IdempotencyConflict(_)), + "expected IdempotencyConflict, got {err:?}" + ); + + // The original 600 allocation is untouched (still exactly one allocation row). + assert_eq!(count_allocations(&raw, &s, "PAY-FP").await, 1); +} + +/// QUEUED-path idempotency conflict: an allocate-before-settlement queues under +/// `allocation_id`; re-submitting the SAME id with a DIFFERENT lump (still +/// unsettled) is rejected as `IdempotencyConflict`, not silently re-queued — the +/// request-hash compare in `replay_short_circuit` (the deterministic sibling of +/// the in-txn intake-race guard). An identical re-submit still returns the queued +/// handle with no duplicate row. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn queued_allocate_reuse_with_different_payload_conflicts() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Queue an allocate of an unsettled payment. + let alloc_id = Uuid::now_v7(); + let queued = allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_req(&s, "PAY-QC", alloc_id, 600)) + .await + .expect("unsettled allocate queues"); + assert!(matches!(queued, AllocationOutcome::Queued(_))); + + // Identical re-submit ⇒ same queued handle (idempotent, no second row). + let again = allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_req(&s, "PAY-QC", alloc_id, 600)) + .await + .expect("identical re-submit returns the queued handle"); + assert!(matches!(again, AllocationOutcome::Queued(_))); + assert_eq!( + queue_row_count(&raw, &s, alloc_id).await, + 1, + "an identical re-submit adds no duplicate queue row" + ); + + // Same id, DIFFERENT lump ⇒ idempotency conflict. + let err = allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_req(&s, "PAY-QC", alloc_id, 400)) + .await + .expect_err("reused id with a different payload is rejected"); + assert!( + matches!(err, DomainError::IdempotencyConflict(_)), + "expected IdempotencyConflict, got {err:?}" + ); +} + +/// The cross-tenant sweep job (`QueueApplierJob`) is the restart/backstop path: +/// it applies a queued allocation whose settlement landed through some path that +/// did NOT drain. Queue an allocate (lump 300) on PAY-SWEEP with AR inv=300, then +/// seed the settlement row DIRECTLY via `PaymentRepo::seed_settlement` in a +/// `db.transaction` (so NO drain-on-settle ran — the queue row is still `QUEUED`). +/// `QueueApplierJob::run()` then finds + applies it: the row flips `→APPLIED`, the +/// dedup `→POSTED`, and the AR drains to 0. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn sweep_applies_seeded_settlement() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Seed AR (inv=300) and queue an allocate of 300 on PAY-SWEEP while unsettled. + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + let allocation_id = Uuid::now_v7(); + let queued = allocate_svc(&provider) + .allocate( + &ctx, + &scope, + allocate_req(&s, "PAY-SWEEP", allocation_id, 300), + ) + .await + .expect("unsettled allocate queues"); + assert!(matches!(queued, AllocationOutcome::Queued(_))); + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("QUEUED") + ); + + // Seed the settlement DIRECTLY (bypassing SettlementService → no drain runs), + // so the queue row is still QUEUED when the sweep starts (the restart path). + let scope_seed = scope.clone(); + provider + .transaction(move |txn| { + let scope = scope_seed.clone(); + Box::pin(async move { + PaymentRepo::seed_settlement(txn, &scope, s.tenant, "PAY-SWEEP", "USD", 1000, 0) + .await + .map_err(lift) + }) + }) + .await + .expect("seed settlement directly"); + // A real settle CRs the UNALLOCATED pool (the cash the allocation draws). The + // direct counter seed bypasses that, so seed the post-settle projector state + // for UNALLOCATED — BOTH guarded grains the apply's DR UNALLOCATED touches: + // the aggregate `account_balance` (per account) AND the per-payer + // `unallocated_balance` pool. Without `account_balance` the projector's + // guarded no-negative pre-check on the aggregate drives it to -amount and the + // row is (correctly) blocked instead of applied. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_account_balance \ + (tenant_id, account_id, currency, account_class, normal_side, balance_minor) \ + VALUES ('{}','{}','USD','UNALLOCATED','CR',1000)", + s.tenant, s.unallocated + ))) + .await + .expect("seed unallocated account_balance"); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_unallocated_balance \ + (tenant_id, payer_tenant_id, account_id, currency, balance_minor) \ + VALUES ('{}','{}','{}','USD',1000)", + s.tenant, s.payer, s.unallocated + ))) + .await + .expect("seed unallocated pool"); + // Sanity: the seed did NOT trigger a drain — the row is still QUEUED. + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("QUEUED"), + "seeding the settlement directly must not apply the queued allocation" + ); + + // The sweep job (cross-tenant) discovers the due QUEUED row and applies it. + let report = QueueApplierJob::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .run() + .await + .expect("sweep must run"); + assert!( + report.applied >= 1, + "the sweep applied at least one row: {report:?}" + ); + + // Applied by the sweep: queue → APPLIED, dedup → POSTED (+ entry id), AR → 0. + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("APPLIED"), + "the sweep flipped the queue row to APPLIED" + ); + let (status, entry_id) = dedup_status(&raw, &s, allocation_id) + .await + .expect("dedup row present after sweep apply"); + assert_eq!(status, "POSTED"); + assert!(entry_id.is_some()); + assert_eq!(count_allocations(&raw, &s, "PAY-SWEEP").await, 1); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(0)); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_queue_concurrency.rs b/gears/bss/ledger/ledger/tests/postgres_queue_concurrency.rs new file mode 100644 index 000000000..d342dea77 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_queue_concurrency.rs @@ -0,0 +1,652 @@ +//! Postgres-only **concurrency** tests for the deferred-apply queue (Slice 2b, +//! Phase 3, Group F): two appliers racing the SAME queued allocation must apply +//! it EXACTLY ONCE. Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_queue_concurrency -- --ignored`. +//! +//! Run discipline (controller): testcontainer `#[ignore]` tests; run the bin +//! sequentially (each test boots its own container). +//! +//! Covers: (1) two concurrent `AllocationService::drain`s of one tenant whose +//! queue holds a single due allocation apply it EXACTLY ONCE — one +//! `payment_allocation` row, `allocated_minor == 300` (not 600), queue +//! `APPLIED`, dedup `POSTED`. The `FOR UPDATE SKIP LOCKED` claim + the +//! `QueuedApply` POSTED-replay short-circuit are the two guards; a loser that +//! claims nothing (or replays the POSTED winner) never lands a second effect. +//! (2) A `settle` (which drains) racing a `QueueApplierJob::run()` on the same +//! tenant: both complete without deadlock and the allocation applies once. +//! +//! Self-contained: re-declares the small `boot` / `setup_seller` / `settle_svc` +//! / `allocate_svc` / `seed_ar_invoice` / `pg` / `retry_on_serialization` +//! helpers it needs (mirrors `postgres_payment_concurrency.rs`), plus the +//! queue/dedup raw-SQL readers. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::needless_pass_by_value +)] + +use std::sync::Arc; + +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine, RepoError}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::jobs::queue_applier::QueueApplierJob; +use bss_ledger::infra::payment::allocate::{AllocateRequest, AllocationOutcome, AllocationService}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{PaymentRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DbErr, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Lift a component `RepoError` into a `DbError` so an in-txn repo write can be +/// the transaction's typed success value (`T`), surviving COMMIT (mirrors +/// `postgres_payments.rs`). Used to seed a settlement DIRECTLY (no drain). +fn lift(e: RepoError) -> DbError { + DbError::Sea(DbErr::Custom(e.to_string())) +} + +/// Boot a container, migrate on a raw connection, and return a +/// `bss`-search-path `DBProvider`. Mirrors `postgres_payment_concurrency::boot`. +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller ids for the concurrency tests (mirrors +/// `postgres_payment_concurrency::Seller`). +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision a seller: USD@2 scale, an OPEN fiscal period for the current +/// month, and the four payment-flow chart accounts. Mirrors +/// `postgres_payment_concurrency::setup_seller`. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn allocate_svc(provider: &DBProvider) -> AllocationService { + AllocationService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn settlement_input(s: &Seller, payment_id: &str, gross: i64, fee: i64) -> SettlementInput { + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "USD".to_owned(), + effective_at: None, + } +} + +/// An allocate request builder (lump in USD, no hint, no caller split — the +/// precedence/oldest-first path). `allocation_id` is supplied explicitly. +fn allocate_req(s: &Seller, payment_id: &str, allocation_id: Uuid, lump: i64) -> AllocateRequest { + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + allocation_id, + lump_minor: lump, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + } +} + +/// Client-side retry of a service call on a projector-level serialization +/// conflict (mirrors `postgres_payment_concurrency::retry_on_serialization`). +/// Under `SERIALIZABLE`, two appliers racing the same grain make one abort with +/// a 40001 the projector stringifies into `DomainError::Internal("…serialize…")` +/// — the caller re-runs the whole op. A genuine deadlock (40P01) is NOT a +/// serialization conflict and propagates, failing the test loudly (the +/// deadlock-freedom guarantee). +async fn retry_on_serialization(mut op: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for _ in 0..20 { + match op().await { + Err(DomainError::Internal(m)) if m.contains("serialize") => {} + other => return other, + } + } + op().await +} + +// ── Queue + dedup raw-SQL readers (keyed on the PAYMENT_ALLOCATE flow) ──────── + +async fn queue_status( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + allocation_id: Uuid, +) -> Option { + raw.query_one(pg(format!( + "SELECT status FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='PAYMENT_ALLOCATE' AND business_id='{allocation_id}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn dedup_status( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + allocation_id: Uuid, +) -> Option<(String, Option)> { + raw.query_one(pg(format!( + "SELECT status, result_entry_id FROM bss.ledger_idempotency_dedup \ + WHERE tenant_id='{}' AND flow='PAYMENT_ALLOCATE' AND business_id='{allocation_id}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| { + ( + r.try_get_by_index::(0).unwrap(), + r.try_get_by_index::>(1).unwrap(), + ) + }) +} + +async fn ar_invoice_balance( + raw: &sea_orm::DatabaseConnection, + s: &Seller, + invoice_id: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance \ + WHERE tenant_id='{}' AND invoice_id='{}'", + s.tenant, invoice_id + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn count_allocations(raw: &sea_orm::DatabaseConnection, s: &Seller, payment_id: &str) -> i64 { + raw.query_one(pg(format!( + "SELECT COUNT(*) FROM bss.ledger_payment_allocation \ + WHERE tenant_id='{}' AND payment_id='{}'", + s.tenant, payment_id + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +/// Seed an OPEN AR invoice by posting `DR AR (invoice_id) / CR PSP_FEE_EXPENSE` +/// directly through the engine (mirrors +/// `postgres_payment_concurrency::seed_ar_invoice`). +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Queue an allocate (lump 300) on `payment_id`, seed an open AR (inv 300), then +/// seed the settlement row DIRECTLY in a `db.transaction` so NO drain ran — the +/// queue row is `QUEUED` and now "due". Returns the `allocation_id`. Shared +/// setup for the race tests (the racing appliers then compete to apply it). +async fn queue_with_seeded_settlement( + raw: &sea_orm::DatabaseConnection, + provider: &DBProvider, + s: &Seller, + payment_id: &str, +) -> Uuid { + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + seed_ar_invoice( + provider, + s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let allocation_id = Uuid::now_v7(); + let queued = allocate_svc(provider) + .allocate( + &ctx, + &scope, + allocate_req(s, payment_id, allocation_id, 300), + ) + .await + .expect("unsettled allocate queues"); + assert!( + matches!(queued, AllocationOutcome::Queued(_)), + "queued first" + ); + + // Seed the settlement DIRECTLY (bypassing SettlementService → no drain), so + // both racing appliers find a still-QUEUED, now-due row to compete over. + let tenant = s.tenant; + let payment = payment_id.to_owned(); + provider + .transaction(move |txn| { + let scope = scope.clone(); + let payment = payment.clone(); + Box::pin(async move { + PaymentRepo::seed_settlement(txn, &scope, tenant, &payment, "USD", 1000, 0) + .await + .map_err(lift) + }) + }) + .await + .expect("seed settlement directly"); + + // A real settle CRs the UNALLOCATED pool (the cash the allocation draws). The + // direct counter seed above bypasses that, so seed the post-settle projector + // state for UNALLOCATED — BOTH guarded grains the apply's DR UNALLOCATED + // touches: the aggregate `account_balance` (per account) AND the per-payer + // `unallocated_balance` pool. Without `account_balance` the projector's + // guarded no-negative pre-check on the aggregate drives it to -amount and the + // allocation is (correctly) blocked. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_account_balance \ + (tenant_id, account_id, currency, account_class, normal_side, balance_minor) \ + VALUES ('{}','{}','USD','UNALLOCATED','CR',1000)", + s.tenant, s.unallocated + ))) + .await + .expect("seed unallocated account_balance"); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_unallocated_balance \ + (tenant_id, payer_tenant_id, account_id, currency, balance_minor) \ + VALUES ('{}','{}','{}','USD',1000)", + s.tenant, s.payer, s.unallocated + ))) + .await + .expect("seed unallocated pool"); + allocation_id +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// Exactly-once under concurrent drains: with a single due queued allocation and +/// its settlement seeded directly (so neither drain triggered it), two +/// `AllocationService::drain`s race on the SAME tenant. The `FOR UPDATE SKIP +/// LOCKED` claim hands the row to at most one applier; if the other still +/// observes it (or replays a finalized winner via the `QueuedApply` POSTED +/// short-circuit), it lands NO second effect. INVARIANT: exactly ONE +/// `payment_allocation` row, `allocated_minor == 300` (never 600), queue +/// `APPLIED`, dedup `POSTED`, AR drained to 0. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires Docker (testcontainers)"] +async fn two_drains_apply_a_queued_allocation_exactly_once() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + + let allocation_id = queue_with_seeded_settlement(&raw, &provider, &s, "PAY-RACE").await; + // Precondition: the directly-seeded settlement did not apply it. + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("QUEUED"), + "the seeded settlement left the queue row QUEUED (no drain ran)" + ); + + // Two concurrent drains of the SAME tenant, each retrying serialization + // conflicts. SKIP LOCKED + the POSTED-replay guarantee apply-exactly-once. + let p_a = provider.clone(); + let p_b = provider.clone(); + let tenant = s.tenant; + let drain_a = tokio::spawn(async move { + let svc = allocate_svc(&p_a); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(tenant); + retry_on_serialization(|| svc.drain(&ctx, &scope, tenant, 16)).await + }); + let drain_b = tokio::spawn(async move { + let svc = allocate_svc(&p_b); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(tenant); + retry_on_serialization(|| svc.drain(&ctx, &scope, tenant, 16)).await + }); + + let (ra, rb) = tokio::join!(drain_a, drain_b); + let report_a = ra + .expect("drain A task must not panic / deadlock") + .expect("drain A ok"); + let report_b = rb + .expect("drain B task must not panic / deadlock") + .expect("drain B ok"); + + // The row was applied EXACTLY ONCE across the two drains (the other saw an + // empty claim or replayed the POSTED winner — never a second apply). + assert_eq!( + report_a.applied + report_b.applied, + 1, + "the queued allocation is applied exactly once across the racing drains: {report_a:?} / {report_b:?}" + ); + + // INVARIANT: one allocation's worth of effect, never doubled. + assert_eq!( + count_allocations(&raw, &s, "PAY-RACE").await, + 1, + "exactly one payment_allocation row (the race applied it once)" + ); + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&AccessScope::for_tenant(s.tenant), s.tenant, "PAY-RACE") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.allocated_minor, 300, + "allocated_minor reflects exactly one apply (300), not a doubled 600" + ); + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("APPLIED") + ); + let (status, entry_id) = dedup_status(&raw, &s, allocation_id) + .await + .expect("dedup row present after apply"); + assert_eq!(status, "POSTED"); + assert!( + entry_id.is_some(), + "the applied dedup carries the result entry id" + ); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(0)); +} + +/// A `settle` (whose drain-on-settle hook applies the queue) racing a +/// `QueueApplierJob::run()` sweep on the SAME tenant: both complete without +/// deadlock and the queued allocation applies EXACTLY ONCE. Here the settlement +/// arrives via the real `SettlementService` (so the settle's own drain competes +/// with the concurrent sweep over the same SKIP-LOCKED claim). Asserts both +/// futures return (no deadlock) and the allocation is applied once (one +/// `payment_allocation` row, `allocated_minor == 300`, queue `APPLIED`). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires Docker (testcontainers)"] +async fn drain_and_sweep_race_without_deadlock() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Seed AR (inv 300) and queue an allocate of 300 on PAY-RS while unsettled. + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + let allocation_id = Uuid::now_v7(); + let queued = allocate_svc(&provider) + .allocate(&ctx, &scope, allocate_req(&s, "PAY-RS", allocation_id, 300)) + .await + .expect("unsettled allocate queues"); + assert!(matches!(queued, AllocationOutcome::Queued(_))); + + // Race the settle (which drains after committing) against a sweep pass. Both + // contend for the same SKIP-LOCKED claim; one applies the row, the other sees + // an empty claim or replays the POSTED winner — neither deadlocks. + let settle_provider = provider.clone(); + let settle_scope = scope.clone(); + let s_settle = settlement_input(&s, "PAY-RS", 1000, 0); + let settle = tokio::spawn(async move { + let ctx = SecurityContext::anonymous(); + settle_svc(&settle_provider) + .settle(&ctx, &settle_scope, s_settle) + .await + }); + let sweep_provider = provider.clone(); + let sweep = tokio::spawn(async move { + QueueApplierJob::new( + sweep_provider, + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .run() + .await + }); + + let (settle_res, sweep_res) = tokio::join!(settle, sweep); + settle_res + .expect("settle task must not panic / deadlock") + .expect("settle must succeed"); + sweep_res + .expect("sweep task must not panic / deadlock") + .expect("sweep must run"); + + // Applied EXACTLY ONCE regardless of which side won the claim. + assert_eq!( + count_allocations(&raw, &s, "PAY-RS").await, + 1, + "the queued allocation is applied exactly once across the settle/sweep race" + ); + let repo = PaymentRepo::new(provider.clone()); + let row = repo + .read_settlement(&scope, s.tenant, "PAY-RS") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + row.allocated_minor, 300, + "allocated_minor is 300, not doubled" + ); + assert_eq!( + queue_status(&raw, &s, allocation_id).await.as_deref(), + Some("APPLIED") + ); + assert_eq!(ar_invoice_balance(&raw, &s, "INV-A").await, Some(0)); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_read_surface.rs b/gears/bss/ledger/ledger/tests/postgres_read_surface.rs new file mode 100644 index 000000000..a302ad039 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_read_surface.rs @@ -0,0 +1,809 @@ +//! Postgres-only integration: the Slice-3 / Slice-4 **read-surface** repo list + +//! by-id methods, exercised across TWO tenants to lock the critical SQL-level +//! property — a list/by-id under tenant A's `AccessScope` NEVER returns tenant B's +//! rows (BOLA), the user `$filter` is **additive over** that scope (never replaces +//! it), and the cursor machinery bounds + advances a page. +//! +//! The methods under test (all `(&self, scope, tenant, &ODataQuery) -> Page<…>` for +//! lists, `(&self, scope, tenant, id) -> Option<…>` for by-id): +//! - `AdjustmentRepo`: `list_refunds` / `read_refund_out_of_txn`, +//! `list_credit_notes` / `read_credit_note_out_of_txn`, `list_debit_notes` / +//! `read_debit_note_out_of_txn`; +//! - `DisputeRepo`: `list_disputes` / `read_dispute`; +//! - `RecognitionRepo`: `list_runs` / `read_run_out_of_txn`; +//! - `JournalRepo`: `list_entries`. +//! +//! Rows are seeded by RAW SQL `INSERT` directly into `bss.ledger_*` (the simplest +//! reliable way to populate a read test — mirrors `postgres_refund_dispute_hold.rs` +//! / `postgres_credit_note.rs`), for two tenants A and B. The shared harness +//! (the `pg()` helper, `Postgres::default().start()`, +//! `Migrator::up`, the `search_path=bss,public` provider) is duplicated from +//! `postgres_refund.rs` per the established convention (no shared module across +//! these test files). Ignored by default; run with `-- --ignored` (needs Docker). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::similar_names, + clippy::too_many_arguments +)] + +use bss_ledger::domain::approval::policy::effective_version; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{ + AdjustmentRepo, ApprovalRepo, DisputeRepo, JournalRepo, RecognitionRepo, +}; +use bss_ledger_sdk::ODataQuery; +use chrono::{DateTime, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement, TransactionTrait}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Build an `ODataQuery` carrying just a parsed `$filter` (the way the REST OData +/// extractor would); an empty query is `ODataQuery::default()`. Mirrors +/// `postgres_journal.rs::odata_filter`. +fn odata_filter(expr: &str) -> ODataQuery { + let parsed = toolkit_odata::parse_filter_string(expr) + .expect("test $filter must parse") + .into_expr(); + ODataQuery::default().with_filter(parsed) +} + +/// Boot Postgres, migrate, and return the migrate connection (for raw seed +/// INSERTs) + a `search_path=bss,public` provider (the gear's prod search path, so +/// the secured entity queries resolve into the `bss` schema). Mirrors +/// `postgres_refund.rs::setup` minus the chart (a read test needs no chart of +/// accounts — it seeds the record rows directly). +async fn boot(url: &str) -> (DatabaseConnection, DBProvider) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (raw, provider) +} + +// ───────────────────────────── raw seed INSERTs ───────────────────────────── +// +// One helper per record table; the column lists + NOT-NULL / CHECK sets are taken +// from the `create_*` migrations (a wrong column name or an out-of-set enum value +// fails at runtime): +// - refund: phase∈{initiated,confirmed,rejected,voided,unknown_final}, +// pattern∈{A_UNALLOCATED,B_RESTORE_AR}, +// clearing_state∈{PENDING,SETTLED,REVERSED}, amount>=0; +// - credit_note: amount/recognized/deferred >= 0; +// - debit_note: amount/recognized/deferred >= 0; +// - dispute: variant∈{CASH_HOLD,AR_RECLASS}, +// last_phase∈{OPENED,WON,LOST,PARTIAL}, cycle>=1, +// cash_hold_minor <= disputed_amount_minor; +// - recognition_run: status∈{RUNNING,DONE,FAILED}; +// - journal_entry: origin∈{SYSTEM,USER} + a balanced 2-line body (the deferred +// balance trigger rejects a zero-line / unbalanced header at +// COMMIT — so a header is seeded with two balancing lines). + +/// Seed a `ledger_refund` row (surrogate PK `(tenant, refund_id)`; natural UNIQUE +/// `(tenant, psp_refund_id, phase)`). Pattern A (`invoice_id` NULL) by default. +async fn seed_refund( + raw: &DatabaseConnection, + tenant: Uuid, + refund_id: &str, + psp_refund_id: &str, + payment_id: &str, + amount: i64, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_refund \ + (tenant_id, refund_id, psp_refund_id, phase, pattern, payment_id, currency, \ + amount_minor, clearing_state, created_at_utc) \ + VALUES ('{tenant}','{refund_id}','{psp_refund_id}','initiated','A_UNALLOCATED', \ + '{payment_id}','USD',{amount},'PENDING', now())" + ))) + .await + .unwrap(); +} + +/// Seed a `ledger_credit_note` row (`(tenant, credit_note_id)` PK). +async fn seed_credit_note( + raw: &DatabaseConnection, + tenant: Uuid, + credit_note_id: &str, + origin_invoice_id: &str, + amount: i64, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_credit_note \ + (tenant_id, credit_note_id, origin_invoice_id, revenue_stream, currency, \ + amount_minor, reason_code, created_at_utc) \ + VALUES ('{tenant}','{credit_note_id}','{origin_invoice_id}','subscription','USD', \ + {amount},'CUSTOMER_GOODWILL', now())" + ))) + .await + .unwrap(); +} + +/// Seed a `ledger_debit_note` row (`(tenant, debit_note_id)` PK). +async fn seed_debit_note( + raw: &DatabaseConnection, + tenant: Uuid, + debit_note_id: &str, + origin_invoice_id: &str, + amount: i64, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_debit_note \ + (tenant_id, debit_note_id, origin_invoice_id, currency, amount_minor, created_at_utc) \ + VALUES ('{tenant}','{debit_note_id}','{origin_invoice_id}','USD',{amount}, now())" + ))) + .await + .unwrap(); +} + +/// Seed an OPEN `ledger_dispute` row (`(tenant, dispute_id)` PK). +/// `cash_hold_minor <= disputed_amount_minor` satisfies the table CHECK. Mirrors +/// `postgres_refund_dispute_hold.rs::open_dispute`. +async fn seed_dispute( + raw: &DatabaseConnection, + tenant: Uuid, + dispute_id: &str, + payment_id: &str, + disputed: i64, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_dispute \ + (tenant_id, dispute_id, payment_id, currency, variant, last_phase, cycle, \ + disputed_amount_minor, cash_hold_minor, version) \ + VALUES ('{tenant}','{dispute_id}','{payment_id}','USD','CASH_HOLD','OPENED',1, \ + {disputed},{disputed},0)" + ))) + .await + .unwrap(); +} + +/// Seed a `ledger_recognition_run` row (3-col PK `(tenant, period_id, run_id)`). +async fn seed_run(raw: &DatabaseConnection, tenant: Uuid, run_id: Uuid, period_id: &str) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_recognition_run \ + (tenant_id, run_id, period_id, started_at_utc, status) \ + VALUES ('{tenant}','{run_id}','{period_id}', now(),'DONE')" + ))) + .await + .unwrap(); +} + +/// Seed a `ledger_dual_control_policy` version (`(tenant, version)` PK). `d2`/`a6`/ +/// `ttl` must satisfy the migration range CHECKs (`d2 ∈ [10000, 100000000]`, +/// `a6 ∈ [1, 30]`, `ttl > 0`). `effective_from` is an RFC-3339 instant. +async fn seed_policy( + raw: &DatabaseConnection, + tenant: Uuid, + version: i64, + effective_from: &str, + d2: i64, + a6: i32, + ttl: i64, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_dual_control_policy \ + (tenant_id, version, effective_from, d2_threshold_minor, \ + a6_backdating_biz_days, pending_ttl_seconds, created_at_utc) \ + VALUES ('{tenant}',{version},'{effective_from}',{d2},{a6},{ttl}, now())" + ))) + .await + .unwrap(); +} + +/// Seed one balanced `ledger_journal_entry` (header + a DR/CR pair of `journal_line` +/// rows) inside a transaction — the deferred balance trigger rejects a zero-line / +/// unbalanced header at COMMIT, so the body is two balancing USD lines. Mirrors +/// `postgres_journal.rs::insert_entry` + `insert_line`. Returns the `entry_id`. +async fn seed_journal_entry( + raw: &DatabaseConnection, + tenant: Uuid, + period_id: &str, + source_business_id: &str, +) -> Uuid { + let entry_id = Uuid::now_v7(); + let txn = raw.begin().await.unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, \ + source_doc_type, source_business_id, posted_at_utc, effective_at, \ + origin, posted_by_actor_id, correlation_id) \ + VALUES ('{entry_id}','{tenant}','{tenant}','{period_id}','USD', \ + 'MANUAL_ADJUSTMENT','{source_business_id}', now(), CURRENT_DATE, \ + 'SYSTEM','{tenant}','{tenant}')" + ))) + .await + .unwrap(); + for (side, amount) in [("DR", 1000_i64), ("CR", 1000_i64)] { + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, \ + account_class, side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{}','{entry_id}','{tenant}','{period_id}','{tenant}','{tenant}', \ + 'AR','{side}',{amount},'USD',2,'RESOLVED')", + Uuid::now_v7() + ))) + .await + .unwrap(); + } + txn.commit().await.expect("balanced entry must commit"); + entry_id +} + +// ───────────────────────────── refund: list + by-id ───────────────────────── + +/// `list_refunds` under A's scope+tenant returns ONLY A's rows (count + ids), +/// never B's — the BOLA property. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_list_is_tenant_scoped() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + + // A: 3 refunds; B: 2 refunds (a populated outsider — so an A-scoped empty of B + // means "scoped out", not "the store is empty"). + seed_refund(&raw, a, "A-RF-1", "A-PSP-1", "A-PAY-1", 100).await; + seed_refund(&raw, a, "A-RF-2", "A-PSP-2", "A-PAY-2", 200).await; + seed_refund(&raw, a, "A-RF-3", "A-PSP-3", "A-PAY-3", 300).await; + seed_refund(&raw, b, "B-RF-1", "B-PSP-1", "B-PAY-1", 400).await; + seed_refund(&raw, b, "B-RF-2", "B-PSP-2", "B-PAY-2", 500).await; + + let repo = AdjustmentRepo::new(provider.clone()); + let page = repo + .list_refunds(&AccessScope::for_tenant(a), a, &ODataQuery::default()) + .await + .expect("list A refunds"); + assert_eq!(page.items.len(), 3, "A sees exactly its own 3 refunds"); + let mut ids: Vec<&str> = page.items.iter().map(|m| m.refund_id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, ["A-RF-1", "A-RF-2", "A-RF-3"]); + assert!( + page.items.iter().all(|m| m.tenant_id == a), + "no B-owned refund leaks into A's list" + ); + + // BOLA: A's scope asking for B's tenant yields ZERO (the scope predicate + // overrides the caller-supplied `tenant = B`, mirroring postgres_bola.rs). + let cross = repo + .list_refunds(&AccessScope::for_tenant(a), b, &ODataQuery::default()) + .await + .expect("A-scope, B-tenant list"); + assert!( + cross.items.is_empty(), + "A's scope must NOT list B's refunds (SQL-level BOLA); got {}", + cross.items.len() + ); + // Sanity: B's own scope sees B's 2 rows. + let b_page = repo + .list_refunds(&AccessScope::for_tenant(b), b, &ODataQuery::default()) + .await + .expect("list B refunds"); + assert_eq!(b_page.items.len(), 2, "B sees its own 2 refunds"); +} + +/// `read_refund_out_of_txn` with A's scope+tenant but B's id → `None` (no existence +/// leak); with A's own id → `Some`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_by_id_foreign_tenant_is_none() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + seed_refund(&raw, a, "A-RF-1", "A-PSP-1", "A-PAY-1", 100).await; + seed_refund(&raw, b, "B-RF-1", "B-PSP-1", "B-PAY-1", 400).await; + + let repo = AdjustmentRepo::new(provider.clone()); + let scope_a = AccessScope::for_tenant(a); + + // A reads its own id → Some. + assert!( + repo.read_refund_out_of_txn(&scope_a, a, "A-RF-1") + .await + .expect("read own") + .is_some(), + "A resolves its own refund by id" + ); + // A reads B's id (under A's tenant) → None: no existence leak. + assert!( + repo.read_refund_out_of_txn(&scope_a, a, "B-RF-1") + .await + .expect("read foreign-id query ok") + .is_none(), + "A must not resolve B's refund id (no existence leak)" + ); + // Even handing A's scope B's tenant + B's id → None (scope overrides). + assert!( + repo.read_refund_out_of_txn(&scope_a, b, "B-RF-1") + .await + .expect("read cross query ok") + .is_none(), + "A's scope must not resolve a B-owned refund even with B's tenant" + ); +} + +/// Cursor: seed N > page rows for A, list with a small `limit`; the page is bounded +/// to `limit`, `page_info` carries a `next_cursor`, and following it returns the +/// remainder with NO overlap. (Refunds only — the cursor machinery is shared by +/// every `paginate_odata` list, so one proof suffices.) +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_list_cursor_paginates() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let a = Uuid::now_v7(); + + // 5 refunds, ids zero-padded so the default `refund_id ASC` keyset is a stable + // lexical order (the cursor walks this keyset). + for i in 1..=5 { + let id = format!("A-RF-{i:02}"); + seed_refund( + &raw, + a, + &id, + &format!("A-PSP-{i:02}"), + &format!("A-PAY-{i:02}"), + 100 * i, + ) + .await; + } + + let repo = AdjustmentRepo::new(provider.clone()); + let scope_a = AccessScope::for_tenant(a); + + // Page 1: limit 2 ⇒ bounded to 2, a next cursor is present. + let page1 = repo + .list_refunds(&scope_a, a, &ODataQuery::default().with_limit(2)) + .await + .expect("page 1"); + assert_eq!(page1.items.len(), 2, "the page is bounded to the limit"); + assert_eq!( + page1.page_info.limit, 2, + "page_info echoes the effective limit" + ); + let cursor = page1 + .page_info + .next_cursor + .clone() + .expect("a bounded first page carries a next cursor"); + let page1_ids: Vec = page1.items.iter().map(|m| m.refund_id.clone()).collect(); + assert_eq!( + page1_ids, + ["A-RF-01", "A-RF-02"], + "page 1 is the first keyset slice" + ); + + // Page 2: follow the cursor (parsed back into the query, the way the REST layer + // re-hydrates a `?cursor=` param) — the next slice, no overlap with page 1. + let cursor_v1 = toolkit_odata::CursorV1::decode(&cursor).expect("cursor decodes"); + let page2 = repo + .list_refunds( + &scope_a, + a, + &ODataQuery::default().with_limit(2).with_cursor(cursor_v1), + ) + .await + .expect("page 2"); + assert_eq!(page2.items.len(), 2, "page 2 is also bounded to the limit"); + let page2_ids: Vec = page2.items.iter().map(|m| m.refund_id.clone()).collect(); + assert_eq!( + page2_ids, + ["A-RF-03", "A-RF-04"], + "page 2 is the next keyset slice" + ); + assert!( + page1_ids.iter().all(|id| !page2_ids.contains(id)), + "the cursor advanced — no row repeats across pages" + ); +} + +/// `$filter`: a `payment_id eq …` filter narrows the list to the matching rows, +/// still ANDed under the tenant scope (a foreign-tenant row with the same +/// `payment_id` would not leak — but here all rows are A's, so this asserts the +/// filter selectivity). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_list_filter_narrows() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + + // Two A refunds share PAY-X; one A refund is on PAY-Y; B has a refund ALSO on + // PAY-X (the cross-tenant collision the scope must still exclude). + seed_refund(&raw, a, "A-RF-1", "A-PSP-1", "PAY-X", 100).await; + seed_refund(&raw, a, "A-RF-2", "A-PSP-2", "PAY-X", 200).await; + seed_refund(&raw, a, "A-RF-3", "A-PSP-3", "PAY-Y", 300).await; + seed_refund(&raw, b, "B-RF-1", "B-PSP-1", "PAY-X", 400).await; + + let repo = AdjustmentRepo::new(provider.clone()); + let page = repo + .list_refunds( + &AccessScope::for_tenant(a), + a, + &odata_filter("payment_id eq 'PAY-X'"), + ) + .await + .expect("filter by payment_id"); + assert_eq!( + page.items.len(), + 2, + "exactly A's two PAY-X refunds (B's PAY-X row excluded by the scope)" + ); + let mut ids: Vec<&str> = page.items.iter().map(|m| m.refund_id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, ["A-RF-1", "A-RF-2"]); + assert!( + page.items + .iter() + .all(|m| m.payment_id == "PAY-X" && m.tenant_id == a), + "every matched row is A's and on PAY-X" + ); +} + +// ─────────────────────────── credit note: list + by-id ────────────────────── + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn credit_note_list_is_tenant_scoped() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + seed_credit_note(&raw, a, "A-CN-1", "A-INV-1", 100).await; + seed_credit_note(&raw, a, "A-CN-2", "A-INV-2", 200).await; + seed_credit_note(&raw, b, "B-CN-1", "B-INV-1", 300).await; + + let repo = AdjustmentRepo::new(provider.clone()); + let page = repo + .list_credit_notes(&AccessScope::for_tenant(a), a, &ODataQuery::default()) + .await + .expect("list A credit notes"); + assert_eq!(page.items.len(), 2, "A sees exactly its own 2 credit notes"); + assert!(page.items.iter().all(|m| m.tenant_id == a)); + let cross = repo + .list_credit_notes(&AccessScope::for_tenant(a), b, &ODataQuery::default()) + .await + .expect("A-scope, B-tenant"); + assert!(cross.items.is_empty(), "A must not list B's credit notes"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn credit_note_by_id_foreign_tenant_is_none() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + seed_credit_note(&raw, a, "A-CN-1", "A-INV-1", 100).await; + seed_credit_note(&raw, b, "B-CN-1", "B-INV-1", 300).await; + + let repo = AdjustmentRepo::new(provider.clone()); + let scope_a = AccessScope::for_tenant(a); + assert!( + repo.read_credit_note_out_of_txn(&scope_a, a, "A-CN-1") + .await + .expect("read own") + .is_some(), + "A resolves its own credit note" + ); + assert!( + repo.read_credit_note_out_of_txn(&scope_a, a, "B-CN-1") + .await + .expect("query ok") + .is_none(), + "A must not resolve B's credit note id" + ); +} + +// ─────────────────────────── debit note: list + by-id ─────────────────────── + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn debit_note_list_is_tenant_scoped() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + seed_debit_note(&raw, a, "A-DN-1", "A-INV-1", 100).await; + seed_debit_note(&raw, a, "A-DN-2", "A-INV-2", 200).await; + seed_debit_note(&raw, b, "B-DN-1", "B-INV-1", 300).await; + + let repo = AdjustmentRepo::new(provider.clone()); + let page = repo + .list_debit_notes(&AccessScope::for_tenant(a), a, &ODataQuery::default()) + .await + .expect("list A debit notes"); + assert_eq!(page.items.len(), 2, "A sees exactly its own 2 debit notes"); + assert!(page.items.iter().all(|m| m.tenant_id == a)); + let cross = repo + .list_debit_notes(&AccessScope::for_tenant(a), b, &ODataQuery::default()) + .await + .expect("A-scope, B-tenant"); + assert!(cross.items.is_empty(), "A must not list B's debit notes"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn debit_note_by_id_foreign_tenant_is_none() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + seed_debit_note(&raw, a, "A-DN-1", "A-INV-1", 100).await; + seed_debit_note(&raw, b, "B-DN-1", "B-INV-1", 300).await; + + let repo = AdjustmentRepo::new(provider.clone()); + let scope_a = AccessScope::for_tenant(a); + assert!( + repo.read_debit_note_out_of_txn(&scope_a, a, "A-DN-1") + .await + .expect("read own") + .is_some(), + "A resolves its own debit note" + ); + assert!( + repo.read_debit_note_out_of_txn(&scope_a, a, "B-DN-1") + .await + .expect("query ok") + .is_none(), + "A must not resolve B's debit note id" + ); +} + +// ───────────────────────────── dispute: list + by-id ──────────────────────── + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn dispute_list_is_tenant_scoped() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + seed_dispute(&raw, a, "A-DISP-1", "A-PAY-1", 1000).await; + seed_dispute(&raw, a, "A-DISP-2", "A-PAY-2", 2000).await; + seed_dispute(&raw, b, "B-DISP-1", "B-PAY-1", 3000).await; + + let repo = DisputeRepo::new(provider.clone()); + let page = repo + .list_disputes(&AccessScope::for_tenant(a), a, &ODataQuery::default()) + .await + .expect("list A disputes"); + assert_eq!(page.items.len(), 2, "A sees exactly its own 2 disputes"); + assert!(page.items.iter().all(|m| m.tenant_id == a)); + let cross = repo + .list_disputes(&AccessScope::for_tenant(a), b, &ODataQuery::default()) + .await + .expect("A-scope, B-tenant"); + assert!(cross.items.is_empty(), "A must not list B's disputes"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn dispute_by_id_foreign_tenant_is_none() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + seed_dispute(&raw, a, "A-DISP-1", "A-PAY-1", 1000).await; + seed_dispute(&raw, b, "B-DISP-1", "B-PAY-1", 3000).await; + + let repo = DisputeRepo::new(provider.clone()); + let scope_a = AccessScope::for_tenant(a); + assert!( + repo.read_dispute(&scope_a, a, "A-DISP-1") + .await + .expect("read own") + .is_some(), + "A resolves its own dispute" + ); + assert!( + repo.read_dispute(&scope_a, a, "B-DISP-1") + .await + .expect("query ok") + .is_none(), + "A must not resolve B's dispute id" + ); +} + +// ─────────────────────── recognition run: list + by-id ────────────────────── + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn recognition_run_list_is_tenant_scoped() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + let (a_run_1, a_run_2, b_run) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + seed_run(&raw, a, a_run_1, "202606").await; + seed_run(&raw, a, a_run_2, "202607").await; + seed_run(&raw, b, b_run, "202606").await; + + let repo = RecognitionRepo::new(provider.clone()); + let page = repo + .list_runs(&AccessScope::for_tenant(a), a, &ODataQuery::default()) + .await + .expect("list A runs"); + assert_eq!(page.items.len(), 2, "A sees exactly its own 2 runs"); + assert!(page.items.iter().all(|m| m.tenant_id == a)); + let cross = repo + .list_runs(&AccessScope::for_tenant(a), b, &ODataQuery::default()) + .await + .expect("A-scope, B-tenant"); + assert!(cross.items.is_empty(), "A must not list B's runs"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn recognition_run_by_id_foreign_tenant_is_none() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + let (a_run, b_run) = (Uuid::now_v7(), Uuid::now_v7()); + seed_run(&raw, a, a_run, "202606").await; + seed_run(&raw, b, b_run, "202606").await; + + let repo = RecognitionRepo::new(provider.clone()); + let scope_a = AccessScope::for_tenant(a); + assert!( + repo.read_run_out_of_txn(&scope_a, a, a_run) + .await + .expect("read own") + .is_some(), + "A resolves its own run by run_id" + ); + assert!( + repo.read_run_out_of_txn(&scope_a, a, b_run) + .await + .expect("query ok") + .is_none(), + "A must not resolve B's run id (no existence leak)" + ); +} + +// ─────────────────────────── journal entries: list ────────────────────────── + +/// `list_entries` (the entry-HEADER list, R5) under A's scope returns ONLY A's +/// entries, never B's. (No by-id variant in scope here — `JournalRepo` exposes +/// `find_entry_with_lines`, covered by `postgres_journal.rs`.) +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn journal_entries_list_is_tenant_scoped() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + let a1 = seed_journal_entry(&raw, a, "202606", "A-BIZ-1").await; + let a2 = seed_journal_entry(&raw, a, "202606", "A-BIZ-2").await; + let _b1 = seed_journal_entry(&raw, b, "202606", "B-BIZ-1").await; + + let repo = JournalRepo::new(provider.clone()); + let page = repo + .list_entries(&AccessScope::for_tenant(a), a, &ODataQuery::default()) + .await + .expect("list A entries"); + assert_eq!(page.items.len(), 2, "A sees exactly its own 2 entries"); + let mut ids: Vec = page.items.iter().map(|m| m.entry_id).collect(); + ids.sort_unstable(); + let mut want = [a1, a2]; + want.sort_unstable(); + assert_eq!(ids, want, "the two entries are A's"); + assert!(page.items.iter().all(|m| m.tenant_id == a)); + + // BOLA: A's scope, B's tenant → empty (even though B genuinely has an entry). + let cross = repo + .list_entries(&AccessScope::for_tenant(a), b, &ODataQuery::default()) + .await + .expect("A-scope, B-tenant"); + assert!( + cross.items.is_empty(), + "A must not list B's journal entries" + ); + // Sanity: B's own scope sees its one entry. + let b_page = repo + .list_entries(&AccessScope::for_tenant(b), b, &ODataQuery::default()) + .await + .expect("list B entries"); + assert_eq!(b_page.items.len(), 1, "B sees its own entry"); +} + +// ──────────────────── dual-control policy: effective read + BOLA ───────────── + +/// `read_policy_versions` is tenant-scoped (SQL-level BOLA) and `effective_version` +/// resolves the version in force (R6): A's two versions resolve to the latest +/// `effective_from <= now`; A's scope reading B's tenant returns no rows. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn dual_control_policy_effective_read_resolves_and_is_scoped() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = boot(&url).await; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + // A: v2 (06-20) supersedes v1 (06-01); B: one version (must stay invisible to A). + seed_policy(&raw, a, 1, "2026-06-01T00:00:00Z", 50_000, 5, 604_800).await; + seed_policy(&raw, a, 2, "2026-06-20T00:00:00Z", 200_000, 7, 3_600).await; + seed_policy(&raw, b, 1, "2026-06-01T00:00:00Z", 80_000, 5, 604_800).await; + + let repo = ApprovalRepo::new(provider.clone()); + let now: DateTime = "2026-06-25T00:00:00Z".parse().expect("ts"); + + let versions = repo + .read_policy_versions(&AccessScope::for_tenant(a), a) + .await + .expect("read A policy versions"); + assert_eq!(versions.len(), 2, "A sees exactly its own 2 versions"); + let effective = effective_version(&versions, now).expect("a version is in force"); + assert_eq!(effective.version, 2, "the latest effective_from wins"); + assert_eq!(effective.policy.d2_threshold_minor, 200_000); + assert_eq!(effective.policy.a6_backdating_biz_days, 7); + assert_eq!(effective.policy.pending_ttl_seconds, 3_600); + + // BOLA: A's scope reading B's tenant resolves to no rows (no value/existence leak). + let cross = repo + .read_policy_versions(&AccessScope::for_tenant(a), b) + .await + .expect("A-scope, B-tenant"); + assert!(cross.is_empty(), "A must not read B's policy versions"); + assert!( + effective_version(&cross, now).is_none(), + "no row ⇒ no effective version ⇒ handler renders the platform defaults" + ); +} + +/// A tenant with no policy row reads as no versions ⇒ `effective_version` is `None` +/// (the handler then renders the ratified platform defaults, `is_default = true`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn dual_control_policy_absent_row_yields_no_effective_version() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, provider) = boot(&url).await; + let a = Uuid::now_v7(); + + let repo = ApprovalRepo::new(provider.clone()); + let now: DateTime = "2026-06-25T00:00:00Z".parse().expect("ts"); + let versions = repo + .read_policy_versions(&AccessScope::for_tenant(a), a) + .await + .expect("read policy versions"); + assert!(versions.is_empty(), "no rows seeded for this tenant"); + assert!( + effective_version(&versions, now).is_none(), + "absent policy ⇒ None ⇒ platform defaults" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_recognition_build.rs b/gears/bss/ledger/ledger/tests/postgres_recognition_build.rs new file mode 100644 index 000000000..c30f79013 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_recognition_build.rs @@ -0,0 +1,726 @@ +//! Postgres-only integration: the Slice 4 in-transaction recognition-schedule +//! materialization (Group C), driven through the REAL foundation engine +//! (`InvoicePostService` over `PostingService` + the `ScheduleBuilderSidecar`). +//! +//! Provisions a seller (AR / Revenue(subscription) / Contract-liability +//! (subscription) / Tax / Suspense chart + USD@2 + an OPEN period), then asserts: +//! - a **deferred** invoice materializes a `recognition_schedule` + its +//! `recognition_segment` rows in the SAME txn as the `CR CONTRACT_LIABILITY` +//! credit (balances + schedule both present); +//! - a derivation failure (segment count over the configured ceiling) rolls the +//! WHOLE post back — no entry, no schedule, no balances; +//! - a duplicate build (re-post of the same invoice/item/stream) lands on the +//! existing ACTIVE schedule via the `SCHEDULE_BUILD` claim — no second +//! `schedule_id`, no second segment set; +//! - a `deferred = 0` invoice posts byte-identically — no Contract-liability +//! line, no schedule. +//! +//! `LedgerLocalClient::new` is `pub(crate)`, so this out-of-crate test drives the +//! `pub` `InvoicePostService` directly (mirrors `postgres_invoice_post.rs`). +//! Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::type_complexity +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::recognition::input::{RecognitionInput, RecognitionTiming}; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::NaiveDate; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + scalar_i64(conn, sql).await.unwrap_or(0) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Provisioned seller ids (incl. the per-stream Contract-liability account the +/// deferred split credits). +struct Seller { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + revenue: Uuid, + contract_liability: Uuid, + tax: Uuid, + suspense: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot, migrate, seed USD@2 + an OPEN period + AR / REVENUE(subscription) / +/// CONTRACT_LIABILITY(subscription) / TAX / SUSPENSE accounts. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue: Uuid::now_v7(), + contract_liability: Uuid::now_v7(), + tax: Uuid::now_v7(), + suspense: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.revenue, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contract_liability, + AccountClass::ContractLiability, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + account( + s.tenant, + s.suspense, + AccountClass::Suspense, + Side::Credit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +/// A `subscription` item mapped to REVENUE, carrying a straight-line recognition +/// spec over `periods` and an `invoice_item_ref` (required for a deferred line). +fn recognized_item(amount: i64, periods: u32, item_ref: &str) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods, + first_period_id: None, + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some(item_ref.to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +/// A plain `subscription` item, fully recognized now (no recognition spec). +fn plain_item(amount: i64) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn invoice(s: &Seller, invoice_id: &str, items: Vec) -> PostedInvoice { + PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items, + tax: Vec::::new(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + } +} + +fn svc( + provider: &DBProvider, + metrics: &MetricsHarness, + cfg: RecognitionConfig, +) -> InvoicePostService { + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(metrics.metrics()), + cfg, + FxConfig::default(), + ) +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +async fn schedule_count(raw: &DatabaseConnection, s: &Seller, invoice_id: &str) -> i64 { + count( + raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='{invoice_id}'", + s.tenant + ), + ) + .await +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn deferred_invoice_materializes_schedule_in_one_txn() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness, RecognitionConfig::default()); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // 1200 ex-tax, straight-line over 12 months ⇒ the WHOLE amount defers to + // CONTRACT_LIABILITY (Group B defers the whole ex-tax for a straight-line + // line), recognized-now Revenue = 0. + let inv = invoice(&s, "INV-DEF", vec![recognized_item(1200, 12, "item-1")]); + let posted = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice must post"); + assert!(!posted.replayed); + + // Balances: AR 1200 debit, Contract-liability 1200 credit, Revenue 0. + assert_eq!(bal(&raw, &s, s.ar).await, Some(1200), "AR = gross"); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(1200), + "the whole amount deferred to Contract-liability" + ); + // Revenue line posts 0 ⇒ either no balance row or a 0 balance; both are fine. + assert!( + matches!(bal(&raw, &s, s.revenue).await, None | Some(0)), + "nothing recognized now" + ); + + // Schedule + segments materialized in the same txn. + assert_eq!(schedule_count(&raw, &s, "INV-DEF").await, 1, "one schedule"); + let total_deferred = scalar_i64( + &raw, + &format!( + "SELECT total_deferred_minor FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='INV-DEF'", + s.tenant + ), + ) + .await; + assert_eq!(total_deferred, Some(1200), "schedule total = deferred"); + let segs = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_recognition_segment seg \ + JOIN bss.ledger_recognition_schedule sch \ + ON sch.tenant_id = seg.tenant_id AND sch.schedule_id = seg.schedule_id \ + WHERE sch.tenant_id='{}' AND sch.source_invoice_id='INV-DEF'", + s.tenant + ), + ) + .await; + assert_eq!(segs, 12, "12 straight-line segments"); + let seg_sum = scalar_i64( + &raw, + &format!( + "SELECT SUM(seg.amount_minor)::bigint FROM bss.ledger_recognition_segment seg \ + JOIN bss.ledger_recognition_schedule sch \ + ON sch.tenant_id = seg.tenant_id AND sch.schedule_id = seg.schedule_id \ + WHERE sch.tenant_id='{}' AND sch.source_invoice_id='INV-DEF'", + s.tenant + ), + ) + .await; + assert_eq!(seg_sum, Some(1200), "segments sum to the deferred amount"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn over_ceiling_derivation_rolls_back_the_whole_post() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + // Ceiling of 6 ⇒ a 12-segment schedule is over-bound and blocks. + let cfg = RecognitionConfig { + max_segments_per_schedule: 6, + recognition_run_tick_secs: 300, + }; + let service = svc(&provider, &harness, cfg); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let inv = invoice(&s, "INV-LONG", vec![recognized_item(1200, 12, "item-1")]); + let err = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect_err("a schedule over the ceiling must block the post"); + assert!( + matches!(err, DomainError::ScheduleTooLong(_)), + "got {err:?}" + ); + + // The WHOLE post rolled back: no entry, no balances, no schedule. + assert_eq!( + bal(&raw, &s, s.ar).await, + None, + "no AR balance (rolled back)" + ); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + None, + "no Contract-liability balance (rolled back)" + ); + assert_eq!( + schedule_count(&raw, &s, "INV-LONG").await, + 0, + "no schedule materialized" + ); + let entries = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_business_id='INV-LONG'", + s.tenant + ), + ) + .await; + assert_eq!(entries, 0, "no journal entry posted"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn duplicate_build_lands_on_existing_active_schedule() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness, RecognitionConfig::default()); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let inv = invoice(&s, "INV-DUP", vec![recognized_item(1200, 12, "item-1")]); + + let first = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("first deferred post must succeed"); + assert!(!first.replayed); + assert_eq!(schedule_count(&raw, &s, "INV-DUP").await, 1, "one schedule"); + let first_schedule_id: Option = raw + .query_one(pg(format!( + "SELECT schedule_id FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='INV-DUP'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()); + + // Re-post the SAME invoice ⇒ the journal post replays (INVOICE_POST dedup) + // AND the SCHEDULE_BUILD claim short-circuits the sidecar — no second + // schedule, no second segment set. + let replay = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("the re-post must replay"); + assert!(replay.replayed, "the invoice post replays"); + assert_eq!( + replay.entry_id, first.entry_id, + "the replay returns the prior entry id" + ); + assert_eq!( + schedule_count(&raw, &s, "INV-DUP").await, + 1, + "still exactly ONE schedule (no second schedule_id)" + ); + let after_schedule_id: Option = raw + .query_one(pg(format!( + "SELECT schedule_id FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='INV-DUP'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()); + assert_eq!( + first_schedule_id, after_schedule_id, + "the same ACTIVE schedule_id survives the duplicate build" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn non_deferred_invoice_posts_with_no_schedule_or_cl_line() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness, RecognitionConfig::default()); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // No recognition spec ⇒ fully recognized now (byte-identical to today): AR + + // Revenue only, NO Contract-liability line, NO schedule. + let inv = invoice(&s, "INV-PLAIN", vec![plain_item(1000)]); + let posted = service + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("a non-deferred invoice must post"); + assert!(!posted.replayed); + + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(1000), + "AR = the full amount" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(1000), + "the whole amount recognizes now" + ); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + None, + "no Contract-liability balance (no deferral)" + ); + assert_eq!( + schedule_count(&raw, &s, "INV-PLAIN").await, + 0, + "no schedule materialized for a non-deferred invoice" + ); + // The posted entry carries NO Contract-liability line. + let cl_lines = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_line \ + WHERE tenant_id='{}' AND account_class='CONTRACT_LIABILITY' \ + AND invoice_id='INV-PLAIN'", + s.tenant + ), + ) + .await; + assert_eq!(cl_lines, 0, "no CR Contract-liability line emitted"); +} + +/// `RecognitionRepo::list_schedules` (the discovery surface backing +/// `GET /recognition-schedules` + the invoice-post `schedule_id` echo): the +/// optional `invoice_id` / `revenue_stream` filters narrow the result, and the +/// `AccessScope` binds it to the tenant (SQL-level BOLA — a foreign scope reads +/// nothing). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn list_schedules_filters_by_invoice_and_stream_and_is_tenant_scoped() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let service = svc(&provider, &harness, RecognitionConfig::default()); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Seed a SECOND revenue stream ("support") so the filters must DISCRIMINATE + // between two schedules — not merely return the only row present. + let reference = ReferenceRepo::new(provider.clone()); + for row in [ + account( + s.tenant, + Uuid::now_v7(), + AccountClass::Revenue, + Side::Credit, + Some("support"), + ), + account( + s.tenant, + Uuid::now_v7(), + AccountClass::ContractLiability, + Side::Credit, + Some("support"), + ), + ] { + reference.insert_account(row).await.unwrap(); + } + + // Schedule A: INV-DEF / subscription / item-1 (1200 ex-tax, fully deferred). + let inv_a = invoice(&s, "INV-DEF", vec![recognized_item(1200, 12, "item-1")]); + service + .post_invoice(&ctx, &scope, &inv_a, true) + .await + .expect("post A (subscription) must succeed"); + // Schedule B: INV-OTHER / support / item-2 (800 ex-tax, fully deferred). + let support_item = InvoiceItem { + amount_minor_ex_tax: 800, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "support".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4001".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods: 12, + first_period_id: None, + }, + po_allocation_group: Some("grp-2".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-2".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some("item-2".to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }; + let inv_b = invoice(&s, "INV-OTHER", vec![support_item]); + service + .post_invoice(&ctx, &scope, &inv_b, true) + .await + .expect("post B (support) must succeed"); + + let repo = bss_ledger::infra::storage::repo::RecognitionRepo::new(provider.clone()); + + // Tenant-only: BOTH schedules, untruncated. + let (all, truncated) = repo + .list_schedules(&scope, s.tenant, None, None) + .await + .expect("list (tenant)"); + assert_eq!(all.len(), 2, "both schedules for the tenant"); + assert!(!truncated, "two rows are well under the cap"); + let sched_a = all + .iter() + .find(|r| r.source_invoice_id == "INV-DEF") + .expect("schedule A present"); + assert_eq!(sched_a.revenue_stream, "subscription"); + assert_eq!(sched_a.source_invoice_item_ref, "item-1"); + assert_eq!(sched_a.status, "ACTIVE"); + assert_eq!(sched_a.total_deferred_minor, 1200); + let id_a = sched_a.schedule_id.clone(); + let id_b = all + .iter() + .find(|r| r.source_invoice_id == "INV-OTHER") + .expect("schedule B present") + .schedule_id + .clone(); + assert_ne!(id_a, id_b, "distinct schedules"); + + // `invoice_id` DISCRIMINATES — selects the right ONE of two (not "the only row"). + let (by_a, _) = repo + .list_schedules(&scope, s.tenant, Some("INV-DEF"), None) + .await + .expect("list (INV-DEF)"); + assert_eq!( + by_a.iter() + .map(|r| r.schedule_id.clone()) + .collect::>(), + vec![id_a.clone()], + ); + let (by_b, _) = repo + .list_schedules(&scope, s.tenant, Some("INV-OTHER"), None) + .await + .expect("list (INV-OTHER)"); + assert_eq!( + by_b.iter() + .map(|r| r.schedule_id.clone()) + .collect::>(), + vec![id_b.clone()], + ); + let (none_inv, _) = repo + .list_schedules(&scope, s.tenant, Some("INV-NOPE"), None) + .await + .expect("list (unknown invoice)"); + assert!(none_inv.is_empty(), "no schedule for an unknown invoice"); + + // `revenue_stream` DISCRIMINATES. + let (by_subscription, _) = repo + .list_schedules(&scope, s.tenant, None, Some("subscription")) + .await + .expect("list (subscription)"); + assert_eq!( + by_subscription + .iter() + .map(|r| r.schedule_id.clone()) + .collect::>(), + vec![id_a.clone()], + ); + let (by_support, _) = repo + .list_schedules(&scope, s.tenant, None, Some("support")) + .await + .expect("list (support)"); + assert_eq!( + by_support + .iter() + .map(|r| r.schedule_id.clone()) + .collect::>(), + vec![id_b.clone()], + ); + let (none_stream, _) = repo + .list_schedules(&scope, s.tenant, None, Some("usage")) + .await + .expect("list (unbooked stream)"); + assert!(none_stream.is_empty(), "no schedule for an unbooked stream"); + + // Combined filter = intersection (A); a cross filter (A's invoice + B's stream) + // matches nothing. + let (both, _) = repo + .list_schedules(&scope, s.tenant, Some("INV-DEF"), Some("subscription")) + .await + .expect("list (both filters)"); + assert_eq!( + both.iter() + .map(|r| r.schedule_id.clone()) + .collect::>(), + vec![id_a.clone()], + ); + let (cross, _) = repo + .list_schedules(&scope, s.tenant, Some("INV-DEF"), Some("support")) + .await + .expect("list (cross filters)"); + assert!( + cross.is_empty(), + "invoice A + stream B is an empty intersection" + ); + + // SQL-level BOLA: a foreign tenant's scope reads none of `s.tenant`'s schedules. + let foreign_scope = AccessScope::for_tenant(Uuid::now_v7()); + let (foreign, _) = repo + .list_schedules(&foreign_scope, s.tenant, None, None) + .await + .expect("list (foreign scope)"); + assert!( + foreign.is_empty(), + "a foreign-tenant scope yields no rows (SQL-level BOLA)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_recognition_change.rs b/gears/bss/ledger/ledger/tests/postgres_recognition_change.rs new file mode 100644 index 000000000..9177d44c5 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_recognition_change.rs @@ -0,0 +1,936 @@ +//! Postgres-only integration tests for the Slice 4 Group H schedule +//! change/cancel path (design §3.6 / §4.6), driven through the REAL stack: +//! `InvoicePostService` (materialize a deferred schedule) → +//! `RecognitionRunService` (release a period) → `RecognitionChangeService` +//! (cancel / replace) → `RecognitionRunService` again (verify the new/old +//! schedule release behaviour). +//! +//! Covers (Group H4): +//! - **replace (prospective)**: a 1200/3-period schedule, release period 1 (400), +//! then `replace` the remaining 800 over fresh segments → old `REPLACED`, a new +//! ACTIVE schedule with `total_deferred = 800` + its PENDING segments, the +//! already-DONE segment stays DONE, a run on the new period releases from the +//! NEW schedule, and the OLD schedule's remaining segments do NOT release; +//! - **cancel**: post + cancel → schedule `CANCELLED`, a later run releases +//! nothing for it; +//! - **catch_up** → `ModificationTreatmentReview`, schedule unchanged (ACTIVE); +//! - **idempotent** `change_id` replay → same result, no second new schedule. +//! +//! `LedgerLocalClient::new` is `pub(crate)`, so this out-of-crate test drives the +//! `pub` services directly (mirrors `postgres_recognition_run.rs`). Ignored by +//! default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::too_many_arguments, + clippy::similar_names +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::recognition::input::{RecognitionInput, RecognitionTiming}; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::recognition::change_service::RecognitionChangeService; +use bss_ledger::infra::recognition::run_service::RecognitionRunService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, ChangeRecognitionSchedule, ChangeSegment, Side}; +use chrono::NaiveDate; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn scalar_str(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + scalar_i64(conn, sql).await.unwrap_or(0) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Provisioned seller ids (mirrors `postgres_recognition_run::Seller`). +struct Seller { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + revenue: Uuid, + contract_liability: Uuid, + tax: Uuid, + suspense: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +async fn open_period(raw: &DatabaseConnection, s: &Seller, period_id: &str) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{period_id}','UTC','OPEN')", + s.tenant, s.tenant + ))) + .await + .unwrap(); +} + +/// Boot, migrate, seed USD@2 + an OPEN period + the recognition accounts. Mirrors +/// `postgres_recognition_run::setup`. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue: Uuid::now_v7(), + contract_liability: Uuid::now_v7(), + tax: Uuid::now_v7(), + suspense: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + open_period(&raw, &s, &s.period_id).await; + + for row in [ + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.revenue, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contract_liability, + AccountClass::ContractLiability, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + account( + s.tenant, + s.suspense, + AccountClass::Suspense, + Side::Credit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +fn recognized_item(amount: i64, periods: u32, first_period: &str, item_ref: &str) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods, + first_period_id: Some(first_period.to_owned()), + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some(item_ref.to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn invoice(s: &Seller, invoice_id: &str, items: Vec) -> PostedInvoice { + PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items, + tax: Vec::::new(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + } +} + +fn invoice_svc(provider: &DBProvider, harness: &MetricsHarness) -> InvoicePostService { + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + RecognitionConfig::default(), + FxConfig::default(), + ) +} + +fn run_svc(provider: &DBProvider, harness: &MetricsHarness) -> RecognitionRunService { + RecognitionRunService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + ) +} + +fn change_svc(provider: &DBProvider) -> RecognitionChangeService { + RecognitionChangeService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())) +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +/// The ACTIVE schedule_id for an invoice (there is exactly one ACTIVE at a time). +async fn active_schedule_id(raw: &DatabaseConnection, s: &Seller, invoice_id: &str) -> String { + scalar_str( + raw, + &format!( + "SELECT schedule_id FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='{invoice_id}' AND status='ACTIVE'", + s.tenant + ), + ) + .await + .expect("an ACTIVE schedule for the invoice") +} + +async fn schedule_status(raw: &DatabaseConnection, s: &Seller, schedule: &str) -> Option { + scalar_str( + raw, + &format!( + "SELECT status FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND schedule_id='{schedule}'", + s.tenant + ), + ) + .await +} + +async fn total_deferred(raw: &DatabaseConnection, s: &Seller, schedule: &str) -> Option { + scalar_i64( + raw, + &format!( + "SELECT total_deferred_minor FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND schedule_id='{schedule}'", + s.tenant + ), + ) + .await +} + +async fn segment_status( + raw: &DatabaseConnection, + s: &Seller, + schedule: &str, + segment_no: i32, +) -> Option { + scalar_str( + raw, + &format!( + "SELECT status FROM bss.ledger_recognition_segment \ + WHERE tenant_id='{}' AND schedule_id='{schedule}' AND segment_no={segment_no}", + s.tenant + ), + ) + .await +} + +async fn segment_count(raw: &DatabaseConnection, s: &Seller, schedule: &str) -> i64 { + count( + raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_recognition_segment \ + WHERE tenant_id='{}' AND schedule_id='{schedule}'", + s.tenant + ), + ) + .await +} + +/// Count ACTIVE schedules for an invoice (the one-live invariant; a replace must +/// keep this at exactly 1). +async fn active_schedule_count(raw: &DatabaseConnection, s: &Seller, invoice_id: &str) -> i64 { + count( + raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='{invoice_id}' AND status='ACTIVE'", + s.tenant + ), + ) + .await +} + +fn seg(period_id: &str, amount: i64) -> ChangeSegment { + ChangeSegment { + period_id: period_id.to_owned(), + amount_minor: amount, + } +} + +// --------------------------------------------------------------------------- + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn replace_prospective_re_plans_remaining_and_old_segments_do_not_release() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A 1200 / 3-period straight-line schedule: seg-1 202606 (400), seg-2 202607 + // (400), seg-3 202608 (400). Open all three periods. + open_period(&raw, &s, "202607").await; + open_period(&raw, &s, "202608").await; + let inv = invoice( + &s, + "INV-RPL", + vec![recognized_item(1200, 3, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let old = active_schedule_id(&raw, &s, "INV-RPL").await; + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(1200), + "fully deferred" + ); + + // Release period 1 only (releases seg-1 = 400). recognized = 400, CL = 800. + run_svc(&provider, &harness) + .trigger(&ctx, &scope, s.tenant, "202606", None) + .await + .expect("release period 1"); + assert_eq!( + segment_status(&raw, &s, &old, 1).await.as_deref(), + Some("DONE") + ); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(800), + "remaining deferred" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(400), + "period 1 recognized" + ); + + // REPLACE the remaining 800 over two fresh segments in 202607 + 202608 + // (prospective). The supplied segments MUST sum to the remaining 800. + let cmd = ChangeRecognitionSchedule { + tenant_id: s.tenant, + schedule_id: old.clone(), + change_id: "chg-rpl-1".to_owned(), + action: "replace".to_owned(), + treatment: "prospective".to_owned(), + new_segments: Some(vec![seg("202607", 300), seg("202608", 500)]), + }; + let result = change_svc(&provider) + .change(&ctx, &scope, cmd) + .await + .expect("replace applies"); + assert_eq!(result.status, "REPLACED"); + let new_id = result + .new_schedule_id + .clone() + .expect("a successor schedule"); + assert_ne!(new_id, old, "the successor is a fresh schedule_id"); + + // Old → REPLACED (its DONE seg-1 untouched); new → ACTIVE with total 800 + two + // PENDING segments; exactly one ACTIVE schedule for the invoice. + assert_eq!( + schedule_status(&raw, &s, &old).await.as_deref(), + Some("REPLACED") + ); + assert_eq!( + segment_status(&raw, &s, &old, 1).await.as_deref(), + Some("DONE"), + "DONE stays DONE" + ); + assert_eq!( + schedule_status(&raw, &s, &new_id).await.as_deref(), + Some("ACTIVE") + ); + assert_eq!( + total_deferred(&raw, &s, &new_id).await, + Some(800), + "remaining re-planned" + ); + assert_eq!( + segment_count(&raw, &s, &new_id).await, + 2, + "two replacement segments" + ); + assert_eq!( + segment_status(&raw, &s, &new_id, 1).await.as_deref(), + Some("PENDING") + ); + assert_eq!( + segment_status(&raw, &s, &new_id, 2).await.as_deref(), + Some("PENDING") + ); + assert_eq!( + active_schedule_count(&raw, &s, "INV-RPL").await, + 1, + "exactly one ACTIVE" + ); + // No compensating entry: CL + Revenue unchanged by the replace itself. + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(800), + "CL unchanged by replace" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(400), + "Revenue unchanged by replace" + ); + + // A run over 202608 releases the NEW schedule's two segments (300 + 500 = 800) + // — and the OLD schedule's remaining seg-2/seg-3 do NOT release (it is + // REPLACED, excluded from the runner's ACTIVE-only feed). + run_svc(&provider, &harness) + .trigger(&ctx, &scope, s.tenant, "202608", None) + .await + .expect("release the new schedule"); + assert_eq!( + segment_status(&raw, &s, &new_id, 1).await.as_deref(), + Some("DONE") + ); + assert_eq!( + segment_status(&raw, &s, &new_id, 2).await.as_deref(), + Some("DONE") + ); + // The OLD schedule's seg-2 / seg-3 are still PENDING (never released). + assert_eq!( + segment_status(&raw, &s, &old, 2).await.as_deref(), + Some("PENDING"), + "old seg untouched" + ); + assert_eq!( + segment_status(&raw, &s, &old, 3).await.as_deref(), + Some("PENDING"), + "old seg untouched" + ); + // Books: CL fully drained (800 released), Revenue == 1200 total (400 + 800). + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(0), + "CL drained via the new schedule" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(1200), + "all recognized, none double" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cancel_marks_cancelled_and_later_run_releases_nothing() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A single 600 / 1-period schedule, never released. + let inv = invoice( + &s, + "INV-CXL", + vec![recognized_item(600, 1, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let sched = active_schedule_id(&raw, &s, "INV-CXL").await; + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(600), + "deferred" + ); + + // CANCEL it. The unreleased deferred remainder stays as CONTRACT_LIABILITY (no + // auto-reversal); the schedule flips CANCELLED, no successor. + let cmd = ChangeRecognitionSchedule { + tenant_id: s.tenant, + schedule_id: sched.clone(), + change_id: "chg-cxl-1".to_owned(), + action: "cancel".to_owned(), + treatment: "prospective".to_owned(), + new_segments: None, + }; + let result = change_svc(&provider) + .change(&ctx, &scope, cmd) + .await + .expect("cancel applies"); + assert_eq!(result.status, "CANCELLED"); + assert!( + result.new_schedule_id.is_none(), + "cancel mints no successor" + ); + assert_eq!( + schedule_status(&raw, &s, &sched).await.as_deref(), + Some("CANCELLED") + ); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(600), + "remainder stays as CL" + ); + + // A later run for the period releases nothing for the CANCELLED schedule. + run_svc(&provider, &harness) + .trigger(&ctx, &scope, s.tenant, "202606", None) + .await + .expect("run is a no-op for the cancelled schedule"); + assert_eq!( + segment_status(&raw, &s, &sched, 1).await.as_deref(), + Some("PENDING"), + "never released" + ); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(600), + "still deferred" + ); + assert_eq!(bal(&raw, &s, s.revenue).await, None, "nothing recognized"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn catch_up_treatment_is_review_and_leaves_schedule_active() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let inv = invoice( + &s, + "INV-CU", + vec![recognized_item(600, 1, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let sched = active_schedule_id(&raw, &s, "INV-CU").await; + + // A catch_up treatment is surfaced for review with NO state change (§3.6). + let cmd = ChangeRecognitionSchedule { + tenant_id: s.tenant, + schedule_id: sched.clone(), + change_id: "chg-cu-1".to_owned(), + action: "cancel".to_owned(), + treatment: "catch_up".to_owned(), + new_segments: None, + }; + let err = change_svc(&provider) + .change(&ctx, &scope, cmd) + .await + .expect_err("catch_up must be a review"); + assert!( + matches!(err, DomainError::ModificationTreatmentReview(_)), + "expected ModificationTreatmentReview, got {err:?}" + ); + + // The schedule is unchanged — still ACTIVE, and no dedup row should block a + // later legitimate change (the treatment gate ran BEFORE the claim). + assert_eq!( + schedule_status(&raw, &s, &sched).await.as_deref(), + Some("ACTIVE"), + "still ACTIVE" + ); + assert_eq!(active_schedule_count(&raw, &s, "INV-CU").await, 1); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn replace_is_idempotent_on_change_id() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A 900 / 1-period schedule (never released ⇒ remaining = 900). + let inv = invoice( + &s, + "INV-IDEM", + vec![recognized_item(900, 1, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let old = active_schedule_id(&raw, &s, "INV-IDEM").await; + + let mk = || ChangeRecognitionSchedule { + tenant_id: s.tenant, + schedule_id: old.clone(), + change_id: "chg-idem-1".to_owned(), + action: "replace".to_owned(), + treatment: "prospective".to_owned(), + new_segments: Some(vec![seg("202606", 900)]), + }; + + let first = change_svc(&provider) + .change(&ctx, &scope, mk()) + .await + .expect("first replace applies"); + let new_id = first.new_schedule_id.clone().expect("a successor"); + assert_eq!(first.status, "REPLACED"); + + // Replay the SAME change_id: same result (same successor id), and NO second + // new schedule is minted. + let replay = change_svc(&provider) + .change(&ctx, &scope, mk()) + .await + .expect("replay returns the prior result"); + assert_eq!(replay.status, "REPLACED"); + assert_eq!( + replay.new_schedule_id.as_deref(), + Some(new_id.as_str()), + "replay returns the same successor id" + ); + + // Exactly ONE ACTIVE successor exists for the invoice (no second mint). + assert_eq!( + active_schedule_count(&raw, &s, "INV-IDEM").await, + 1, + "no second new schedule" + ); + // Total schedules for the invoice = 2 (the REPLACED old + the one ACTIVE new). + let total = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='INV-IDEM'", + s.tenant + ), + ) + .await; + assert_eq!(total, 2, "old REPLACED + exactly one ACTIVE successor"); +} + +// ── Fix 2: replacement-segment PERIOD validation (design §4.6) ──────────────── + +/// Post a fresh single-period deferred schedule (`amount` deferred in 202606, +/// never released ⇒ remaining == amount) and return its ACTIVE schedule_id — the +/// substrate for the period-validation rejection tests (the supplied replacement +/// segments sum to `amount`, so the sum check passes and the PERIOD check is what +/// fires). +async fn fresh_schedule( + raw: &DatabaseConnection, + provider: &DBProvider, + harness: &MetricsHarness, + ctx: &SecurityContext, + scope: &AccessScope, + s: &Seller, + invoice_id: &str, + amount: i64, +) -> String { + let inv = invoice( + s, + invoice_id, + vec![recognized_item(amount, 1, "202606", "item-1")], + ); + invoice_svc(provider, harness) + .post_invoice(ctx, scope, &inv, true) + .await + .expect("deferred invoice posts"); + active_schedule_id(raw, s, invoice_id).await +} + +/// Build a `replace` command for `schedule_id` with the given replacement +/// segments (treatment `prospective`, a unique `change_id`). +fn replace_cmd( + s: &Seller, + schedule_id: &str, + change_id: &str, + segments: Vec, +) -> ChangeRecognitionSchedule { + ChangeRecognitionSchedule { + tenant_id: s.tenant, + schedule_id: schedule_id.to_owned(), + change_id: change_id.to_owned(), + action: "replace".to_owned(), + treatment: "prospective".to_owned(), + new_segments: Some(segments), + } +} + +/// Replacement segments in DESCENDING period order are rejected with a 400 +/// (`InvalidRequest`) — the periods must be strictly ascending. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn replace_with_descending_periods_is_rejected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let sched = fresh_schedule( + &raw, &provider, &harness, &ctx, &scope, &s, "INV-DESC", 1000, + ) + .await; + // Sums to 1000 (remaining), but 202607 then 202606 is descending. + let cmd = replace_cmd( + &s, + &sched, + "chg-desc", + vec![seg("202607", 500), seg("202606", 500)], + ); + let err = change_svc(&provider) + .change(&ctx, &scope, cmd) + .await + .expect_err("descending replacement periods must be rejected"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest (400), got {err:?}" + ); + // The schedule is untouched — still ACTIVE (the validation ran before the flip). + assert_eq!( + schedule_status(&raw, &s, &sched).await.as_deref(), + Some("ACTIVE") + ); + assert_eq!(active_schedule_count(&raw, &s, "INV-DESC").await, 1); +} + +/// Replacement segments with a DUPLICATE period are rejected with a 400 — the +/// periods must be distinct. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn replace_with_duplicate_period_is_rejected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let sched = fresh_schedule(&raw, &provider, &harness, &ctx, &scope, &s, "INV-DUP", 1000).await; + // Sums to 1000, but 202606 appears twice (not distinct). + let cmd = replace_cmd( + &s, + &sched, + "chg-dup", + vec![seg("202606", 500), seg("202606", 500)], + ); + let err = change_svc(&provider) + .change(&ctx, &scope, cmd) + .await + .expect_err("a duplicate replacement period must be rejected"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest (400), got {err:?}" + ); + assert_eq!( + schedule_status(&raw, &s, &sched).await.as_deref(), + Some("ACTIVE") + ); +} + +/// A MALFORMED replacement period (not a valid `YYYYMM`) is rejected with a 400. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn replace_with_malformed_period_is_rejected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let sched = fresh_schedule(&raw, &provider, &harness, &ctx, &scope, &s, "INV-MAL", 1000).await; + // Sums to 1000, but "202613" is not a valid YYYYMM (month 13). + let cmd = replace_cmd(&s, &sched, "chg-mal", vec![seg("202613", 1000)]); + let err = change_svc(&provider) + .change(&ctx, &scope, cmd) + .await + .expect_err("a malformed replacement period must be rejected"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest (400), got {err:?}" + ); + assert_eq!( + schedule_status(&raw, &s, &sched).await.as_deref(), + Some("ACTIVE") + ); +} + +/// A replacement whose first period overlaps an ALREADY-DONE period of the old +/// schedule is rejected with a 400 — a replacement can never re-recognize a +/// period that already posted (cross-version double-recognition). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn replace_overlapping_an_already_done_period_is_rejected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A 1200 / 3-period schedule (202606/202607/202608, 400 each). Release period + // 1 so 202606 is DONE; remaining = 800. + open_period(&raw, &s, "202607").await; + open_period(&raw, &s, "202608").await; + let inv = invoice( + &s, + "INV-OVL", + vec![recognized_item(1200, 3, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let old = active_schedule_id(&raw, &s, "INV-OVL").await; + run_svc(&provider, &harness) + .trigger(&ctx, &scope, s.tenant, "202606", None) + .await + .expect("release period 1"); + assert_eq!( + segment_status(&raw, &s, &old, 1).await.as_deref(), + Some("DONE") + ); + + // Replace the remaining 800 but with a FIRST period (202606) that overlaps the + // already-DONE 202606. Sums to 800 (remaining), so the sum check passes and the + // period-floor check is what rejects it. + let cmd = replace_cmd(&s, &old, "chg-ovl", vec![seg("202606", 800)]); + let err = change_svc(&provider) + .change(&ctx, &scope, cmd) + .await + .expect_err("a replacement re-targeting an already-DONE period must be rejected"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest (400), got {err:?}" + ); + // The old schedule is untouched — still ACTIVE, no successor minted. + assert_eq!( + schedule_status(&raw, &s, &old).await.as_deref(), + Some("ACTIVE") + ); + assert_eq!( + active_schedule_count(&raw, &s, "INV-OVL").await, + 1, + "no successor" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_recognition_disaggregation.rs b/gears/bss/ledger/ledger/tests/postgres_recognition_disaggregation.rs new file mode 100644 index 000000000..727fc1834 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_recognition_disaggregation.rs @@ -0,0 +1,616 @@ +//! Postgres-only integration test for the Slice 4 ASC 606 **disaggregation** +//! (Group G2/G3), driven through the REAL stack: `InvoicePostService` (to +//! materialize per-stream deferred schedules + segments) → +//! `RecognitionRunService` (to release them) → `RecognitionRepo:: +//! list_revenue_disaggregation` (the by-stream report the `GET +//! /revenue/disaggregation` endpoint reads through). +//! +//! Covers (design §3.5 / §4.5, Group G2): +//! - **per-stream drain**: a multi-stream deferred bundle (one item per stream, +//! `subscription` + `usage`) materializes ONE schedule per stream (the partial +//! UNIQUE on `(tenant, source_invoice_id, source_invoice_item_ref, +//! revenue_stream)`); a run releases every due segment so EACH stream's +//! per-stream `CONTRACT_LIABILITY` balance drains to zero; +//! - **disaggregation**: the report returns one entry per stream with the right +//! `recognized_minor`, at the `(period_id, revenue_stream)` grain. +//! +//! `LedgerLocalClient::new` is `pub(crate)`, so this out-of-crate test drives the +//! `pub` services + repo directly (mirrors `postgres_recognition_run.rs`). +//! Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::similar_names +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::recognition::input::{RecognitionInput, RecognitionTiming}; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::recognition::run_service::RecognitionRunService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{RecognitionRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, RecognitionRunOutcome, Side}; +use chrono::NaiveDate; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Provisioned seller ids. Two revenue streams (`subscription` + `usage`), each +/// with its OWN per-stream `REVENUE` + `CONTRACT_LIABILITY` account, so a +/// per-stream drain is observable on distinct balance rows. +struct Seller { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + revenue_sub: Uuid, + revenue_use: Uuid, + cl_sub: Uuid, + cl_use: Uuid, + tax: Uuid, + suspense: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +async fn open_period(raw: &DatabaseConnection, s: &Seller, period_id: &str) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{period_id}','UTC','OPEN')", + s.tenant, s.tenant + ))) + .await + .unwrap(); +} + +/// Force a period CLOSED (test shortcut — the harness raw-INSERTs OPEN periods via +/// [`open_period`]; this flips one to CLOSED so an E-2 missed-close is observable: +/// `fiscal_period` is mutable, unlike the append-only journal). +async fn close_period(raw: &DatabaseConnection, s: &Seller, period_id: &str) { + raw.execute(pg(format!( + "UPDATE bss.ledger_fiscal_period SET status='CLOSED' \ + WHERE tenant_id='{}' AND period_id='{period_id}'", + s.tenant + ))) + .await + .unwrap(); +} + +/// Boot, migrate, seed USD@2 + an OPEN period + AR / TAX / SUSPENSE and the TWO +/// per-stream REVENUE + CONTRACT_LIABILITY accounts (`subscription` + `usage`). +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue_sub: Uuid::now_v7(), + revenue_use: Uuid::now_v7(), + cl_sub: Uuid::now_v7(), + cl_use: Uuid::now_v7(), + tax: Uuid::now_v7(), + suspense: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + open_period(&raw, &s, &s.period_id).await; + + for row in [ + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.revenue_sub, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.revenue_use, + AccountClass::Revenue, + Side::Credit, + Some("usage"), + ), + account( + s.tenant, + s.cl_sub, + AccountClass::ContractLiability, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.cl_use, + AccountClass::ContractLiability, + Side::Credit, + Some("usage"), + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + account( + s.tenant, + s.suspense, + AccountClass::Suspense, + Side::Credit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +/// A `stream` item with a straight-line recognition spec over `periods` months +/// from `first_period`, with its own `item_ref` (so each stream's schedule keys +/// on a distinct `(source_invoice_item_ref, revenue_stream)`). +fn recognized_item( + amount: i64, + stream: &str, + periods: u32, + first_period: &str, + item_ref: &str, +) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: stream.to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods, + first_period_id: Some(first_period.to_owned()), + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some(item_ref.to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn invoice(s: &Seller, invoice_id: &str, items: Vec) -> PostedInvoice { + PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items, + tax: Vec::::new(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + } +} + +fn invoice_svc(provider: &DBProvider, harness: &MetricsHarness) -> InvoicePostService { + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + RecognitionConfig::default(), + FxConfig::default(), + ) +} + +fn run_svc(provider: &DBProvider, harness: &MetricsHarness) -> RecognitionRunService { + RecognitionRunService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + ) +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +// --------------------------------------------------------------------------- + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn per_stream_bundle_drains_each_stream_and_disaggregates() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // One deferred bundle, ONE item per stream — each deferred straight-line over + // 2 months from 202606 (so the segments land in 202606 + 202607). Distinct + // item_refs ⇒ a distinct ACTIVE schedule per stream (the per-stream split, + // §3.5). subscription: 1000 over 2 ⇒ 500 + 500; usage: 600 over 2 ⇒ 300 + 300. + open_period(&raw, &s, "202607").await; + let inv = invoice( + &s, + "INV-MULTI", + vec![ + recognized_item(1000, "subscription", 2, "202606", "item-sub"), + recognized_item(600, "usage", 2, "202606", "item-use"), + ], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("multi-stream deferred invoice posts"); + + // Two ACTIVE schedules (one per stream) materialized in the post txn. + let schedule_count = scalar_i64( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='INV-MULTI' AND status='ACTIVE'", + s.tenant + ), + ) + .await; + assert_eq!(schedule_count, Some(2), "one ACTIVE schedule per stream"); + + // Each per-stream CONTRACT_LIABILITY account holds its own deferred total. + assert_eq!( + bal(&raw, &s, s.cl_sub).await, + Some(1000), + "subscription CL deferred" + ); + assert_eq!( + bal(&raw, &s, s.cl_use).await, + Some(600), + "usage CL deferred" + ); + + // Run the LATER period 202607 (releases both segments of BOTH schedules, in + // order). 4 segments total (2 per stream). + let svc = run_svc(&provider, &harness); + let outcome = svc + .trigger(&ctx, &scope, s.tenant, "202607", None) + .await + .expect("run releases every due segment"); + match outcome { + RecognitionRunOutcome::Ran(r) => { + assert_eq!(r.released, 4, "2 segments × 2 streams released fresh"); + } + RecognitionRunOutcome::Queued(_) => panic!("in-order release must not queue"), + } + + // (a) EACH stream's per-stream CONTRACT_LIABILITY balance drains to ZERO + // (fully released), and each stream's Revenue is fully recognized. + assert_eq!( + bal(&raw, &s, s.cl_sub).await, + Some(0), + "subscription CL fully drained" + ); + assert_eq!( + bal(&raw, &s, s.cl_use).await, + Some(0), + "usage CL fully drained" + ); + assert_eq!( + bal(&raw, &s, s.revenue_sub).await, + Some(1000), + "subscription recognized" + ); + assert_eq!( + bal(&raw, &s, s.revenue_use).await, + Some(600), + "usage recognized" + ); + + // (b) The disaggregation query returns one entry per (period, stream) with the + // right recognized_minor — over all periods (period_id = None). Ordered by + // (period_id, revenue_stream): 202606/subscription, 202606/usage, + // 202607/subscription, 202607/usage. + let repo = RecognitionRepo::new(provider.clone()); + let all = repo + .list_revenue_disaggregation(&scope, s.tenant, None) + .await + .expect("disaggregation over all periods"); + let got: Vec<(String, String, i64, String)> = all + .iter() + .map(|e| { + ( + e.period_id.clone(), + e.revenue_stream.clone(), + e.recognized_minor, + e.currency.clone(), + ) + }) + .collect(); + assert_eq!( + got, + vec![ + ( + "202606".to_owned(), + "subscription".to_owned(), + 500, + "USD".to_owned() + ), + ( + "202606".to_owned(), + "usage".to_owned(), + 300, + "USD".to_owned() + ), + ( + "202607".to_owned(), + "subscription".to_owned(), + 500, + "USD".to_owned() + ), + ( + "202607".to_owned(), + "usage".to_owned(), + 300, + "USD".to_owned() + ), + ], + "one entry per (period, stream) with Σ amount_minor, ordered" + ); + + // Per-stream totals across periods sum to each stream's deferred total. + let sub_total: i64 = all + .iter() + .filter(|e| e.revenue_stream == "subscription") + .map(|e| e.recognized_minor) + .sum(); + let use_total: i64 = all + .iter() + .filter(|e| e.revenue_stream == "usage") + .map(|e| e.recognized_minor) + .sum(); + assert_eq!( + sub_total, 1000, + "subscription recognized in full across periods" + ); + assert_eq!(use_total, 600, "usage recognized in full across periods"); + + // (c) Narrowing to one period returns only that period's two streams. + let p1 = repo + .list_revenue_disaggregation(&scope, s.tenant, Some("202606")) + .await + .expect("disaggregation for 202606"); + let p1_got: Vec<(String, i64)> = p1 + .iter() + .map(|e| (e.revenue_stream.clone(), e.recognized_minor)) + .collect(); + assert_eq!( + p1_got, + vec![("subscription".to_owned(), 500), ("usage".to_owned(), 300)], + "202606 narrows to that period's per-stream recognized revenue" + ); + + // (d) BOLA: a foreign tenant's scope yields no entries. + let foreign = AccessScope::for_tenant(Uuid::now_v7()); + let none = repo + .list_revenue_disaggregation(&foreign, s.tenant, None) + .await + .expect("foreign-scope read succeeds but yields nothing"); + assert!( + none.is_empty(), + "a foreign tenant scope sees no recognized revenue" + ); +} + +/// **E-2 missed-close → the report buckets revenue under the ACTUAL (open) period, +/// not the planned/closed one** (design §4.3 / §4.5). A segment keeps its planned +/// `period_id` as the audit target, but an E-2 missed-close releases it INTO the +/// current open period; the journal entry (hence `journal_line.period_id`) carries +/// that open period. Because the disaggregation read is journal-sourced, it reports +/// the period the revenue truly landed in — whereas a DONE-segment scan would +/// wrongly split it under the closed planned period. (The same journal source makes +/// the report reversal-aware — a `DR REVENUE` clawback nets out — exercised by the +/// release/reversal paths in `postgres_recognition_change.rs`.) +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn missed_close_disaggregates_under_the_open_period() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; // setup opens 202606 + seeds accounts + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // The schedule straight-lines 1000 over 202605 + 202606 (500 each). Open 202605 + // so the deferred invoice can post there; both segments materialize PENDING. + open_period(&raw, &s, "202605").await; + let inv = PostedInvoice { + invoice_id: "INV-MISSED".to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 5, 1), + due_date: Some(naive(2026, 6, 1)), + period_id: "202605".to_owned(), + items: vec![recognized_item( + 1000, + "subscription", + 2, + "202605", + "item-sub", + )], + tax: Vec::::new(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + }; + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts in 202605"); + + // 202605 now CLOSES; 202606 (opened by setup) is the current open period. + close_period(&raw, &s, "202605").await; + + // Run the open period 202606: BOTH segments are due (period ≤ 202606). The + // 202605 segment's planned period is closed (< open 202606) ⇒ E-2 releases it + // INTO 202606; the 202606 segment releases into its own (open) period. All 1000 + // lands in 202606. + let svc = run_svc(&provider, &harness); + let outcome = svc + .trigger(&ctx, &scope, s.tenant, "202606", None) + .await + .expect("run releases both due segments into the open period"); + match outcome { + RecognitionRunOutcome::Ran(r) => assert_eq!(r.released, 2, "both segments released fresh"), + RecognitionRunOutcome::Queued(_) => panic!("in-order release must not queue"), + } + + // CONTRACT_LIABILITY fully drained; REVENUE fully recognized. + assert_eq!(bal(&raw, &s, s.cl_sub).await, Some(0), "CL fully drained"); + assert_eq!( + bal(&raw, &s, s.revenue_sub).await, + Some(1000), + "revenue fully recognized" + ); + + // Disaggregation (all periods): BOTH releases bucket under 202606 — the ACTUAL + // open period the entries posted into — with NOTHING under the planned-but- + // closed 202605. (A segment-period scan would wrongly split 500/500.) + let repo = RecognitionRepo::new(provider.clone()); + let all = repo + .list_revenue_disaggregation(&scope, s.tenant, None) + .await + .expect("disaggregation over all periods"); + let got: Vec<(String, String, i64, String)> = all + .iter() + .map(|e| { + ( + e.period_id.clone(), + e.revenue_stream.clone(), + e.recognized_minor, + e.currency.clone(), + ) + }) + .collect(); + assert_eq!( + got, + vec![( + "202606".to_owned(), + "subscription".to_owned(), + 1000, + "USD".to_owned() + )], + "all recognized revenue is reported under the ACTUAL open period 202606" + ); + assert!( + all.iter().all(|e| e.period_id != "202605"), + "no revenue is reported under the planned-but-closed period 202605" + ); + + // Narrowing confirms it: the closed planned period reports nothing; the open + // period reports the full recognized revenue. + let p_closed = repo + .list_revenue_disaggregation(&scope, s.tenant, Some("202605")) + .await + .expect("query 202605"); + assert!( + p_closed.is_empty(), + "the closed planned period reports no recognized revenue" + ); + let p_open: Vec<(String, i64)> = repo + .list_revenue_disaggregation(&scope, s.tenant, Some("202606")) + .await + .expect("query 202606") + .iter() + .map(|e| (e.revenue_stream.clone(), e.recognized_minor)) + .collect(); + assert_eq!( + p_open, + vec![("subscription".to_owned(), 1000)], + "the open period reports the full recognized revenue" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_recognition_run.rs b/gears/bss/ledger/ledger/tests/postgres_recognition_run.rs new file mode 100644 index 000000000..f02656af4 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_recognition_run.rs @@ -0,0 +1,1195 @@ +//! Postgres-only integration + concurrency tests for the Slice 4 ASC 606 S6 +//! **release** (Groups D/E/F), driven through the REAL stack: +//! `InvoicePostService` (to materialize a deferred schedule + segments) → +//! `RecognitionRunService` / `RecognitionRunner` (to release / reverse them). +//! +//! Covers (design §11, Group F4): +//! - **atomic release**: a run posts one `DR CL / CR Revenue` entry per due +//! segment AND bumps `recognized_minor` AND stamps the segment `DONE`, all in +//! one txn (balances + counter + status all move together); +//! - **no double recognition**: re-running the same period credits each segment +//! exactly once (the per-segment `RECOGNITION` gate + `status = DONE` / +//! `UNIQUE (schedule, period_id)`); +//! - **over-recognition blocked at the per-schedule CHECK** even when a sibling +//! schedule keeps the per-stream `CONTRACT_LIABILITY` account aggregate +//! positive (the cap is per-obligation, not per-account); +//! - **reversal** decrements `recognized_minor`, restores `CONTRACT_LIABILITY`, +//! and leaves the reversed segment `DONE`; +//! - **racing runs** on the same period → each segment credited exactly once (no +//! double-credit under contention); +//! - **ordering resume**: an out-of-order-parked `QUEUED` segment drains on a +//! later run once its predecessor commits `DONE`. +//! +//! `LedgerLocalClient::new` is `pub(crate)`, so this out-of-crate test drives the +//! `pub` services directly (mirrors `postgres_recognition_build.rs`). Ignored by +//! default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::type_complexity, + clippy::similar_names +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::model::RepoError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::recognition::input::{RecognitionInput, RecognitionTiming}; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::recognition::run_service::RecognitionRunService; +use bss_ledger::infra::recognition::runner::{RecognitionRunner, ReleasableSegment}; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::recognition_repo::NewSchedule; +use bss_ledger::infra::storage::repo::{RecognitionRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, RecognitionRunOutcome, Side}; +use chrono::NaiveDate; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + scalar_i64(conn, sql).await.unwrap_or(0) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Provisioned seller ids (incl. the per-stream Contract-liability account the +/// deferred split credits + the release draws down). +struct Seller { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + revenue: Uuid, + contract_liability: Uuid, + tax: Uuid, + suspense: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Open one more fiscal period for the seller (so a release into a later period +/// passes the foundation OPEN-period gate). +async fn open_period(raw: &DatabaseConnection, s: &Seller, period_id: &str) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{period_id}','UTC','OPEN')", + s.tenant, s.tenant + ))) + .await + .unwrap(); +} + +/// Boot, migrate, seed USD@2 + an OPEN period + AR / REVENUE(subscription) / +/// CONTRACT_LIABILITY(subscription) / TAX / SUSPENSE accounts. Mirrors +/// `postgres_recognition_build::setup`. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue: Uuid::now_v7(), + contract_liability: Uuid::now_v7(), + tax: Uuid::now_v7(), + suspense: Uuid::now_v7(), + period_id: "202606".to_owned(), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + open_period(&raw, &s, &s.period_id).await; + + for row in [ + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.revenue, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contract_liability, + AccountClass::ContractLiability, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + account( + s.tenant, + s.suspense, + AccountClass::Suspense, + Side::Credit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +/// A `subscription` item with a straight-line recognition spec over `periods` +/// months from `first_period_id` (so the segments land in known periods). +fn recognized_item(amount: i64, periods: u32, first_period: &str, item_ref: &str) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods, + first_period_id: Some(first_period.to_owned()), + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some(item_ref.to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn invoice(s: &Seller, invoice_id: &str, items: Vec) -> PostedInvoice { + PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items, + tax: Vec::::new(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + } +} + +fn invoice_svc(provider: &DBProvider, harness: &MetricsHarness) -> InvoicePostService { + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + RecognitionConfig::default(), + FxConfig::default(), + ) +} + +fn run_svc(provider: &DBProvider, harness: &MetricsHarness) -> RecognitionRunService { + RecognitionRunService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + ) +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +/// The schedule_id of the (single) ACTIVE schedule for an invoice. +async fn schedule_id(raw: &DatabaseConnection, s: &Seller, invoice_id: &str) -> String { + raw.query_one(pg(format!( + "SELECT schedule_id FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='{invoice_id}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) + .expect("a schedule for the invoice") +} + +async fn recognized_minor(raw: &DatabaseConnection, s: &Seller, schedule: &str) -> Option { + scalar_i64( + raw, + &format!( + "SELECT recognized_minor FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND schedule_id='{schedule}'", + s.tenant + ), + ) + .await +} + +/// The `status` of a schedule row by `schedule_id`. +async fn schedule_status_of( + raw: &DatabaseConnection, + s: &Seller, + schedule: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT status FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND schedule_id='{schedule}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Count recognition (DR CL / CR Revenue) journal ENTRIES for the schedule's +/// release business id `schedule_id:segment_no` (NOT the reversal). +async fn release_entry_count( + raw: &DatabaseConnection, + s: &Seller, + schedule: &str, + segment_no: i32, +) -> i64 { + count( + raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_doc_type='RECOGNITION' \ + AND source_business_id='{schedule}:{segment_no}'", + s.tenant + ), + ) + .await +} + +async fn segment_status( + raw: &DatabaseConnection, + s: &Seller, + schedule: &str, + segment_no: i32, +) -> Option { + raw.query_one(pg(format!( + "SELECT status FROM bss.ledger_recognition_segment \ + WHERE tenant_id='{}' AND schedule_id='{schedule}' AND segment_no={segment_no}", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +// --------------------------------------------------------------------------- + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn run_releases_atomically_and_is_not_double_credited() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A 2-segment straight-line schedule, both segments in the single OPEN period + // 202606 (so both are due + releasable without opening more periods): 1000 ex + // tax over 2 months from 202606 ⇒ each segment 500, but both land in + // 202606..202607. To keep both in the OPEN period, open 202607 too. + open_period(&raw, &s, "202607").await; + let inv = invoice( + &s, + "INV-REL", + vec![recognized_item(1000, 2, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let sched = schedule_id(&raw, &s, "INV-REL").await; + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(1000), + "CL fully deferred before release" + ); + + // Trigger a run for the LATER period 202607 (releases both segments + // 202606 + 202607, in order). + let svc = run_svc(&provider, &harness); + let outcome = svc + .trigger(&ctx, &scope, s.tenant, "202607", None) + .await + .expect("run releases"); + match outcome { + RecognitionRunOutcome::Ran(r) => assert_eq!(r.released, 2, "both segments released fresh"), + RecognitionRunOutcome::Queued(_) => panic!("in-order release must not queue"), + } + + // Atomic effects: CL drained to 0, Revenue credited 1000, recognized_minor = + // total, both segments DONE, one release entry per segment. + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(0), + "CL drained" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(1000), + "Revenue recognized" + ); + assert_eq!(recognized_minor(&raw, &s, &sched).await, Some(1000)); + assert_eq!( + segment_status(&raw, &s, &sched, 1).await.as_deref(), + Some("DONE") + ); + assert_eq!( + segment_status(&raw, &s, &sched, 2).await.as_deref(), + Some("DONE") + ); + assert_eq!(release_entry_count(&raw, &s, &sched, 1).await, 1); + assert_eq!(release_entry_count(&raw, &s, &sched, 2).await, 1); + + // Re-run the SAME period: NO double credit (each segment replays via the + // per-segment RECOGNITION gate + status=DONE). Balances + counter unchanged, + // still exactly one entry per segment. + let again = svc + .trigger(&ctx, &scope, s.tenant, "202607", None) + .await + .expect("re-run is a no-op"); + if let RecognitionRunOutcome::Ran(r) = again { + assert_eq!(r.released, 0, "nothing fresh on the re-run"); + } + assert_eq!(bal(&raw, &s, s.contract_liability).await, Some(0)); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(1000), + "no second credit" + ); + assert_eq!(recognized_minor(&raw, &s, &sched).await, Some(1000)); + assert_eq!( + release_entry_count(&raw, &s, &sched, 1).await, + 1, + "still one entry" + ); + assert_eq!(release_entry_count(&raw, &s, &sched, 2).await, 1); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn over_recognition_blocked_at_per_schedule_check_with_sibling_positive() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // TWO schedules on the SAME stream/account (two single-period 1-segment + // invoices, each 600 deferred in 202606). The per-stream CONTRACT_LIABILITY + // account aggregates BOTH (1200 credit). + for inv_id in ["INV-A", "INV-B"] { + let inv = invoice( + &s, + inv_id, + vec![recognized_item(600, 1, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + } + let sched_a = schedule_id(&raw, &s, "INV-A").await; + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(1200), + "both schedules aggregate on the per-stream CL account" + ); + + // Release ONLY schedule A's segment (via the runner's single-segment release), + // leaving schedule B fully deferred. recognized_minor(A) = 600 = its total; + // the per-stream CONTRACT_LIABILITY account is still +600 (B's deferred + // balance), so the ACCOUNT aggregate is comfortably positive. + let runner = RecognitionRunner::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + ); + let seg_a = ReleasableSegment { + schedule_id: sched_a.clone(), + segment_no: 1, + period_id: "202606".to_owned(), + amount_minor: 600, + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + }; + runner + .release_segment(&ctx, &scope, s.tenant, &seg_a, Uuid::now_v7()) + .await + .expect("release schedule A's segment"); + assert_eq!(recognized_minor(&raw, &s, &sched_a).await, Some(600)); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(600), + "the per-stream CL account is still positive (schedule B's deferred balance)" + ); + + // Now attempt to OVER-bump schedule A past its 600 total by +1 (the path the + // runner's stamp sidecar exercises). The per-schedule + // `recognized_minor <= total_deferred_minor` CHECK rejects it as a cap + // violation — EVEN THOUGH the per-stream CONTRACT_LIABILITY account is still + // +600. The cap is per-obligation (per schedule), not per-account. + let err = provider + .transaction(|txn| { + let sched_a = sched_a.clone(); + let scope = scope.clone(); + Box::pin(async move { + RecognitionRepo::add_recognized(txn, &scope, s.tenant, &sched_a, 1) + .await + .map_err(|e| DbError::Sea(sea_orm::DbErr::Custom(e.to_string()))) + }) + }) + .await + .expect_err("over-recognition must be blocked at the per-schedule CHECK"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("chk_ledger_recognition_schedule_") || msg.contains("check"), + "over-recognition is the per-schedule cap CHECK, got: {msg}" + ); + // Schedule A's counter is unchanged (the over-bump rolled back). + assert_eq!(recognized_minor(&raw, &s, &sched_a).await, Some(600)); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reversal_decrements_and_segment_stays_done() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // One single-period 1-segment schedule (600 deferred in 202606), released. + let inv = invoice( + &s, + "INV-REV", + vec![recognized_item(600, 1, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let sched = schedule_id(&raw, &s, "INV-REV").await; + run_svc(&provider, &harness) + .trigger(&ctx, &scope, s.tenant, "202606", None) + .await + .expect("release"); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(0), + "CL drained" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(600), + "Revenue recognized" + ); + assert_eq!(recognized_minor(&raw, &s, &sched).await, Some(600)); + assert_eq!( + segment_status(&raw, &s, &sched, 1).await.as_deref(), + Some("DONE") + ); + + // Reverse the released segment: DR Revenue / CR CL, decrement recognized_minor + // back to 0 — and the reversed segment STAYS DONE (design §4.3). + let runner = RecognitionRunner::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + ); + let seg = ReleasableSegment { + schedule_id: sched.clone(), + segment_no: 1, + period_id: "202606".to_owned(), + amount_minor: 600, + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + }; + let posting = runner + .release_reversal(&ctx, &scope, s.tenant, &seg) + .await + .expect("reversal posts"); + assert!(!posting.replayed, "a fresh reversal"); + + // Effects: CL restored to 600, Revenue back to 0, recognized_minor back to 0. + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(600), + "CL restored" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(0), + "revenue un-recognized" + ); + assert_eq!( + recognized_minor(&raw, &s, &sched).await, + Some(0), + "counter decremented" + ); + // The reversed segment is left DONE (its release happened + was compensated; + // re-recognizing needs a NEW schedule version, Phase 3). + assert_eq!( + segment_status(&raw, &s, &sched, 1).await.as_deref(), + Some("DONE"), + "reversed segment stays DONE" + ); + + // The reversal is idempotent (schedule_id:segment_no:reversal): a replay does + // not decrement twice (which would underflow the recognized_minor >= 0 CHECK). + let replay = runner + .release_reversal(&ctx, &scope, s.tenant, &seg) + .await + .expect("reversal replay is a no-op"); + assert!(replay.replayed, "the reversal replays"); + assert_eq!( + recognized_minor(&raw, &s, &sched).await, + Some(0), + "no double decrement" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn racing_runs_credit_each_segment_exactly_once() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A 1-segment schedule (600 deferred in 202606). + let inv = invoice( + &s, + "INV-RACE", + vec![recognized_item(600, 1, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let sched = schedule_id(&raw, &s, "INV-RACE").await; + + // Two runs racing the SAME period (each its own run_id ⇒ no run-dedup + // short-circuit). The single-active-run `coord` lease serialises them: one + // wins the lease and releases the segment, the other sees `LeaseHeld` and + // returns a no-op replay (it never starts a second pass). Both succeed; the + // per-segment RECOGNITION gate + `status = DONE` remain the ultimate + // at-most-once backstop, so exactly ONE credit lands either way. + let svc1 = run_svc(&provider, &harness); + let svc2 = run_svc(&provider, &harness); + let (ctx1, ctx2) = (ctx.clone(), ctx.clone()); + let (sc1, sc2) = (scope.clone(), scope.clone()); + let t = s.tenant; + let (r1, r2) = tokio::join!( + async move { svc1.trigger(&ctx1, &sc1, t, "202606", None).await }, + async move { svc2.trigger(&ctx2, &sc2, t, "202606", None).await }, + ); + r1.expect("run 1 ok"); + r2.expect("run 2 ok"); + + // Exactly one credit landed: CL drained once, Revenue == 600 (not 1200), + // recognized_minor == 600, exactly one release entry, segment DONE. + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(0), + "CL drained once" + ); + assert_eq!( + bal(&raw, &s, s.revenue).await, + Some(600), + "no double-credit" + ); + assert_eq!(recognized_minor(&raw, &s, &sched).await, Some(600)); + assert_eq!( + release_entry_count(&raw, &s, &sched, 1).await, + 1, + "exactly one release entry" + ); + assert_eq!( + segment_status(&raw, &s, &sched, 1).await.as_deref(), + Some("DONE") + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn queued_successor_drains_behind_its_predecessor_in_one_pass() { + // Ordering (§4.6) drain, in-pass: a previously out-of-order-parked QUEUED + // successor is re-enumerated by a run and releases only AFTER its + // lower-period predecessor commits DONE (the predecessor, being lower + // segment_no, is released first in the same ascending pass). Pins that a + // QUEUED segment is not stuck — it is picked up and drained behind its + // predecessor, never ahead of it. + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A 2-segment schedule: seg-1 in 202606 (predecessor), seg-2 in 202607. + open_period(&raw, &s, "202607").await; + let inv = invoice( + &s, + "INV-ORD", + vec![recognized_item(1000, 2, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let sched = schedule_id(&raw, &s, "INV-ORD").await; + + // Simulate an earlier out-of-order pass that parked seg-2 QUEUED (its + // predecessor seg-1 had not committed) WITHOUT touching seg-1 (left PENDING). + let repo = RecognitionRepo::new(provider.clone()); + repo.mark_segment_queued(&scope, s.tenant, &sched, 2) + .await + .expect("park seg-2 QUEUED"); + assert_eq!( + segment_status(&raw, &s, &sched, 2).await.as_deref(), + Some("QUEUED") + ); + + // Drive a run via the runner over seg-2 ALONE (the QUEUED successor) while + // seg-1 is still PENDING: the predecessor gate parks it (it stays QUEUED, it + // is NOT released out of order). One run pass, one run_id. + let runner = RecognitionRunner::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + ); + // `run_period("202607")` re-enumerates seg-2 (QUEUED) AND seg-1 (PENDING, + // 202606 <= 202607). seg-1 has no predecessor ⇒ releases; seg-2's predecessor + // seg-1 is then DONE ⇒ seg-2 drains. Both release in this single pass — the + // QUEUED successor is drained behind its predecessor, never ahead of it. + let summary = runner + .run_period(&ctx, &scope, s.tenant, "202607", Uuid::now_v7()) + .await + .expect("run drains in order"); + assert_eq!( + summary.released, 2, + "predecessor then successor, both released" + ); + assert_eq!( + summary.queued, 0, + "nothing left parked once the predecessor is DONE" + ); + + // Final state: both DONE, CL fully drained, Revenue fully recognized, and the + // releases landed in period order (seg-1's entry in 202606, seg-2's in 202607). + assert_eq!( + segment_status(&raw, &s, &sched, 1).await.as_deref(), + Some("DONE") + ); + assert_eq!( + segment_status(&raw, &s, &sched, 2).await.as_deref(), + Some("DONE") + ); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + Some(0), + "CL fully drained" + ); + assert_eq!(bal(&raw, &s, s.revenue).await, Some(1000)); + let seg2_period = scalar_i64( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_doc_type='RECOGNITION' \ + AND source_business_id='{sched}:2' AND period_id='202607'", + s.tenant + ), + ) + .await; + assert_eq!( + seg2_period, + Some(1), + "seg-2 released into its own period 202607" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn queued_successor_re_parks_when_predecessor_excluded_from_window() { + // The complement of the drain test: when a run's window includes a QUEUED + // successor but NOT its still-un-DONE predecessor (a narrower run target), + // the successor is re-parked QUEUED (the §4.6 gate holds) — never released + // ahead of its predecessor — and a LATER, wider run drains it. + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // seg-1 in 202606, seg-2 in 202607, seg-3 in 202608. Open all three periods. + open_period(&raw, &s, "202607").await; + open_period(&raw, &s, "202608").await; + let inv = invoice( + &s, + "INV-ORD2", + vec![recognized_item(900, 3, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let sched = schedule_id(&raw, &s, "INV-ORD2").await; + + let runner = RecognitionRunner::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + ); + + // Park seg-3 QUEUED, then run for window 202607 (seg-3 is 202608 > 202607, so + // OUT of the window — it cannot be re-enumerated this pass). seg-1 + seg-2 + // release in order; seg-3 stays QUEUED (not in window, not touched). + repo_park(&provider, &scope, s.tenant, &sched, 3).await; + let first = runner + .run_period(&ctx, &scope, s.tenant, "202607", Uuid::now_v7()) + .await + .expect("first window run"); + assert_eq!(first.released, 2, "seg-1 + seg-2 release in order"); + assert_eq!( + segment_status(&raw, &s, &sched, 3).await.as_deref(), + Some("QUEUED"), + "seg-3 untouched" + ); + + // Now widen the window to 202608: seg-3 (QUEUED) is re-enumerated, its + // predecessors (seg-1, seg-2) are DONE ⇒ it drains. + let second = runner + .run_period(&ctx, &scope, s.tenant, "202608", Uuid::now_v7()) + .await + .expect("widened run drains seg-3"); + assert_eq!(second.released, 1, "seg-3 drains"); + assert_eq!( + segment_status(&raw, &s, &sched, 3).await.as_deref(), + Some("DONE") + ); + assert_eq!( + recognized_minor(&raw, &s, &sched).await, + Some(900), + "all three recognized" + ); +} + +/// Park one segment QUEUED via the repo (a tiny helper to keep the test bodies +/// uncluttered). +async fn repo_park( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + schedule: &str, + segment_no: i32, +) { + RecognitionRepo::new(provider.clone()) + .mark_segment_queued(scope, tenant, schedule, segment_no) + .await + .expect("park segment QUEUED"); +} + +// ── Group I-rest: schedule GET (I2) + RecognitionWithoutInvoiceLink (I3) ────── + +/// A deferred `subscription` item identical to [`recognized_item`] but with NO +/// `invoice_item_ref` — exercises the §4.7 invoice-link guard (I3): a deferred +/// line that cannot resolve its Contract-liability anchor must be blocked with +/// [`DomainError::RecognitionWithoutInvoiceLink`] BEFORE the post. +fn recognized_item_no_link(amount: i64, periods: u32, first_period: &str) -> InvoiceItem { + InvoiceItem { + invoice_item_ref: None, + ..recognized_item(amount, periods, first_period, "unused") + } +} + +/// I2: after a deferred invoice post, `RecognitionRepo::read_schedule` + +/// `list_segments` (the exact reads the `GET /recognition-schedules/{id}` local +/// client runs) return the materialized schedule header + its segments ordered by +/// `segment_no`; a missing `schedule_id` yields `None` (the handler's 404 source). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn get_schedule_returns_header_and_segments_and_none_when_absent() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // A 2-segment straight-line schedule (1000 over 2 months from 202606). + open_period(&raw, &s, "202607").await; + let inv = invoice( + &s, + "INV-GET", + vec![recognized_item(1000, 2, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let sched = schedule_id(&raw, &s, "INV-GET").await; + + let repo = RecognitionRepo::new(provider.clone()); + + // The schedule header reads back with its build-time facts. + let schedule = repo + .read_schedule(&scope, s.tenant, &sched) + .await + .expect("read schedule") + .expect("the schedule exists"); + assert_eq!(schedule.status, "ACTIVE"); + assert_eq!(schedule.version, 0); + assert_eq!(schedule.revenue_stream, "subscription"); + assert_eq!(schedule.currency, "USD"); + assert_eq!(schedule.total_deferred_minor, 1000); + assert_eq!(schedule.recognized_minor, 0); + assert_eq!(schedule.source_invoice_id, "INV-GET"); + assert_eq!(schedule.source_invoice_item_ref, "item-1"); + + // Its segments come back ordered by segment_no (= period order), PENDING. + let segments = repo + .list_segments(&scope, s.tenant, &sched) + .await + .expect("list segments"); + assert_eq!(segments.len(), 2, "two straight-line segments"); + assert_eq!(segments[0].segment_no, 1); + assert_eq!(segments[0].period_id, "202606"); + assert_eq!(segments[0].amount_minor, 500); + assert_eq!(segments[0].status, "PENDING"); + assert_eq!(segments[1].segment_no, 2); + assert_eq!(segments[1].period_id, "202607"); + assert_eq!(segments[1].status, "PENDING"); + + // A missing schedule_id resolves to None — the handler's 404 source. + let missing = repo + .read_schedule(&scope, s.tenant, "does-not-exist") + .await + .expect("read absent schedule"); + assert!( + missing.is_none(), + "an unknown schedule_id yields None (→ 404)" + ); +} + +/// I3: a deferred recognition line with no resolvable `invoice_item_ref` is +/// blocked with [`DomainError::RecognitionWithoutInvoiceLink`] (wire +/// `RECOGNITION_WITHOUT_INVOICE_LINK`, 400) BEFORE the post — no orphan schedule +/// and no journal entry materialize. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn deferred_post_without_invoice_link_is_rejected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + let inv = invoice( + &s, + "INV-NOLINK", + vec![recognized_item_no_link(1000, 2, "202606")], + ); + let err = invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect_err("a deferred line without an invoice_item_ref must be blocked"); + assert!( + matches!(err, DomainError::RecognitionWithoutInvoiceLink(_)), + "expected RecognitionWithoutInvoiceLink, got {err:?}" + ); + + // Nothing materialized: no schedule, no CONTRACT_LIABILITY balance, no entry. + assert_eq!( + count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_recognition_schedule \ + WHERE tenant_id='{}' AND source_invoice_id='INV-NOLINK'", + s.tenant + ), + ) + .await, + 0, + "the blocked post leaves no orphan schedule" + ); + assert_eq!( + bal(&raw, &s, s.contract_liability).await, + None, + "no CONTRACT_LIABILITY balance was projected" + ); + assert_eq!( + count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.ledger_journal_entry \ + WHERE tenant_id='{}' AND source_business_id='INV-NOLINK'", + s.tenant + ), + ) + .await, + 0, + "the blocked post wrote no journal entry" + ); +} + +// ── Fix 1: schedule ACTIVE → COMPLETED on full drain (design §4.6) ──────────── + +/// Build a `NewSchedule` for a given business key (used to probe the partial +/// `UNIQUE … WHERE status='ACTIVE'` one-live slot directly via the repo). +fn new_schedule(s: &Seller, schedule_id: &str, invoice_id: &str, item_ref: &str) -> NewSchedule { + NewSchedule { + tenant_id: s.tenant, + schedule_id: schedule_id.to_owned(), + payer_tenant_id: s.payer, + source_invoice_id: invoice_id.to_owned(), + source_invoice_item_ref: item_ref.to_owned(), + po_allocation_group: Some("grp-1".to_owned()), + subscription_ref: Some("sub-1".to_owned()), + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + total_deferred_minor: 100, + policy_ref: "policy.sl.v1".to_owned(), + ssp_snapshot_ref: None, + vc_estimate_ref: None, + vc_method_ref: None, + } +} + +/// Insert a fresh ACTIVE schedule via the real repo path inside one txn, mapping +/// a repo error into a `DbError` so the result is observable (the partial UNIQUE +/// collision surfaces as `Err`). Mirrors the in-txn probe style of +/// `over_recognition_blocked_at_per_schedule_check_with_sibling_positive`. +async fn try_insert_active_schedule( + provider: &DBProvider, + scope: &AccessScope, + schedule: NewSchedule, +) -> Result<(), DbError> { + let schedule = Arc::new(schedule); + provider + .transaction(|txn| { + let scope = scope.clone(); + let schedule = Arc::clone(&schedule); + Box::pin(async move { + RecognitionRepo::insert_schedule(txn, &scope, schedule.as_ref()) + .await + .map_err(|e: RepoError| DbError::Sea(sea_orm::DbErr::Custom(e.to_string()))) + }) + }) + .await +} + +/// A fully-recognized schedule transitions `ACTIVE → COMPLETED` (terminal), which +/// frees the partial `UNIQUE (…, revenue_stream) WHERE status='ACTIVE'` one-live +/// slot (a fresh ACTIVE schedule for the SAME business key then inserts); a +/// partially-recognized schedule stays `ACTIVE` and keeps the slot occupied. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn drained_schedule_completes_and_frees_the_one_live_slot() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let harness = MetricsHarness::new(); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // --- Fully-drained schedule → COMPLETED --- + // A 1-segment schedule (600 deferred in 202606); release it fully. + let inv = invoice( + &s, + "INV-DONE", + vec![recognized_item(600, 1, "202606", "item-1")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("deferred invoice posts"); + let sched = schedule_id(&raw, &s, "INV-DONE").await; + assert_eq!( + schedule_status_of(&raw, &s, &sched).await.as_deref(), + Some("ACTIVE"), + "ACTIVE before release" + ); + + run_svc(&provider, &harness) + .trigger(&ctx, &scope, s.tenant, "202606", None) + .await + .expect("release drains the single segment"); + + // The single segment is DONE, recognized == total ⇒ the schedule COMPLETED. + assert_eq!( + recognized_minor(&raw, &s, &sched).await, + Some(600), + "fully recognized" + ); + assert_eq!( + segment_status(&raw, &s, &sched, 1).await.as_deref(), + Some("DONE") + ); + assert_eq!( + schedule_status_of(&raw, &s, &sched).await.as_deref(), + Some("COMPLETED"), + "a fully-drained schedule reaches COMPLETED (design §4.6)" + ); + + // The one-live slot is freed: a fresh ACTIVE schedule for the SAME business + // key (invoice INV-DONE / item-1 / subscription) now inserts without + // colliding on the partial UNIQUE (the old is COMPLETED, not ACTIVE). + try_insert_active_schedule( + &provider, + &scope, + new_schedule(&s, &Uuid::now_v7().to_string(), "INV-DONE", "item-1"), + ) + .await + .expect("a fresh ACTIVE schedule is admitted once the predecessor COMPLETED"); + + // --- Partially-recognized schedule stays ACTIVE + keeps the slot --- + // A 2-segment schedule (1000 over 202606 + 202607); release ONLY period 1. + open_period(&raw, &s, "202607").await; + let inv2 = invoice( + &s, + "INV-PART", + vec![recognized_item(1000, 2, "202606", "item-9")], + ); + invoice_svc(&provider, &harness) + .post_invoice(&ctx, &scope, &inv2, true) + .await + .expect("deferred invoice posts"); + let sched2 = schedule_id(&raw, &s, "INV-PART").await; + run_svc(&provider, &harness) + .trigger(&ctx, &scope, s.tenant, "202606", None) + .await + .expect("release period 1 only"); + + // seg-1 DONE, seg-2 still PENDING, recognized (500) < total (1000) ⇒ ACTIVE. + assert_eq!( + segment_status(&raw, &s, &sched2, 1).await.as_deref(), + Some("DONE") + ); + assert_eq!( + segment_status(&raw, &s, &sched2, 2).await.as_deref(), + Some("PENDING") + ); + assert_eq!( + recognized_minor(&raw, &s, &sched2).await, + Some(500), + "partially recognized" + ); + assert_eq!( + schedule_status_of(&raw, &s, &sched2).await.as_deref(), + Some("ACTIVE"), + "a partially-recognized schedule stays ACTIVE (not COMPLETED)" + ); + + // Its one-live slot is still OCCUPIED: a second ACTIVE schedule for the SAME + // business key collides on the partial UNIQUE. + let collide = try_insert_active_schedule( + &provider, + &scope, + new_schedule(&s, &Uuid::now_v7().to_string(), "INV-PART", "item-9"), + ) + .await; + assert!( + collide.is_err(), + "a second live schedule for an ACTIVE business key must collide on the one-live UNIQUE" + ); +} + +// ── Fix 5: §4.7 invoice-item link re-asserted at run time ───────────────────── diff --git a/gears/bss/ledger/ledger/tests/postgres_reconciliation.rs b/gears/bss/ledger/ledger/tests/postgres_reconciliation.rs new file mode 100644 index 000000000..ff4f5b14f --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_reconciliation.rs @@ -0,0 +1,753 @@ +//! Postgres-only integration tests for the Slice 7 Phase 3 reconciliation framework and +//! control-feed close gate (design §4.3 / §4.5). K1: an AR↔derived variance opens a +//! `RECON_MISMATCH` and blocks close. K2: an invoice-completeness gap opens a +//! `MISSED_POSTING` that blocks close, which the upstream's idempotent re-post then +//! auto-clears. K3: a Payments↔PSP divergence opens a `PSP_VARIANCE`. K4: the +//! bill-run-finished close gate (un-asserted blocks, asserted passes). K5: inert until +//! the feed lands. Control feeds are exercised through the real in-process store (the +//! push → framework / close-gate read path). Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_reconciliation -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use bss_ledger::config::ReconConfig; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::ports::metrics::{LedgerMetricsPort, NoopLedgerMetrics}; +use bss_ledger::infra::control_feed::InProcessControlFeeds; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::exception::ExceptionRouter; +use bss_ledger::infra::period_close::{CloseControlFeeds, PeriodCloseService}; +use bss_ledger::infra::reconciliation::{ + CHECK_AR_DERIVED, CHECK_INVOICE_COMPLETENESS, CHECK_PAYMENTS_PSP, ReconciliationFramework, +}; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger_sdk::{ + BillRunFinishedV1, IssuedInvoiceManifest, IssuedInvoiceManifestV1, PspSettlementFeedV1, + PspSettlementReport, UnconfiguredBillRunFinishedV1, UnconfiguredIssuedInvoiceManifestV1, + UnconfiguredPspSettlementFeedV1, +}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +const PERIOD: &str = "202606"; +/// A second fiscal period, for the period-scoped PSP recon test (C2). +const PERIOD2: &str = "202607"; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +/// Boot + migrate; seed ONE OPEN fiscal period for `tenant` (LE = tenant). Returns the +/// raw conn, the provider, and the tenant id. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Uuid) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) \ + VALUES ('{tenant}','{tenant}','{PERIOD}','UTC','OPEN')" + ))) + .await + .unwrap(); + (raw, provider, tenant) +} + +/// Seed one AR chart-of-accounts row (normal side DR) and return its `account_id`. +/// The posted-invoice lines net to zero on this account so the books stay tie-out-clean +/// (the completeness test must isolate `MISSED_POSTING` from the AR↔derived tie-out). +async fn seed_ar_account(raw: &DatabaseConnection, tenant: Uuid) -> Uuid { + let account = Uuid::now_v7(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_tenant_account \ + (account_id, tenant_id, legal_entity_id, account_class, currency, normal_side, \ + may_go_negative, lifecycle_state) \ + VALUES ('{account}','{tenant}','{tenant}','AR','USD','DR', false, 'OPEN')" + ))) + .await + .unwrap(); + account +} + +/// Seed a posted `INVOICE_POST` journal entry for `invoice_id` (the completeness check +/// reads the header's `source_business_id`). The entry carries two net-zero AR lines +/// (`DR 1000 / CR 1000` on the same `account`) so it is balanced + non-empty (the +/// `check_entry_balanced` trigger) yet tie-out-neutral (computed 0 = cached 0). The +/// `rounding_evidence`/`created_seq` columns default. +async fn seed_invoice_post( + raw: &DatabaseConnection, + tenant: Uuid, + account: Uuid, + invoice_id: &str, +) { + use sea_orm::TransactionTrait; + let entry_id = Uuid::now_v7(); + // The header + both lines MUST commit in ONE transaction: `trg_journal_entry_balanced` + // is `DEFERRABLE INITIALLY DEFERRED`, so it checks balanced + non-empty only at COMMIT. + // Inserting the header on its own connection autocommit would commit an empty entry and + // trip `LEDGER_ENTRY_EMPTY`. + let txn = raw.begin().await.unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, \ + source_doc_type, source_business_id, posted_at_utc, effective_at, \ + origin, posted_by_actor_id, correlation_id) \ + VALUES ('{entry_id}','{tenant}','{tenant}','{PERIOD}','USD', \ + 'INVOICE_POST','{invoice_id}', now(), DATE '2026-06-01', \ + 'SYSTEM','{tenant}','{tenant}')" + ))) + .await + .unwrap(); + for side in ["DR", "CR"] { + let line_id = Uuid::now_v7(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, \ + account_class, side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{line_id}','{entry_id}','{tenant}','{PERIOD}','{tenant}','{account}', \ + 'AR','{side}',1000,'USD',2,'RESOLVED')" + ))) + .await + .unwrap(); + } + txn.commit().await.unwrap(); +} + +/// Seed a posted `PAYMENT_SETTLE` entry in `period` with no fee, so it nets +/// `DR CASH_CLEARING gross / CR UNALLOCATED gross` (balanced, non-empty). The +/// period-scoped PSP recon (C2) sums the `CR UNALLOCATED` leg of these entries +/// for the period; settling into two periods exercises the period boundary. +async fn seed_payment_settle( + raw: &DatabaseConnection, + tenant: Uuid, + period: &str, + payment_id: &str, + gross: i64, +) { + use sea_orm::TransactionTrait; + let entry_id = Uuid::now_v7(); + let txn = raw.begin().await.unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, \ + source_doc_type, source_business_id, posted_at_utc, effective_at, \ + origin, posted_by_actor_id, correlation_id) \ + VALUES ('{entry_id}','{tenant}','{tenant}','{period}','USD', \ + 'PAYMENT_SETTLE','{payment_id}', now(), DATE '2026-06-01', \ + 'SYSTEM','{tenant}','{tenant}')" + ))) + .await + .unwrap(); + for (class, side) in [("CASH_CLEARING", "DR"), ("UNALLOCATED", "CR")] { + let line_id = Uuid::now_v7(); + let nil = Uuid::nil(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, \ + account_class, side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{line_id}','{entry_id}','{tenant}','{period}','{tenant}','{nil}', \ + '{class}','{side}',{gross},'USD',2,'RESOLVED')" + ))) + .await + .unwrap(); + } + txn.commit().await.unwrap(); +} + +/// Count exception rows for `(tenant, type)` in a given status. +async fn count_exceptions( + raw: &DatabaseConnection, + tenant: Uuid, + exception_type: &str, + status: &str, +) -> i64 { + let row = raw + .query_one(pg(format!( + "SELECT count(*) AS c FROM bss.ledger_exception_queue \ + WHERE tenant_id='{tenant}' AND exception_type='{exception_type}' AND status='{status}'" + ))) + .await + .unwrap() + .expect("count row"); + row.try_get::("", "c").unwrap() +} + +/// Count `reconciliation_run` rows for `(tenant, check_type)`. +async fn count_runs(raw: &DatabaseConnection, tenant: Uuid, check_type: &str) -> i64 { + let row = raw + .query_one(pg(format!( + "SELECT count(*) AS c FROM bss.ledger_reconciliation_run \ + WHERE tenant_id='{tenant}' AND check_type='{check_type}'" + ))) + .await + .unwrap() + .expect("count row"); + row.try_get::("", "c").unwrap() +} + +/// The latest `within_tolerance` flag for `(tenant, check_type)`. +async fn latest_within_tolerance(raw: &DatabaseConnection, tenant: Uuid, check_type: &str) -> bool { + raw.query_one(pg(format!( + "SELECT within_tolerance FROM bss.ledger_reconciliation_run \ + WHERE tenant_id='{tenant}' AND check_type='{check_type}' ORDER BY at_utc DESC LIMIT 1" + ))) + .await + .unwrap() + .expect("a run row") + .try_get::("", "within_tolerance") + .unwrap() +} + +fn noop_metrics() -> Arc { + Arc::new(NoopLedgerMetrics) +} + +/// Build a framework over the in-process control-feed store (the push → read path). +#[allow( + clippy::needless_pass_by_value, + reason = "test helper — clones the provider for the engine and moves it into the exception router" +)] +fn framework( + provider: DBProvider, + feeds: Arc, + config: ReconConfig, +) -> ReconciliationFramework { + ReconciliationFramework::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + noop_metrics(), + ExceptionRouter::shared(provider), + Arc::clone(&feeds) as Arc, + Arc::clone(&feeds) as Arc, + config, + ) +} + +fn close_service(provider: DBProvider, control: CloseControlFeeds) -> PeriodCloseService { + PeriodCloseService::new( + provider, + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new()), + ) + .with_control_feeds(control) +} + +async fn boot(url: &str) -> (DatabaseConnection, DBProvider, Uuid) { + setup(url).await +} + +/// K1 — an AR↔derived tie-out variance (a stray balance-cache grain) opens a +/// `RECON_MISMATCH` (out of tolerance) and blocks period close. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn k1_ar_variance_opens_recon_mismatch_and_blocks_close() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, tenant) = boot(&url).await; + + // A stray `account_balance` cache row with no journal → the recompute disagrees + // with the cache (computed 0 ≠ cached 50_000): an out-of-tolerance tie-out variance. + let account = Uuid::now_v7(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_account_balance \ + (tenant_id, account_id, currency, account_class, normal_side, balance_minor, version) \ + VALUES ('{tenant}','{account}','USD','AR','DR', 50000, 1)" + ))) + .await + .unwrap(); + + let feeds = Arc::new(InProcessControlFeeds::new()); + let fw = framework(provider.clone(), feeds, ReconConfig::default()); + fw.run_check( + &SecurityContext::anonymous(), + tenant, + PERIOD, + CHECK_AR_DERIVED, + ) + .await + .expect("AR_DERIVED check runs"); + + assert_eq!( + count_runs(&raw, tenant, CHECK_AR_DERIVED).await, + 1, + "one AR run written" + ); + assert!( + !latest_within_tolerance(&raw, tenant, CHECK_AR_DERIVED).await, + "the 50_000 variance is out of tolerance" + ); + assert_eq!( + count_exceptions(&raw, tenant, "RECON_MISMATCH", "OPEN").await, + 1, + "an out-of-tolerance AR run opens exactly one RECON_MISMATCH" + ); + + // Close is blocked (both by the tie-out variance directly and by the OPEN exception). + let close = close_service(provider, CloseControlFeeds::inert()); + let err = close + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + PERIOD.to_owned(), + ) + .await + .expect_err("an AR variance blocks close"); + assert!( + matches!(err, DomainError::PeriodCloseBlocked(_)), + "got {err:?}" + ); +} + +/// K2 — an issued invoice with no committed `INVOICE_POST` opens a `MISSED_POSTING` and +/// blocks close; the upstream's idempotent re-post auto-clears it and the period closes. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn k2_missed_posting_blocks_then_idempotent_repost_clears() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, tenant) = boot(&url).await; + + // Manifest issued {inv-1, inv-2}; only inv-1 is posted ⇒ inv-2 is a missed posting. + let account = seed_ar_account(&raw, tenant).await; + seed_invoice_post(&raw, tenant, account, "inv-1").await; + let feeds = Arc::new(InProcessControlFeeds::new()); + feeds.ingest_manifest( + tenant, + PERIOD, + IssuedInvoiceManifest { + invoice_ids: vec!["inv-1".to_owned(), "inv-2".to_owned()], + count: 2, + gross_total_minor: 2000, + }, + ); + let config = ReconConfig { + manifest_enforcement: true, + ..ReconConfig::default() + }; + let fw = framework(provider.clone(), Arc::clone(&feeds), config); + + fw.run_check( + &SecurityContext::anonymous(), + tenant, + PERIOD, + CHECK_INVOICE_COMPLETENESS, + ) + .await + .expect("completeness check runs"); + assert_eq!( + count_exceptions(&raw, tenant, "MISSED_POSTING", "OPEN").await, + 1, + "the unposted inv-2 opens one MISSED_POSTING" + ); + + // Close is blocked (manifest enforcement on + the missing posting). + let control = CloseControlFeeds { + manifest_feed: Arc::clone(&feeds) as Arc, + bill_run_feed: Arc::new(UnconfiguredBillRunFinishedV1), + manifest_enforcement: true, + bill_run_enforcement: false, + fx_revaluation_enforcement: false, + }; + let close = close_service(provider.clone(), control); + let err = close + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + PERIOD.to_owned(), + ) + .await + .expect_err("a missed posting blocks close"); + assert!( + matches!(err, DomainError::PeriodCloseBlocked(_)), + "got {err:?}" + ); + + // The upstream's idempotent re-post lands inv-2; the re-run auto-resolves the + // MISSED_POSTING and the period now closes cleanly. + seed_invoice_post(&raw, tenant, account, "inv-2").await; + fw.run_check( + &SecurityContext::anonymous(), + tenant, + PERIOD, + CHECK_INVOICE_COMPLETENESS, + ) + .await + .expect("completeness re-run"); + assert_eq!( + count_exceptions(&raw, tenant, "MISSED_POSTING", "OPEN").await, + 0, + "the re-post auto-resolves the MISSED_POSTING" + ); + assert_eq!( + count_exceptions(&raw, tenant, "MISSED_POSTING", "RESOLVED").await, + 1, + "the cleared row is RESOLVED, not deleted" + ); + let outcome = close + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + PERIOD.to_owned(), + ) + .await + .expect("a complete period closes"); + assert!( + !outcome.already_closed, + "the period closes on the re-attempt" + ); +} + +/// K3 — a Payments↔PSP settlement divergence (the PSP reports settled the ledger has not +/// recorded) opens a `PSP_VARIANCE`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn k3_psp_variance_opens_exception() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, tenant) = boot(&url).await; + + // No ledger settlements (settled 0) but the PSP reports 100 settled ⇒ variance. + let feeds = Arc::new(InProcessControlFeeds::new()); + feeds.ingest_psp_report( + tenant, + PERIOD, + PspSettlementReport { + report_id: "rpt-1".to_owned(), + settled_minor: 100, + currency: "USD".to_owned(), + }, + ); + let fw = framework(provider, Arc::clone(&feeds), ReconConfig::default()); + fw.run_check( + &SecurityContext::anonymous(), + tenant, + PERIOD, + CHECK_PAYMENTS_PSP, + ) + .await + .expect("PSP check runs"); + + assert_eq!( + count_runs(&raw, tenant, CHECK_PAYMENTS_PSP).await, + 1, + "one PSP run written" + ); + assert!( + !latest_within_tolerance(&raw, tenant, CHECK_PAYMENTS_PSP).await, + "ledger 0 vs PSP 100 is out of tolerance" + ); + assert_eq!( + count_exceptions(&raw, tenant, "PSP_VARIANCE", "OPEN").await, + 1, + "a PSP divergence opens one PSP_VARIANCE" + ); +} + +/// K6 (C2) — the Payments↔PSP ledger total is PERIOD-SCOPED: a settle in a PRIOR +/// period must not inflate the current period's recon. Settle 100 in period 1 and +/// 60 in period 2, push a PSP report of 60 for period 2, and assert period 2 +/// reconciles within tolerance. The pre-C2 lifetime `payment_settlement.settled_minor` +/// sum compared 160 vs 60 and opened a spurious `PSP_VARIANCE`; the period-scoped +/// journal sum compares 60 vs 60. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn k6_psp_check_is_period_scoped() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, tenant) = boot(&url).await; + + // A second OPEN fiscal period alongside the default one. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) \ + VALUES ('{tenant}','{tenant}','{PERIOD2}','UTC','OPEN')" + ))) + .await + .unwrap(); + + // Settle 100 in period 1, 60 in period 2. + seed_payment_settle(&raw, tenant, PERIOD, "PAY-P1", 100).await; + seed_payment_settle(&raw, tenant, PERIOD2, "PAY-P2", 60).await; + + // The PSP reports 60 for period 2 — matching period 2's settlements only. + let feeds = Arc::new(InProcessControlFeeds::new()); + feeds.ingest_psp_report( + tenant, + PERIOD2, + PspSettlementReport { + report_id: "rpt-p2".to_owned(), + settled_minor: 60, + currency: "USD".to_owned(), + }, + ); + let fw = framework(provider, Arc::clone(&feeds), ReconConfig::default()); + fw.run_check( + &SecurityContext::anonymous(), + tenant, + PERIOD2, + CHECK_PAYMENTS_PSP, + ) + .await + .expect("PSP check runs"); + + assert!( + latest_within_tolerance(&raw, tenant, CHECK_PAYMENTS_PSP).await, + "period-2 ledger settled (60) must match PSP 60 — period-scoped, not the 160 lifetime sum" + ); + assert_eq!( + count_exceptions(&raw, tenant, "PSP_VARIANCE", "OPEN").await, + 0, + "no spurious PSP_VARIANCE once the recon is period-scoped" + ); +} + +/// K7 (C3) — the Mode-B FX-revaluation completeness gate: with enforcement ON and +/// NO COMPLETE marker for the period, close BLOCKS; once the period-end run records +/// the marker, close proceeds. Clean empty books, so only the fx-reval gate is under +/// test. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn k7_fx_revaluation_gate_blocks_until_marker() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, tenant) = boot(&url).await; + + let control = || CloseControlFeeds { + manifest_feed: Arc::new(UnconfiguredIssuedInvoiceManifestV1), + bill_run_feed: Arc::new(UnconfiguredBillRunFinishedV1), + manifest_enforcement: false, + bill_run_enforcement: false, + fx_revaluation_enforcement: true, + }; + + // No COMPLETE marker for the period ⇒ close blocks. + let err = close_service(provider.clone(), control()) + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + PERIOD.to_owned(), + ) + .await + .expect_err("a missing FX-revaluation marker blocks close"); + assert!( + matches!(err, DomainError::PeriodCloseBlocked(_)), + "got {err:?}" + ); + + // The period-end revaluation records the COMPLETE marker ⇒ the gate passes. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fx_revaluation_run \ + (tenant_id, period_id, scope, status, completed_at_utc) \ + VALUES ('{tenant}','{PERIOD}','PERIOD','COMPLETE', now())" + ))) + .await + .unwrap(); + let outcome = close_service(provider, control()) + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + PERIOD.to_owned(), + ) + .await + .expect("a COMPLETE FX-revaluation marker lets close proceed"); + assert!( + !outcome.already_closed, + "the period closes once the marker is present" + ); +} + +/// K4 — the bill-run-finished close gate blocks until the signal is asserted (flag ON); +/// with `Some(true)` asserted the close passes. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn k4_bill_run_gate_blocks_until_asserted() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, tenant) = boot(&url).await; + + // Empty books (clean tie-out). Bill-run enforcement ON, but nothing asserted ⇒ block. + let feeds = Arc::new(InProcessControlFeeds::new()); + let control_absent = CloseControlFeeds { + manifest_feed: Arc::new(UnconfiguredIssuedInvoiceManifestV1), + bill_run_feed: Arc::clone(&feeds) as Arc, + manifest_enforcement: false, + bill_run_enforcement: true, + fx_revaluation_enforcement: false, + }; + let close_absent = close_service(provider.clone(), control_absent); + let err = close_absent + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + PERIOD.to_owned(), + ) + .await + .expect_err("an un-asserted bill run blocks close"); + assert!( + matches!(err, DomainError::PeriodCloseBlocked(_)), + "got {err:?}" + ); + + // Orchestration asserts the bill run finished ⇒ the gate passes and the period closes. + feeds.ingest_bill_run_finished(tenant, PERIOD, true); + let control_done = CloseControlFeeds { + manifest_feed: Arc::new(UnconfiguredIssuedInvoiceManifestV1), + bill_run_feed: Arc::clone(&feeds) as Arc, + manifest_enforcement: false, + bill_run_enforcement: true, + fx_revaluation_enforcement: false, + }; + let close_done = close_service(provider, control_done); + let outcome = close_done + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + PERIOD.to_owned(), + ) + .await + .expect("an asserted bill run lets close proceed"); + assert!( + !outcome.already_closed, + "the period closes once the bill run is asserted" + ); + let status = raw + .query_one(pg(format!( + "SELECT status FROM bss.ledger_fiscal_period \ + WHERE tenant_id='{tenant}' AND period_id='{PERIOD}'" + ))) + .await + .unwrap() + .expect("period row") + .try_get::("", "status") + .unwrap(); + assert_eq!(status, "CLOSED"); +} + +/// K5 — inert-until-the-feed-lands: with no manifest configured (the default +/// `Unconfigured…` / empty store) the invoice-completeness check is inert (no run, no +/// exception) and the close gate does not block on it (enforcement OFF, the default). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn k5_inert_until_feed_lands() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, tenant) = boot(&url).await; + + // Empty control store (nothing pushed) + default config (enforcement OFF). + let feeds = Arc::new(InProcessControlFeeds::new()); + let fw = framework(provider.clone(), Arc::clone(&feeds), ReconConfig::default()); + + // The completeness check is inert: `run_check` rejects (nothing to reconcile), no + // run row is written, and no MISSED_POSTING is opened. + let err = fw + .run_check( + &SecurityContext::anonymous(), + tenant, + PERIOD, + CHECK_INVOICE_COMPLETENESS, + ) + .await + .expect_err("an unconfigured manifest is inert"); + assert!(matches!(err, DomainError::InvalidRequest(_)), "got {err:?}"); + assert_eq!( + count_runs(&raw, tenant, CHECK_INVOICE_COMPLETENESS).await, + 0, + "no run written" + ); + assert_eq!( + count_exceptions(&raw, tenant, "MISSED_POSTING", "OPEN").await, + 0, + "no exception" + ); + + // Close is NOT blocked by the (inert) completeness gate — enforcement OFF + clean books. + let close = close_service( + provider, + CloseControlFeeds { + manifest_feed: Arc::clone(&feeds) as Arc, + bill_run_feed: Arc::new(UnconfiguredBillRunFinishedV1), + manifest_enforcement: false, + bill_run_enforcement: false, + fx_revaluation_enforcement: false, + }, + ); + let outcome = close + .close( + &SecurityContext::anonymous(), + tenant, + tenant, + PERIOD.to_owned(), + ) + .await + .expect("an inert completeness gate does not block close"); + assert!(!outcome.already_closed, "the period closes cleanly"); +} + +/// A configured PSP feed returning `None` (Unconfigured default) leaves the Payments↔PSP +/// check inert (`run_check` rejects, no run) — the same inert-until-live contract. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn k5b_unconfigured_psp_check_is_inert() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, tenant) = boot(&url).await; + + // A framework whose PSP port is the fail-safe Unconfigured default (always None). + let fw = ReconciliationFramework::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + noop_metrics(), + ExceptionRouter::shared(provider), + Arc::new(UnconfiguredIssuedInvoiceManifestV1), + Arc::new(UnconfiguredPspSettlementFeedV1), + ReconConfig::default(), + ); + let err = fw + .run_check( + &SecurityContext::anonymous(), + tenant, + PERIOD, + CHECK_PAYMENTS_PSP, + ) + .await + .expect_err("an unconfigured PSP feed is inert"); + assert!(matches!(err, DomainError::InvalidRequest(_)), "got {err:?}"); + assert_eq!( + count_runs(&raw, tenant, CHECK_PAYMENTS_PSP).await, + 0, + "no run written" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_reference.rs b/gears/bss/ledger/ledger/tests/postgres_reference.rs new file mode 100644 index 000000000..fec9192f6 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_reference.rs @@ -0,0 +1,118 @@ +//! Postgres-only integration tests for the idempotency + reference tables. +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. +//! +//! Covers: (a) `idempotency_dedup` PK rejects a duplicate +//! `(tenant, flow, business_id)`; (b) `currency_scale_registry` round-trips; +//! (c) `tenant_account` expression-unique index rejects a duplicate CoA row. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown +)] + +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use uuid::Uuid; + +use bss_ledger::infra::storage::migrations::Migrator; + +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + DatabaseConnection, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let db = Database::connect(&url).await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + (container, db) +} + +fn exec(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn idempotency_dedup_rejects_duplicate_key() { + let (_c, db) = boot().await; + let tenant_id = Uuid::new_v4(); + + let insert = |hash: &str| { + exec(format!( + "INSERT INTO bss.ledger_idempotency_dedup + (tenant_id, flow, business_id, payload_hash, status) + VALUES ('{tenant_id}', 'INVOICE_POST', 'biz-1', '{hash}', 'COMMITTED')" + )) + }; + + db.execute(insert("h1")).await.expect("first insert ok"); + let err = db + .execute(insert("h2")) + .await + .expect_err("duplicate (tenant, flow, business_id) must be rejected"); + assert!( + err.to_string().to_lowercase().contains("duplicate") + || err.to_string().contains("idempotency_dedup_pkey"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn currency_scale_registry_round_trips() { + let (_c, db) = boot().await; + let tenant_id = Uuid::new_v4(); + + db.execute(exec(format!( + "INSERT INTO bss.ledger_currency_scale_registry (tenant_id, currency, minor_units, source) + VALUES ('{tenant_id}', 'USD', 2, 'ISO4217')" + ))) + .await + .unwrap(); + + let row = db + .query_one(exec(format!( + "SELECT minor_units FROM bss.ledger_currency_scale_registry + WHERE tenant_id = '{tenant_id}' AND currency = 'USD'" + ))) + .await + .unwrap() + .expect("row must exist"); + let minor_units: i16 = row.try_get("", "minor_units").unwrap(); + assert_eq!(minor_units, 2); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn tenant_account_coa_unique_rejects_duplicate() { + let (_c, db) = boot().await; + let tenant_id = Uuid::new_v4(); + let legal_entity_id = Uuid::new_v4(); + + let insert = || { + let account_id = Uuid::new_v4(); + exec(format!( + "INSERT INTO bss.ledger_tenant_account + (account_id, tenant_id, legal_entity_id, account_class, currency, normal_side) + VALUES ('{account_id}', '{tenant_id}', '{legal_entity_id}', 'AR', 'USD', 'DR')" + )) + }; + + db.execute(insert()).await.expect("first CoA row ok"); + let err = db + .execute(insert()) + .await + .expect_err("duplicate CoA grain must be rejected by the expression-unique index"); + assert!( + err.to_string().to_lowercase().contains("duplicate") + || err.to_string().contains("uq_tenant_account_coa"), + "unexpected error: {err}" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_refund.rs b/gears/bss/ledger/ledger/tests/postgres_refund.rs new file mode 100644 index 000000000..763997a7e --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_refund.rs @@ -0,0 +1,2635 @@ +//! Postgres-only integration: the Slice-3 Phase-2 `RefundHandler` (Group B), driven +//! through the REAL foundation engine (`PostingService` + the in-txn +//! `RefundPostSidecar`) against a real settled payment posted by +//! `SettlementService` (the receipt the refund unwinds). Asserts the design §4.4 / +//! §8 durable effects: +//! +//! - **Pattern A two-stage** (`A_UNALLOCATED`): stage-1 `initiated` posts DR +//! `UNALLOCATED` · CR `REFUND_CLEARING` (the clearing balance opens); stage-2 +//! `confirmed` posts DR `REFUND_CLEARING` · CR `CASH_CLEARING` — and the +//! `REFUND_CLEARING` balance **drains back to zero**; +//! - **Pattern B two-stage** (`B_RESTORE_AR`): stage-1 posts DR `AR` (re-opens the +//! receivable) · CR `REFUND_CLEARING`; stage-2 drains `REFUND_CLEARING` → +//! `CASH_CLEARING`; +//! - a refund **NEVER touches `CONTRACT_LIABILITY`** (no such account balance row is +//! ever created); +//! - the `refund` record rows persist (one per phase); +//! - a refund against a **payment with no settlement** is `RefundOriginNotFound` +//! (404, no books effect); a **currency mismatch** is `CurrencyMismatch`; +//! - a **replay** of the same `(psp_refund_id, phase)` is idempotent (no second +//! books effect). +//! +//! `RefundHandler::new` is `pub`, so this out-of-crate test drives it + +//! `SettlementService` directly (mirrors `postgres_credit_note.rs` / +//! `postgres_chargebacks.rs`). Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::similar_names, + clippy::too_many_arguments +)] + +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; + +use bss_ledger::domain::adjustment::refund::{ + RefundDirection, RefundPattern, RefundPhase, RefundRequest, +}; +use bss_ledger::domain::approval::intent::ApprovalIntent; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::adjustment::refund_service::{RefundHandler, RefundOutcome}; +use bss_ledger::infra::approval::service::{ApprovalExecutor, ApprovalService}; +use bss_ledger::infra::audit::secured_audit_sink::{AuditEventType, SecuredAuditSink}; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::{DateTime, Datelike, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::{AccessScope, DbTx}; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Provisioned seller for the refund flow: the chart classes a refund touches — +/// `UNALLOCATED` (the settle pool, Pattern A debit), `AR` (Pattern B restore), +/// `REFUND_CLEARING` (the two-stage clearing), `CASH_CLEARING` (the disbursement) — +/// plus `PSP_FEE_EXPENSE` for the settle fee leg. +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + refund_clearing: Uuid, + ar: Uuid, + psp_fee: Uuid, + suspense: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot, migrate, seed USD@2 + an OPEN period (current month) + the refund chart. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + refund_clearing: Uuid::now_v7(), + ar: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + suspense: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.refund_clearing, + AccountClass::RefundClearing, + Side::Credit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + // The unknown_final park target — SUSPENSE holds the stuck clearing amount + // (credit-normal, mirroring the REFUND_CLEARING obligation it drains) until + // a terminal disposition reconciles it (Slice 7). + account(s.tenant, s.suspense, AccountClass::Suspense, Side::Credit), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn refund_handler(provider: &DBProvider) -> RefundHandler { + RefundHandler::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())) +} + +/// Settle `gross` (fee 0) for `payment_id` — seeds the `payment_settlement` row +/// (`settled_minor = gross`) the refund resolves as its origin. +async fn settle(provider: &DBProvider, s: &Seller, payment_id: &str, gross: i64) { + settle_svc(provider) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle must succeed"); +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +/// `refund` row count for a `(psp_refund_id, phase)`. +async fn refund_rows(raw: &DatabaseConnection, s: &Seller, psp: &str, phase: &str) -> Option { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_refund \ + WHERE tenant_id='{}' AND psp_refund_id='{psp}' AND phase='{phase}'", + s.tenant + ), + ) + .await +} + +/// `true` iff ANY CONTRACT_LIABILITY balance row exists for the seller — a refund +/// must never create one (design §4.4). +async fn any_contract_liability(raw: &DatabaseConnection, s: &Seller) -> i64 { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_account_balance b \ + JOIN bss.ledger_tenant_account a ON a.account_id = b.account_id \ + WHERE b.tenant_id='{}' AND a.account_class='CONTRACT_LIABILITY'", + s.tenant + ), + ) + .await + .unwrap_or(0) +} + +/// Read a `payment_settlement` counter column for `(tenant, payment_id)` — the cap +/// basis Group C maintains. +async fn settlement_counter( + raw: &DatabaseConnection, + s: &Seller, + payment_id: &str, + col: &str, +) -> Option { + scalar_i64( + raw, + &format!( + "SELECT {col} FROM bss.ledger_payment_settlement \ + WHERE tenant_id='{}' AND payment_id='{payment_id}'", + s.tenant + ), + ) + .await +} + +/// Read the `payment_allocation_refund.refunded_minor` for a `(payment, invoice)` — +/// the Pattern-B per-invoice cap counter. +async fn allocation_refunded( + raw: &DatabaseConnection, + s: &Seller, + payment_id: &str, + invoice_id: &str, +) -> Option { + scalar_i64( + raw, + &format!( + "SELECT refunded_minor FROM bss.ledger_payment_allocation_refund \ + WHERE tenant_id='{}' AND payment_id='{payment_id}' AND invoice_id='{invoice_id}'", + s.tenant + ), + ) + .await +} + +/// The `clearing_state` of a `(psp_refund_id, phase)` refund row. +async fn refund_clearing_state( + raw: &DatabaseConnection, + s: &Seller, + psp: &str, + phase: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT clearing_state FROM bss.ledger_refund \ + WHERE tenant_id='{}' AND psp_refund_id='{psp}' AND phase='{phase}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Seed a `payment_allocation_refund` row directly (the per-`(payment, invoice)` +/// cap basis a Pattern-B refund draws against) — `allocated_minor = allocated`, +/// `refunded_minor = 0`. Stands in for the allocation that would have applied this +/// payment to the invoice (the `AllocationSidecar` seeds this row in the real flow; +/// seeding it directly keeps the refund cap test self-contained). +async fn seed_allocation_refund( + raw: &DatabaseConnection, + s: &Seller, + payment_id: &str, + invoice_id: &str, + allocated: i64, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_payment_allocation_refund \ + (tenant_id, payment_id, invoice_id, allocated_minor, refunded_minor, version) \ + VALUES ('{}','{payment_id}','{invoice_id}',{allocated},0,0)", + s.tenant + ))) + .await + .unwrap(); +} + +/// Bump `payment_settlement.allocated_minor` directly to model a prior allocation +/// of `amount` from the pool (so a Pattern-A refund's `refunded_unallocated` cap + +/// the spendable-headroom CHECK have a non-trivial allocated base). Mirrors what +/// `add_allocated` does, without wiring the whole allocation flow. +async fn bump_allocated(raw: &DatabaseConnection, s: &Seller, payment_id: &str, amount: i64) { + raw.execute(pg(format!( + "UPDATE bss.ledger_payment_settlement \ + SET allocated_minor = allocated_minor + {amount}, version = version + 1 \ + WHERE tenant_id='{}' AND payment_id='{payment_id}'", + s.tenant + ))) + .await + .unwrap(); +} + +fn refund_req( + s: &Seller, + refund_id: &str, + psp_refund_id: &str, + payment_id: &str, + pattern: RefundPattern, + phase: RefundPhase, + invoice_id: Option<&str>, + amount: i64, +) -> RefundRequest { + RefundRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + refund_id: refund_id.to_owned(), + psp_refund_id: psp_refund_id.to_owned(), + phase, + pattern, + payment_id: payment_id.to_owned(), + invoice_id: invoice_id.map(ToOwned::to_owned), + currency: "USD".to_owned(), + amount_minor: amount, + two_stage: true, + // First-order OUTBOUND refund by default; the refund-of-refund tests build + // claw-backs via `clawback_req`. + relates_to_refund_id: None, + direction: RefundDirection::Outbound, + } +} + +/// A refund-of-refund CLAW-BACK request: references a prior refund + the `Clawback` +/// direction (so the legs invert and the money-out counters DECREMENT, under the +/// underflow guard). Group E. Mirrors `refund_req`'s builder shape. +#[allow(clippy::too_many_arguments)] +fn clawback_req( + s: &Seller, + refund_id: &str, + psp_refund_id: &str, + payment_id: &str, + pattern: RefundPattern, + phase: RefundPhase, + invoice_id: Option<&str>, + amount: i64, + relates_to: &str, +) -> RefundRequest { + RefundRequest { + relates_to_refund_id: Some(relates_to.to_owned()), + direction: RefundDirection::Clawback, + ..refund_req( + s, + refund_id, + psp_refund_id, + payment_id, + pattern, + phase, + invoice_id, + amount, + ) + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn pattern_a_two_stage_drains_refund_clearing_to_zero() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle 1000 → UNALLOCATED holds 1000 (CR), CASH_CLEARING holds 1000 (DR). + settle(&provider, &s, "PAY-A", 1000).await; + assert_eq!(bal(&raw, &s, s.unallocated).await, Some(1000)); + assert_eq!(bal(&raw, &s, s.cash).await, Some(1000)); + + // Stage-1 initiated: DR UNALLOCATED 300 · CR REFUND_CLEARING 300. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-A1", + "PSP-A", + "PAY-A", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("stage-1 posts"); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(700), + "UNALLOCATED drawn down by the refund" + ); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(300), + "stage-1 opens the REFUND_CLEARING balance" + ); + assert_eq!(refund_rows(&raw, &s, "PSP-A", "initiated").await, Some(1)); + + // Stage-2 confirmed: DR REFUND_CLEARING 300 · CR CASH_CLEARING 300. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-A2", + "PSP-A", + "PAY-A", + RefundPattern::AUnallocated, + RefundPhase::Confirmed, + None, + 300, + ), + ) + .await + .expect("stage-2 posts"); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(0), + "stage-2 drains REFUND_CLEARING back to zero" + ); + assert_eq!( + bal(&raw, &s, s.cash).await, + Some(700), + "CASH_CLEARING reduced by the disbursed cash (1000 − 300)" + ); + assert_eq!(refund_rows(&raw, &s, "PSP-A", "confirmed").await, Some(1)); + assert_eq!( + any_contract_liability(&raw, &s).await, + 0, + "a refund must never touch CONTRACT_LIABILITY" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn pattern_b_two_stage_restores_ar_then_drains_clearing() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-B", 1000).await; + // Pattern B's per-(payment, invoice) cap reads the payment_allocation_refund + // row that allocation seeds — the (PAY-B, INV-9) pair was allocated 400 (the + // restored-AR refund's cap basis), mirroring `pattern_b_per_invoice_cap`. + seed_allocation_refund(&raw, &s, "PAY-B", "INV-9", 400).await; + + // Stage-1 initiated (Pattern B): DR AR 400 (re-open the receivable) · CR + // REFUND_CLEARING 400. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-B1", + "PSP-B", + "PAY-B", + RefundPattern::BRestoreAr, + RefundPhase::Initiated, + Some("INV-9"), + 400, + ), + ) + .await + .expect("stage-1 posts"); + assert_eq!( + bal(&raw, &s, s.ar).await, + Some(400), + "AR restored (the receivable re-opens)" + ); + assert_eq!(bal(&raw, &s, s.refund_clearing).await, Some(400)); + + // Stage-2 confirmed drains REFUND_CLEARING → CASH_CLEARING. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-B2", + "PSP-B", + "PAY-B", + RefundPattern::BRestoreAr, + RefundPhase::Confirmed, + Some("INV-9"), + 400, + ), + ) + .await + .expect("stage-2 posts"); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(0), + "stage-2 drains REFUND_CLEARING to zero" + ); + assert_eq!( + bal(&raw, &s, s.cash).await, + Some(600), + "cash disbursed (1000 − 400)" + ); + assert_eq!( + any_contract_liability(&raw, &s).await, + 0, + "Pattern B never touches CONTRACT_LIABILITY" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_against_unsettled_payment_is_origin_not_found() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // No settle for PAY-NONE ⇒ no payment_settlement ⇒ RefundOriginNotFound (404). + let err = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-NF", + "PSP-NF", + "PAY-NONE", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 100, + ), + ) + .await + .expect_err("a refund against an unsettled payment must be rejected"); + assert!( + matches!(err, DomainError::RefundOriginNotFound(_)), + "expected RefundOriginNotFound, got {err:?}" + ); + // No books / record effect. + assert_eq!(refund_rows(&raw, &s, "PSP-NF", "initiated").await, Some(0)); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_currency_mismatch_is_rejected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (_raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-CUR", 1000).await; // settled in USD + + let mut req = refund_req( + &s, + "RF-CUR", + "PSP-CUR", + "PAY-CUR", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 100, + ); + req.currency = "EUR".to_owned(); // refund currency ≠ the USD settlement + let err = refund_handler(&provider) + .post_refund(&ctx, &scope, req) + .await + .expect_err("a currency-mismatched refund must be rejected"); + assert!( + matches!(err, DomainError::CurrencyMismatch(_)), + "expected CurrencyMismatch, got {err:?}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_stage_is_idempotent_on_psp_phase() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-IDEM", 1000).await; + + let req = || { + refund_req( + &s, + "RF-IDEM", + "PSP-IDEM", + "PAY-IDEM", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 250, + ) + }; + let first = refund_handler(&provider) + .post_refund(&ctx, &scope, req()) + .await + .expect("first post"); + assert!(!first.replayed, "first post is fresh"); + assert_eq!(bal(&raw, &s, s.refund_clearing).await, Some(250)); + + // Replay the SAME (psp_refund_id, phase) ⇒ idempotent: the engine returns the + // prior posting, the sidecar does not run again, and the books are unchanged. + let replay = refund_handler(&provider) + .post_refund(&ctx, &scope, req()) + .await + .expect("replay returns the prior posting"); + assert!(replay.replayed, "second post is a replay"); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(250), + "no second books effect on replay" + ); + assert_eq!( + refund_rows(&raw, &s, "PSP-IDEM", "initiated").await, + Some(1), + "exactly one refund row (the replay did not insert a second)" + ); +} + +// --------------------------------------------------------------------------- +// Group C — caps at stage-1 + the stage-1 reversal (rejected/voided). +// --------------------------------------------------------------------------- + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn stage1_initiation_reserves_refunded_cap_both_patterns() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-CAP", 1000).await; + // Pattern A stage-1: reserves refunded_minor AND refunded_unallocated_minor. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-CAP1", + "PSP-CAP", + "PAY-CAP", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("stage-1 reserves the cap"); + assert_eq!( + settlement_counter(&raw, &s, "PAY-CAP", "refunded_minor").await, + Some(300), + "stage-1 bumps total money-out refunded_minor" + ); + assert_eq!( + settlement_counter(&raw, &s, "PAY-CAP", "refunded_unallocated_minor").await, + Some(300), + "Pattern A also bumps the spendable-headroom refunded_unallocated_minor" + ); + + // Stage-2 confirmed must NOT move the counters (the cash was capped at stage-1). + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-CAP2", + "PSP-CAP", + "PAY-CAP", + RefundPattern::AUnallocated, + RefundPhase::Confirmed, + None, + 300, + ), + ) + .await + .expect("stage-2 drains clearing"); + assert_eq!( + settlement_counter(&raw, &s, "PAY-CAP", "refunded_minor").await, + Some(300), + "stage-2 confirmed leaves refunded_minor unchanged (no double count)" + ); + assert_eq!( + settlement_counter(&raw, &s, "PAY-CAP", "refunded_unallocated_minor").await, + Some(300), + "stage-2 confirmed leaves refunded_unallocated_minor unchanged" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn stage1_over_settled_is_refund_exceeds_settled() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-OVER", 1000).await; + // A stage-1 refund of 1500 > 1000 settled: the rank-1 cap CHECK rejects it + // BEFORE any cash leaves — REFUND_EXCEEDS_SETTLED, no books / record effect. + let err = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-OVER", + "PSP-OVER", + "PAY-OVER", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 1500, + ), + ) + .await + .expect_err("an over-settled stage-1 refund must be rejected by the cap"); + assert!( + matches!(err, DomainError::RefundExceedsSettled(_)), + "expected RefundExceedsSettled, got {err:?}" + ); + // The whole post rolled back: no refund row, no REFUND_CLEARING balance, cap 0. + assert_eq!( + refund_rows(&raw, &s, "PSP-OVER", "initiated").await, + Some(0) + ); + assert_eq!(bal(&raw, &s, s.refund_clearing).await, None); + assert_eq!( + settlement_counter(&raw, &s, "PAY-OVER", "refunded_minor").await, + Some(0), + "the rejected reservation left refunded_minor at 0" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn pattern_a_refund_consumes_unallocated_headroom() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-HR", 1000).await; + // Model a prior allocation of 800 from the pool (spendable headroom now 200). + bump_allocated(&raw, &s, "PAY-HR", 800).await; + + // A Pattern-A refund of 200 fits the spendable headroom (allocated 800 + + // refunded_unallocated 200 = 1000 <= settled 1000). + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-HR1", + "PSP-HR1", + "PAY-HR", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 200, + ), + ) + .await + .expect("refund within spendable headroom succeeds"); + + // A further Pattern-A refund of even 1 would push allocated 800 + + // refunded_unallocated 201 = 1001 > 1000 ⇒ the spendable-headroom CHECK rejects + // it: the refunded on-account cash can no longer also be allocated. + let err = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-HR2", + "PSP-HR2", + "PAY-HR", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 1, + ), + ) + .await + .expect_err("a refund past the spendable headroom must be rejected"); + assert!( + matches!(err, DomainError::RefundExceedsSettled(_)), + "expected RefundExceedsSettled (spendable headroom), got {err:?}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn pattern_b_per_invoice_cap_blocks_over_allocated() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-PB", 1000).await; + // The (PAY-PB, INV-PB) pair was allocated 400 (the per-invoice cap basis). + seed_allocation_refund(&raw, &s, "PAY-PB", "INV-PB", 400).await; + + // A Pattern-B refund of 400 fits the per-invoice cap (refunded 400 <= allocated + // 400) and bumps payment_allocation_refund.refunded_minor. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-PB1", + "PSP-PB1", + "PAY-PB", + RefundPattern::BRestoreAr, + RefundPhase::Initiated, + Some("INV-PB"), + 400, + ), + ) + .await + .expect("Pattern-B refund within the per-invoice cap succeeds"); + assert_eq!( + allocation_refunded(&raw, &s, "PAY-PB", "INV-PB").await, + Some(400), + "Pattern B bumps the per-(payment, invoice) refunded_minor" + ); + + // A further Pattern-B refund of 1 ⇒ refunded 401 > 400 allocated ⇒ the + // per-invoice cap CHECK rejects it as REFUND_EXCEEDS_ALLOCATED. + let err = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-PB2", + "PSP-PB2", + "PAY-PB", + RefundPattern::BRestoreAr, + RefundPhase::Initiated, + Some("INV-PB"), + 1, + ), + ) + .await + .expect_err("a Pattern-B refund past the allocated amount must be rejected"); + assert!( + matches!(err, DomainError::RefundExceedsAllocated(_)), + "expected RefundExceedsAllocated, got {err:?}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn rejected_stage1_reverses_and_frees_cap() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-REJ", 1000).await; + + // Stage-1 Pattern A initiated: DR UNALLOCATED 350 · CR REFUND_CLEARING 350, cap + // reserved. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-REJ1", + "PSP-REJ", + "PAY-REJ", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 350, + ), + ) + .await + .expect("stage-1 posts + reserves cap"); + assert_eq!(bal(&raw, &s, s.unallocated).await, Some(650)); + assert_eq!(bal(&raw, &s, s.refund_clearing).await, Some(350)); + assert_eq!( + settlement_counter(&raw, &s, "PAY-REJ", "refunded_minor").await, + Some(350) + ); + assert_eq!( + settlement_counter(&raw, &s, "PAY-REJ", "refunded_unallocated_minor").await, + Some(350) + ); + + // PSP rejected the initiated refund: the stage-1 reversal line-negates + // (DR REFUND_CLEARING 350 · CR UNALLOCATED 350), decrements the caps, and drains + // REFUND_CLEARING to zero. + let posting = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-REJ2", + "PSP-REJ", + "PAY-REJ", + RefundPattern::AUnallocated, + RefundPhase::Rejected, + None, + 350, + ), + ) + .await + .expect("stage-1 reversal posts"); + assert!(!posting.replayed, "the reversal is a fresh post"); + + // REFUND_CLEARING drained to zero; UNALLOCATED restored to the pre-refund 1000. + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(0), + "the reversal drains REFUND_CLEARING to zero" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(1000), + "the reversal restores the drawn-down UNALLOCATED pool" + ); + // The caps are released back to the pre-initiation 0 (the cap is freed). + assert_eq!( + settlement_counter(&raw, &s, "PAY-REJ", "refunded_minor").await, + Some(0), + "the reversal frees refunded_minor back to 0" + ); + assert_eq!( + settlement_counter(&raw, &s, "PAY-REJ", "refunded_unallocated_minor").await, + Some(0), + "the reversal frees refunded_unallocated_minor back to 0" + ); + // The reversal refund row is REVERSED and links the stage-1 entry. + assert_eq!( + refund_clearing_state(&raw, &s, "PSP-REJ", "rejected").await, + Some("REVERSED".to_owned()), + "the rejected refund row is clearing_state = REVERSED" + ); + let reverses = scalar_i64( + &raw, + &format!( + "SELECT count(*) FROM bss.ledger_refund \ + WHERE tenant_id='{}' AND psp_refund_id='PSP-REJ' AND phase='rejected' \ + AND reverses_entry_id IS NOT NULL", + s.tenant + ), + ) + .await; + assert_eq!( + reverses, + Some(1), + "the rejected refund row carries the stage-1 reverses_entry_id" + ); + + // The cap is now fully re-opened: a fresh full refund of 1000 succeeds. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-REJ3", + "PSP-REJ-NEW", + "PAY-REJ", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 1000, + ), + ) + .await + .expect("the freed cap admits a fresh full refund"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn reject_without_stage1_is_invalid_request() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-NOSTG", 1000).await; + // A `rejected` with NO prior stage-1 `initiated` entry has nothing to reverse — + // an upstream contract violation, rejected as InvalidRequest (no books effect). + let err = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-NOSTG", + "PSP-NOSTG", + "PAY-NOSTG", + RefundPattern::AUnallocated, + RefundPhase::Rejected, + None, + 100, + ), + ) + .await + .expect_err("a reject with no stage-1 to reverse must be rejected"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest, got {err:?}" + ); + assert_eq!( + refund_rows(&raw, &s, "PSP-NOSTG", "rejected").await, + Some(0) + ); +} + +// ─────────────────────────── dual-control (Group D) ─────────────────────────── +// +// A refund whose returned cash crosses the tenant's D2 threshold (default +// $1000 = 100_000 minor; no tenant policy row ⇒ ratified defaults) must NOT post +// inline — it parks a PENDING approval and returns `DualControlRequired` (409). A +// SECOND actor's approve then re-drives the held refund through the executor +// (`post_refund_approved`), moving the books exactly once; the preparer cannot +// approve their own (distinct-approver CHECK). A below-threshold refund posts +// inline, unchanged. Mirrors `postgres_dual_control.rs` but against the REAL +// refund-posting path (chart + settled origin), so it asserts the books actually +// move on approve and the caps are taken. + +/// An `ApprovalExecutor` that replays the held refund through the un-gated +/// `RefundHandler` (`post_refund_approved`) — the real Group-D executor arm, minus +/// the `LedgerClientV1`/`InvoicePoster` wiring the full `LedgerApprovalExecutor` +/// needs. Counts executions so a test can assert "posted exactly once". +#[derive(Clone)] +struct RefundReplayExecutor { + refund: Arc, + calls: Arc, +} + +#[async_trait::async_trait] +impl ApprovalExecutor for RefundReplayExecutor { + async fn execute( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + intent: &ApprovalIntent, + ) -> Result<(), DomainError> { + match intent { + ApprovalIntent::Refund(i) => { + let req = RefundRequest::try_from(i)?; + self.refund.post_refund_approved(ctx, scope, req).await?; + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + other => Err(DomainError::Internal(format!( + "unexpected intent in refund replay test: {other:?}" + ))), + } + } +} + +/// An authed `SecurityContext` for `subject` in `tenant` (the dual-control engine +/// reads `subject_id` / `subject_tenant_id` for the preparer/approver identity). +fn dc_ctx(subject: Uuid, tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(subject) + .subject_tenant_id(tenant) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// Build the gated handler + the approval service sharing the un-gated replay +/// handler over the same provider. Returns `(gated_handler, approval_service, +/// executor)`. +fn dual_control_wiring( + provider: &DBProvider, +) -> (RefundHandler, Arc, RefundReplayExecutor) { + // The un-gated handler the executor replays through (never re-gates). + let replay_handler = Arc::new(RefundHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + )); + let exec = RefundReplayExecutor { + refund: Arc::clone(&replay_handler), + calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }; + let svc = Arc::new(ApprovalService::new( + provider.clone(), + Arc::new(exec.clone()), + Arc::new(NoopLedgerMetrics), + bss_ledger::config::FxConfig::default(), + )); + // The GATED handler the preparer hits: same db, dual-control attached. + let gated = RefundHandler::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())) + .with_approval(Arc::clone(&svc)); + (gated, svc, exec) +} + +/// `count(*)` of PENDING REFUND approvals for the tenant. +async fn pending_refund_approvals(raw: &DatabaseConnection, s: &Seller) -> i64 { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_approval \ + WHERE tenant_id='{}' AND kind='REFUND' AND state='PENDING'", + s.tenant + ), + ) + .await + .unwrap_or(0) +} + +/// Above D2: an over-threshold refund does NOT post — it returns +/// `DualControlRequired` and creates exactly one PENDING REFUND approval (no books +/// effect yet). A second actor approves → the held refund posts (books move, the +/// `refund` row + cap land), exactly once. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_over_threshold_gates_then_a_second_actor_approve_posts_it() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let scope = AccessScope::for_tenant(s.tenant); + let preparer = Uuid::now_v7(); + let approver = Uuid::now_v7(); + + // Settle 200_000 so a 150_000 (> 100_000 D2) Pattern-A refund has headroom. + settle(&provider, &s, "PAY-DC", 200_000).await; + + let (gated, svc, exec) = dual_control_wiring(&provider); + + // Preparer attempts a 150_000 refund → gated (above the 100_000 default D2). + let err = gated + .post_refund( + &dc_ctx(preparer, s.tenant), + &scope, + refund_req( + &s, + "RF-DC", + "PSP-DC", + "PAY-DC", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 150_000, + ), + ) + .await + .expect_err("an over-threshold refund must gate, not post"); + assert!( + matches!(err, DomainError::DualControlRequired(_)), + "expected DualControlRequired (409), got {err:?}" + ); + assert_eq!( + pending_refund_approvals(&raw, &s).await, + 1, + "the gate created exactly one PENDING REFUND approval" + ); + // No books moved yet (UNALLOCATED still holds the full settled 200_000). + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(200_000), + "gating must not move the books" + ); + assert_eq!(refund_rows(&raw, &s, "PSP-DC", "initiated").await, Some(0)); + + // Resolve the PENDING approval id (the only one for the tenant). + let approval_id = svc + .list( + &dc_ctx(approver, s.tenant), + &scope, + Some("PENDING"), + Some("REFUND"), + ) + .await + .expect("list approvals") + .first() + .map(|m| m.approval_id) + .expect("one pending approval"); + + // A SECOND actor approves → the held refund posts (execute-then-mark). + svc.approve(&dc_ctx(approver, s.tenant), &scope, approval_id) + .await + .expect("approve"); + + assert_eq!( + exec.calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the held refund posts exactly once" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(50_000), + "approve drew UNALLOCATED down by the refund (200_000 − 150_000)" + ); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(150_000), + "the stage-1 REFUND_CLEARING balance opened on approve" + ); + assert_eq!(refund_rows(&raw, &s, "PSP-DC", "initiated").await, Some(1)); + // The money-out cap was taken (refunded_minor bumped under the settlement lock). + assert_eq!( + settlement_counter(&raw, &s, "PAY-DC", "refunded_minor").await, + Some(150_000), + "the stage-1 reservation moved the money-out cap on approve" + ); + // The approval is now APPROVED, approver stamped. + let row = svc + .get(&dc_ctx(approver, s.tenant), &scope, approval_id) + .await + .expect("get") + .expect("present"); + assert_eq!(row.state, "APPROVED"); + assert_eq!(row.approved_by, Some(approver)); +} + +/// The preparer cannot approve their OWN over-threshold refund: the distinct-actor +/// rule rejects it (`SelfApprovalForbidden`) and the held refund never posts. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn preparer_cannot_self_approve_their_refund() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let scope = AccessScope::for_tenant(s.tenant); + let preparer = Uuid::now_v7(); + + settle(&provider, &s, "PAY-SELF", 200_000).await; + let (gated, svc, exec) = dual_control_wiring(&provider); + + let err = gated + .post_refund( + &dc_ctx(preparer, s.tenant), + &scope, + refund_req( + &s, + "RF-SELF", + "PSP-SELF", + "PAY-SELF", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 150_000, + ), + ) + .await + .expect_err("over-threshold gates"); + assert!( + matches!(err, DomainError::DualControlRequired(_)), + "got {err:?}" + ); + + let approval_id = svc + .list( + &dc_ctx(preparer, s.tenant), + &scope, + Some("PENDING"), + Some("REFUND"), + ) + .await + .expect("list") + .first() + .map(|m| m.approval_id) + .expect("one pending"); + + // The PREPARER approves their own → forbidden, nothing posts. + let err = svc + .approve(&dc_ctx(preparer, s.tenant), &scope, approval_id) + .await + .expect_err("self-approval forbidden"); + assert!( + matches!(err, DomainError::SelfApprovalForbidden(_)), + "got {err:?}" + ); + assert_eq!( + exec.calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "a forbidden self-approval must not post the refund" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(200_000), + "the books are untouched" + ); + let row = svc + .get(&dc_ctx(preparer, s.tenant), &scope, approval_id) + .await + .expect("get") + .expect("present"); + assert_eq!(row.state, "PENDING", "the refund approval is still pending"); +} + +/// Below D2: an under-threshold refund posts INLINE through the gated handler — +/// no approval row, books move immediately (the gate returns `None`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_under_threshold_posts_inline_without_an_approval() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let scope = AccessScope::for_tenant(s.tenant); + let preparer = Uuid::now_v7(); + + settle(&provider, &s, "PAY-SMALL", 200_000).await; + let (gated, _svc, _exec) = dual_control_wiring(&provider); + + // 50_000 < 100_000 D2 → inline. + gated + .post_refund( + &dc_ctx(preparer, s.tenant), + &scope, + refund_req( + &s, + "RF-SMALL", + "PSP-SMALL", + "PAY-SMALL", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 50_000, + ), + ) + .await + .expect("a below-threshold refund posts inline"); + assert_eq!( + pending_refund_approvals(&raw, &s).await, + 0, + "no approval is created below threshold" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(150_000), + "the inline refund drew UNALLOCATED down immediately (200_000 − 50_000)" + ); + assert_eq!( + refund_rows(&raw, &s, "PSP-SMALL", "initiated").await, + Some(1) + ); +} + +// --------------------------------------------------------------------------- +// Group E — refund-of-refund (claw-back vs additional-outbound) + out-of-order +// claw-back defer/retry/escalate (design §4.4 / Rev3 / S3-F1). +// --------------------------------------------------------------------------- + +/// Count REFUND_CLAWBACK queue rows for the tenant in a given status. +async fn clawback_queue_rows(raw: &DatabaseConnection, s: &Seller, status: &str) -> i64 { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='REFUND_CLAWBACK' AND status='{status}'", + s.tenant + ), + ) + .await + .unwrap_or(0) +} + +/// Force a REFUND_CLAWBACK queue row to look aged: backdate `queued_at` well past +/// the 7-day aging horizon AND clear `apply_after` so it is immediately claimable. +async fn age_clawback_row(raw: &DatabaseConnection, s: &Seller) { + raw.execute(pg(format!( + "UPDATE bss.ledger_pending_event_queue \ + SET queued_at = now() - interval '30 days', apply_after = NULL \ + WHERE tenant_id='{}' AND flow='REFUND_CLAWBACK'", + s.tenant + ))) + .await + .unwrap(); +} + +/// Claw-back AFTER a matching outbound refund stage-1: the decrement nets +/// `refunded_minor` back down (so the total money-out cap reflects the NET refunded +/// and does not falsely trip), and REFUND_CLEARING drains in the opposite direction. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn clawback_after_outbound_decrements_net_refunded() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-CB", 1000).await; + + // Outbound stage-1 refund of 400 → refunded_minor = 400; UNALLOCATED 1000→600. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-OUT", + "PSP-OUT", + "PAY-CB", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + ), + ) + .await + .expect("outbound stage-1 posts"); + assert_eq!( + settlement_counter(&raw, &s, "PAY-CB", "refunded_minor").await, + Some(400) + ); + assert_eq!(bal(&raw, &s, s.unallocated).await, Some(600)); + assert_eq!(bal(&raw, &s, s.refund_clearing).await, Some(400)); + + // Claw-back stage-1 of 400 (the PSP returned the cash): DECREMENTS refunded_minor + // 400→0 (net refunded), inverts the legs (DR REFUND_CLEARING · CR UNALLOCATED) + // so REFUND_CLEARING drains 400→0 and UNALLOCATED is restored 600→1000. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + clawback_req( + &s, + "RF-CB", + "PSP-CB", + "PAY-CB", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + "RF-OUT", + ), + ) + .await + .expect("in-order claw-back posts"); + assert_eq!( + settlement_counter(&raw, &s, "PAY-CB", "refunded_minor").await, + Some(0), + "claw-back decrements money-out back to the NET refunded (400 − 400)" + ); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(0), + "claw-back drains REFUND_CLEARING the opposite way" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(1000), + "claw-back restores the drawn-down UNALLOCATED" + ); + // The claw-back refund row carries the relates_to link. + let relates: Option = raw + .query_one(pg(format!( + "SELECT relates_to_refund_id FROM bss.ledger_refund \ + WHERE tenant_id='{}' AND psp_refund_id='PSP-CB' AND phase='initiated'", + s.tenant + ))) + .await + .unwrap() + .and_then(|r| r.try_get_by_index::>(0).unwrap()); + assert_eq!( + relates.as_deref(), + Some("RF-OUT"), + "claw-back row links the prior refund" + ); + assert_eq!( + clawback_queue_rows(&raw, &s, "QUEUED").await, + 0, + "in-order claw-back never queues" + ); +} + +/// An ADDITIONAL-OUTBOUND refund-of-refund (cash out again) INCREMENTS the money-out +/// counter like a plain stage-1, under the SAME cap. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn additional_outbound_refund_of_refund_increments_under_cap() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-AO", 1000).await; + + // First outbound refund of 300. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-1", + "PSP-1", + "PAY-AO", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("first outbound posts"); + + // An ADDITIONAL OUTBOUND refund-of-refund of 200 (direction = Outbound, with a + // relates_to link): INCREMENTS refunded_minor 300→500 under the same cap. + let mut additional = refund_req( + &s, + "RF-2", + "PSP-2", + "PAY-AO", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 200, + ); + additional.relates_to_refund_id = Some("RF-1".to_owned()); + additional.direction = RefundDirection::Outbound; + refund_handler(&provider) + .post_refund(&ctx, &scope, additional) + .await + .expect("additional-outbound posts"); + assert_eq!( + settlement_counter(&raw, &s, "PAY-AO", "refunded_minor").await, + Some(500), + "additional-outbound increments under the SAME money-out cap (300 + 200)" + ); + assert_eq!( + clawback_queue_rows(&raw, &s, "QUEUED").await, + 0, + "outbound never queues" + ); +} + +/// An OUT-OF-ORDER claw-back (no matching outbound refund stage-1 yet): the decrement +/// would underflow → DEFERRED to the REFUND_CLAWBACK queue (NOT aborted, NOT applied), +/// `refunded_minor` untouched. After the matching outbound lands, the drain applies it. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn out_of_order_clawback_defers_then_applies_after_outbound() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-OOO", 1000).await; + + // Claw-back of 400 arrives FIRST (no outbound yet) → refunded_minor would go + // 0 − 400 < 0 → DEFER. The call surfaces RefundClawbackDeferred (not a hard + // error), nothing posts, and the row is QUEUED. + let deferred = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + clawback_req( + &s, + "RF-CB", + "PSP-CB", + "PAY-OOO", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + "RF-OUT", + ), + ) + .await; + assert!( + matches!(deferred, Err(DomainError::RefundClawbackDeferred(_))), + "out-of-order claw-back defers (not a hard fail): {deferred:?}" + ); + assert_eq!( + settlement_counter(&raw, &s, "PAY-OOO", "refunded_minor").await, + Some(0), + "the deferred claw-back applied NO decrement" + ); + assert_eq!( + clawback_queue_rows(&raw, &s, "QUEUED").await, + 1, + "claw-back is durably QUEUED" + ); + assert_eq!( + refund_rows(&raw, &s, "PSP-CB", "initiated").await, + Some(0), + "no refund row posted" + ); + + // The matching OUTBOUND refund stage-1 lands → refunded_minor 0→400. + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-OUT", + "PSP-OUT", + "PAY-OOO", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + ), + ) + .await + .expect("outbound posts"); + assert_eq!( + settlement_counter(&raw, &s, "PAY-OOO", "refunded_minor").await, + Some(400) + ); + + // Drain the claw-back queue: the decrement now FITS → APPLIED (refunded_minor + // 400→0), the queue row flips →APPLIED, the claw-back refund row posts. + let report = refund_handler(&provider) + .drain_clawbacks(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.applied, 1, "the reconciled claw-back applied"); + assert_eq!( + settlement_counter(&raw, &s, "PAY-OOO", "refunded_minor").await, + Some(0), + "the drained claw-back decremented to the net refunded" + ); + assert_eq!(clawback_queue_rows(&raw, &s, "QUEUED").await, 0); + assert_eq!( + clawback_queue_rows(&raw, &s, "APPLIED").await, + 1, + "queue row flipped →APPLIED" + ); + assert_eq!( + refund_rows(&raw, &s, "PSP-CB", "initiated").await, + Some(1), + "claw-back row now posted" + ); +} + +/// A claw-back that NEVER reconciles past the aging horizon: the drain flips it +/// →CANCELLED + escalates (the CLAWBACK_UNDERFLOW alarm fires via the noop publisher; +/// here we assert the durable CANCELLED transition + that no decrement was applied). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn never_reconciled_clawback_is_cancelled_and_escalated() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-ORPH", 1000).await; + + // Orphan claw-back: no matching outbound ever → defers, QUEUED. + let _ = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + clawback_req( + &s, + "RF-CB", + "PSP-CB", + "PAY-ORPH", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + "RF-OUT", + ), + ) + .await; + assert_eq!(clawback_queue_rows(&raw, &s, "QUEUED").await, 1); + + // Age the row past the 7-day horizon, then drain: it STILL underflows (no + // outbound landed) AND is aged → CANCELLED + escalated. + age_clawback_row(&raw, &s).await; + let report = refund_handler(&provider) + .drain_clawbacks(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.escalated, 1, "the orphan claw-back escalated"); + assert_eq!(report.applied, 0, "nothing applied"); + assert_eq!( + clawback_queue_rows(&raw, &s, "CANCELLED").await, + 1, + "queue row flipped →CANCELLED" + ); + assert_eq!(clawback_queue_rows(&raw, &s, "QUEUED").await, 0); + assert_eq!( + settlement_counter(&raw, &s, "PAY-ORPH", "refunded_minor").await, + Some(0), + "the never-reconciled claw-back applied NO decrement (the CHECK never fired)" + ); +} + +// --------------------------------------------------------------------------- +// Group F: the `unknown_final` terminal disposition (loss-line write-off + +// secured-audit append). +// --------------------------------------------------------------------------- + +/// One append the spy [`SecuredAuditSink`] captured (the fields the assertions +/// check). No durable persistence — the spy stands in for Slice 6's store. +#[derive(Clone, Debug)] +struct AuditCall { + tenant: Uuid, + event_type: String, + actor_ref: Option, + reason_code: Option, + before_after: serde_json::Value, +} + +/// A spy secured-audit sink: records every `append` (count + the captured calls) +/// so the `unknown_final` test can assert the disposition wrote exactly one +/// secured-audit record with the right event type / reason / payload. Like the +/// production `NoopSecuredAuditSink` it persists NOTHING durable + never fails (so +/// it cannot roll the disposition back) — it only observes the call. +#[derive(Default)] +struct SpyAuditSink { + count: AtomicU64, + calls: Mutex>, +} + +#[async_trait::async_trait] +impl SecuredAuditSink for SpyAuditSink { + async fn append( + &self, + _txn: &DbTx<'_>, + _scope: &AccessScope, + tenant: Uuid, + event_type: AuditEventType, + actor_ref: Option<&str>, + reason_code: Option<&str>, + before_after: &serde_json::Value, + _correlation_id: Option, + _retain_until: Option>, + ) -> Result { + self.count.fetch_add(1, Ordering::SeqCst); + self.calls.lock().unwrap().push(AuditCall { + tenant, + event_type: event_type.as_str().to_owned(), + actor_ref: actor_ref.map(ToOwned::to_owned), + reason_code: reason_code.map(ToOwned::to_owned), + before_after: before_after.clone(), + }); + Ok(Uuid::now_v7()) + } +} + +/// The `unknown_final` dual-control disposition (design §4.4 / K-1): a two-stage +/// refund's stage-1 leaves `REFUND_CLEARING` open; the PSP then produces NO final +/// state, so the ledger-side disposition PARKS the stuck clearing on SUSPENSE +/// (`DR REFUND_CLEARING · CR SUSPENSE`) — draining clearing to zero, NOT booking a +/// premature loss/gain (the outcome is unknown) — AND writes a secured-audit record +/// (asserted via the spy sink). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn unknown_final_parks_refund_clearing_to_suspense_and_audits() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // The SUSPENSE park target is seeded by `setup` (credit-normal). Settle 1000, + // then a Pattern-A stage-1 of 300 → REFUND_CLEARING opens at 300. + settle(&provider, &s, "PAY-UF", 1000).await; + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-UF1", + "PSP-UF", + "PAY-UF", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("stage-1 posts"); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(300), + "stage-1 opens the REFUND_CLEARING balance" + ); + + // The unknown_final disposition: gated handler is NOT used here (no approval + // engine attached ⇒ inline, like the executor's approved replay), with the spy + // audit sink attached. + let spy = Arc::new(SpyAuditSink::default()); + let handler = RefundHandler::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())) + .with_audit_sink(Arc::clone(&spy) as Arc) + .with_metrics(Arc::new(NoopLedgerMetrics)); + handler + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-UF-DISP", + "PSP-UF", + "PAY-UF", + RefundPattern::AUnallocated, + RefundPhase::UnknownFinal, + None, + 300, + ), + ) + .await + .expect("unknown_final disposition posts"); + + // REFUND_CLEARING drained to zero (DR cancelled the stage-1 CR) … + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(0), + "unknown_final drains REFUND_CLEARING to zero" + ); + // … parked onto SUSPENSE (CR SUSPENSE 300, credit-normal → +300) pending + // reconciliation — NOT booked to loss/gain (the outcome is unknown) … + assert_eq!( + bal(&raw, &s, s.suspense).await, + Some(300), + "the stuck clearing was parked to SUSPENSE pending reconciliation" + ); + // … a refund row recorded SETTLED on the unknown_final phase grain … + assert_eq!( + refund_rows(&raw, &s, "PSP-UF", "unknown_final").await, + Some(1) + ); + assert_eq!( + refund_clearing_state(&raw, &s, "PSP-UF", "unknown_final") + .await + .as_deref(), + Some("SETTLED"), + "the disposition drains REFUND_CLEARING (parked to SUSPENSE) → SETTLED" + ); + // … and a NEVER touched CONTRACT_LIABILITY (a refund never restates revenue). + assert_eq!(any_contract_liability(&raw, &s).await, 0); + + // The secured-audit append fired EXACTLY once with the K-1 contract. + assert_eq!( + spy.count.load(Ordering::SeqCst), + 1, + "exactly one secured-audit record" + ); + let call = spy.calls.lock().unwrap()[0].clone(); + assert_eq!(call.tenant, s.tenant); + assert_eq!(call.event_type, "MANUAL_ADJUSTMENT"); + assert_eq!(call.reason_code.as_deref(), Some("REFUND_UNKNOWN_FINAL")); + assert!(call.actor_ref.is_some(), "the acting subject is captured"); + assert_eq!(call.before_after["disposition"], "REFUND_UNKNOWN_FINAL"); + assert_eq!(call.before_after["after"]["parked_minor"], 300); + assert_eq!(call.before_after["after"]["park_account_class"], "SUSPENSE"); + + // Idempotent replay: re-posting the same disposition does NOT double-park (the + // (tenant, REFUND, PSP-UF:unknown_final) claim short-circuits before the + // sidecar) — and the spy records NO second append. + handler + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-UF-DISP", + "PSP-UF", + "PAY-UF", + RefundPattern::AUnallocated, + RefundPhase::UnknownFinal, + None, + 300, + ), + ) + .await + .expect("replay is idempotent"); + assert_eq!( + bal(&raw, &s, s.suspense).await, + Some(300), + "no double park on replay" + ); + assert_eq!( + spy.count.load(Ordering::SeqCst), + 1, + "replay appends no second audit record" + ); +} + +// --------------------------------------------------------------------------- +// Group G — refund-before-payment QUARANTINE de-quarantine drain (design §4.4). +// The intake (quarantine) is exercised elsewhere; this drives the +// `drain_quarantine → apply_quarantined` state machine that had ZERO coverage: +// the four terminal shapes (Released / AwaitingApproval / StillMissing / +// Escalated) plus the dispute-held hand-off. Also the claw-back replay +// short-circuit (QUEUED re-signals deferred, POSTED replays) and the AC #19 +// idempotency-conflict capture (Z14-1). +// --------------------------------------------------------------------------- + +/// Count REFUND_QUARANTINE work-state queue rows for the tenant in a given status. +async fn quarantine_queue_rows(raw: &DatabaseConnection, s: &Seller, status: &str) -> i64 { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='REFUND_QUARANTINE' AND status='{status}'", + s.tenant + ), + ) + .await + .unwrap_or(0) +} + +/// Count REFUND_DISPUTE_HOLD work-state queue rows for the tenant in a given status — +/// a de-quarantined refund whose now-present origin has an OPEN dispute is handed off +/// to this queue (the de-quarantine terminates, the dispute-hold drain owns it). +async fn dispute_hold_queue_rows(raw: &DatabaseConnection, s: &Seller, status: &str) -> i64 { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='REFUND_DISPUTE_HOLD' AND status='{status}'", + s.tenant + ), + ) + .await + .unwrap_or(0) +} + +/// Force the REFUND_QUARANTINE queue row to look aged: backdate `queued_at` well past +/// the 14-day quarantine aging horizon AND clear `apply_after` so it is immediately +/// claimable. Mirrors `age_clawback_row`. +async fn age_quarantine_row(raw: &DatabaseConnection, s: &Seller) { + raw.execute(pg(format!( + "UPDATE bss.ledger_pending_event_queue \ + SET queued_at = now() - interval '30 days', apply_after = NULL \ + WHERE tenant_id='{}' AND flow='REFUND_QUARANTINE'", + s.tenant + ))) + .await + .unwrap(); +} + +/// Seed an OPEN dispute on `payment_id` directly (the simplest reliable way to put the +/// origin payment sub judice). Mirrors `postgres_refund_dispute_hold.rs::open_dispute`. +async fn open_dispute( + raw: &DatabaseConnection, + s: &Seller, + dispute_id: &str, + payment_id: &str, + disputed: i64, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_dispute \ + (tenant_id, dispute_id, payment_id, currency, variant, last_phase, cycle, \ + disputed_amount_minor, cash_hold_minor, version) \ + VALUES ('{}','{dispute_id}','{payment_id}','USD','CASH_HOLD','OPENED',1,{disputed},{disputed},0)", + s.tenant + ))) + .await + .unwrap(); +} + +/// De-quarantine happy path: a refund-before-payment is QUARANTINED (origin absent), +/// then the origin payment lands; the next drain re-validates, posts the now-allowed +/// stage-1 inline (under threshold), and flips the queue row `→APPLIED` (`Released`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn quarantine_then_settle_drains_and_posts() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + // PAY-Q has no settlement yet → the refund is quarantined (refund-before-payment), + // never posted. + let outcome = handler + .record_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-Q", + "PSP-Q", + "PAY-Q", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("record_refund quarantines a refund-before-payment"); + assert!( + matches!(outcome, RefundOutcome::Quarantined(_)), + "an absent origin quarantines, got {outcome:?}" + ); + assert_eq!(quarantine_queue_rows(&raw, &s, "QUEUED").await, 1); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + None, + "a quarantined refund posts nothing" + ); + + // The origin payment lands. + settle(&provider, &s, "PAY-Q", 1000).await; + + // Drain: origin now resolvable + under threshold ⇒ posts inline, row →APPLIED. + let report = handler + .drain_quarantine(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.released, 1, "the de-quarantined refund posted"); + assert_eq!(report.still_missing, 0); + assert_eq!(quarantine_queue_rows(&raw, &s, "QUEUED").await, 0); + assert_eq!( + quarantine_queue_rows(&raw, &s, "APPLIED").await, + 1, + "the quarantine row flipped →APPLIED" + ); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(300), + "the de-quarantined stage-1 opened REFUND_CLEARING" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(700), + "UNALLOCATED drawn down by the now-posted refund" + ); + assert_eq!(refund_rows(&raw, &s, "PSP-Q", "initiated").await, Some(1)); +} + +/// De-quarantine back-off: the origin is STILL absent and the row is within the aging +/// horizon ⇒ the drain leaves it `QUEUED` (`StillMissing`), posts nothing. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn quarantine_drain_still_missing_backs_off() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + handler + .record_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-QM", + "PSP-QM", + "PAY-QM", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("quarantine"); + assert_eq!(quarantine_queue_rows(&raw, &s, "QUEUED").await, 1); + + // No settle ⇒ origin still missing; fresh row (not aged) ⇒ back off, stay QUEUED. + let report = handler + .drain_quarantine(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.still_missing, 1, "origin absent ⇒ back off"); + assert_eq!(report.released, 0); + assert_eq!(report.escalated, 0); + assert_eq!( + quarantine_queue_rows(&raw, &s, "QUEUED").await, + 1, + "the row stays QUEUED for a later retry" + ); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + None, + "nothing posted while the origin is missing" + ); +} + +/// De-quarantine give-up: the origin never landed past the 14-day aging horizon ⇒ the +/// drain flips the row `→CANCELLED` + escalates (`Escalated`). The Critical +/// `RefundQuarantined` alarm fires via the noop publisher; here the durable CANCELLED +/// transition is the assertion (mirrors `never_reconciled_clawback_*`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn quarantine_aged_out_is_cancelled_and_escalated() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + handler + .record_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-QA", + "PSP-QA", + "PAY-QA", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("quarantine"); + assert_eq!(quarantine_queue_rows(&raw, &s, "QUEUED").await, 1); + + // Age the row past the 14-day horizon, then drain: origin STILL absent AND aged ⇒ + // CANCELLED + escalated. + age_quarantine_row(&raw, &s).await; + let report = handler + .drain_quarantine(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.escalated, 1, "the aged-out quarantine escalated"); + assert_eq!(report.released, 0); + assert_eq!(report.still_missing, 0); + assert_eq!( + quarantine_queue_rows(&raw, &s, "CANCELLED").await, + 1, + "the quarantine row flipped →CANCELLED" + ); + assert_eq!(quarantine_queue_rows(&raw, &s, "QUEUED").await, 0); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + None, + "an abandoned quarantine posts nothing" + ); +} + +/// De-quarantine over D2: the origin lands but the refund crosses the THEN-CURRENT D2 +/// threshold ⇒ the gated drain opens an approval (`AwaitingApproval`) and the row +/// stays `QUEUED` — it NEVER auto-posts over threshold. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn quarantine_over_threshold_awaits_approval() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + // The drain re-drives through the GATED post path, where the gate creates a + // PENDING approval keyed on the acting subject — so the drive MUST carry an + // AUTHED context (an anonymous ctx has no subject and the gate no-ops). + let ctx = dc_ctx(Uuid::now_v7(), s.tenant); + let scope = AccessScope::for_tenant(s.tenant); + // The GATED handler the drain re-drives through (over D2 ⇒ opens an approval). + let (gated, _svc, _exec) = dual_control_wiring(&provider); + + // Quarantine a 150_000 refund (origin absent) — intake never gates (the gate is + // re-checked at drain time, against the THEN-CURRENT threshold). + let outcome = gated + .record_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-QDC", + "PSP-QDC", + "PAY-QDC", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 150_000, + ), + ) + .await + .expect("quarantine"); + assert!(matches!(outcome, RefundOutcome::Quarantined(_))); + + // The origin lands with headroom (200_000 settled), then drain: 150_000 > 100_000 + // D2 ⇒ the drain opens an approval, the row stays QUEUED. + settle(&provider, &s, "PAY-QDC", 200_000).await; + let report = gated + .drain_quarantine(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!( + report.awaiting_approval, 1, + "an over-threshold de-quarantine awaits approval" + ); + assert_eq!(report.released, 0); + assert_eq!( + pending_refund_approvals(&raw, &s).await, + 1, + "the gated drain opened exactly one PENDING REFUND approval" + ); + assert_eq!( + quarantine_queue_rows(&raw, &s, "QUEUED").await, + 1, + "the row stays QUEUED — it never auto-posts over threshold" + ); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + None, + "nothing posts while awaiting approval" + ); +} + +/// De-quarantine into an OPEN dispute: the origin lands but the payment now has an +/// OPEN dispute ⇒ the re-driven refund is HELD on the dispute-hold queue. The +/// quarantine concern is resolved (origin present), so the quarantine row flips +/// `→APPLIED` (`Released`) and the dispute-hold drain owns it from here. Nothing posts. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn quarantine_drain_into_open_dispute_marks_applied() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + handler + .record_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-QOD", + "PSP-QOD", + "PAY-QOD", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("quarantine"); + + // The origin lands, but a dispute opens on it before the drain runs. + settle(&provider, &s, "PAY-QOD", 1000).await; + open_dispute(&raw, &s, "DISP-QOD", "PAY-QOD", 1000).await; + + let report = handler + .drain_quarantine(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!( + report.released, 1, + "the quarantine terminates cleanly (origin present, now dispute-held)" + ); + assert_eq!( + quarantine_queue_rows(&raw, &s, "APPLIED").await, + 1, + "the quarantine row flipped →APPLIED (the dispute-hold queue owns it now)" + ); + assert_eq!(quarantine_queue_rows(&raw, &s, "QUEUED").await, 0); + assert_eq!( + dispute_hold_queue_rows(&raw, &s, "QUEUED").await, + 1, + "the now-disputed refund was handed to the dispute-hold queue" + ); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + None, + "a dispute-held refund posts nothing" + ); + assert_eq!(refund_rows(&raw, &s, "PSP-QOD", "initiated").await, Some(0)); +} + +/// Claw-back replay (QUEUED): a deferred claw-back re-submitted under the same +/// `(psp_refund_id, initiated)` engine grain re-signals `RefundClawbackDeferred` via +/// the replay short-circuit — WITHOUT enqueuing a second row. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn out_of_order_clawback_replay_resignals_deferred() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + settle(&provider, &s, "PAY-CBR", 1000).await; + + // Out-of-order claw-back (no matching outbound) defers → one QUEUED row. + let first = handler + .post_refund( + &ctx, + &scope, + clawback_req( + &s, + "RF-CB", + "PSP-CBR", + "PAY-CBR", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + "RF-OUT", + ), + ) + .await; + assert!( + matches!(first, Err(DomainError::RefundClawbackDeferred(_))), + "out-of-order claw-back defers: {first:?}" + ); + assert_eq!(clawback_queue_rows(&raw, &s, "QUEUED").await, 1); + + // Re-submit the SAME claw-back: the engine dedup (REFUND, PSP-CBR:initiated) is + // QUEUED, so the short-circuit re-signals deferred WITHOUT a second enqueue. + let replay = handler + .post_refund( + &ctx, + &scope, + clawback_req( + &s, + "RF-CB", + "PSP-CBR", + "PAY-CBR", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + "RF-OUT", + ), + ) + .await; + assert!( + matches!(replay, Err(DomainError::RefundClawbackDeferred(_))), + "a re-submitted deferred claw-back re-signals deferred: {replay:?}" + ); + assert_eq!( + clawback_queue_rows(&raw, &s, "QUEUED").await, + 1, + "the short-circuit intercepted before a second enqueue (still one row)" + ); +} + +/// Claw-back replay (POSTED): once a deferred claw-back reconciles + applies, a +/// re-submission under the same grain returns the prior posting (`replayed`) via the +/// short-circuit — no second decrement. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn applied_clawback_replay_returns_posted() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + settle(&provider, &s, "PAY-CBP", 1000).await; + + // Claw-back defers (no outbound yet). + let _ = handler + .post_refund( + &ctx, + &scope, + clawback_req( + &s, + "RF-CBP", + "PSP-CBP", + "PAY-CBP", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + "RF-OUTP", + ), + ) + .await; + assert_eq!(clawback_queue_rows(&raw, &s, "QUEUED").await, 1); + + // The matching outbound lands (refunded_minor 0→400), then drain applies the + // claw-back (refunded_minor 400→0, dedup →POSTED, queue row →APPLIED). + handler + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-OUTP", + "PSP-OUTP", + "PAY-CBP", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + ), + ) + .await + .expect("outbound posts"); + let report = handler + .drain_clawbacks(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.applied, 1); + assert_eq!(clawback_queue_rows(&raw, &s, "APPLIED").await, 1); + assert_eq!( + settlement_counter(&raw, &s, "PAY-CBP", "refunded_minor").await, + Some(0), + "the applied claw-back netted refunded_minor back to zero" + ); + + // Re-submit the now-APPLIED claw-back: the short-circuit reads POSTED ⇒ returns + // the prior posting (replayed), no second decrement. + let replay = handler + .post_refund( + &ctx, + &scope, + clawback_req( + &s, + "RF-CBP", + "PSP-CBP", + "PAY-CBP", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + "RF-OUTP", + ), + ) + .await + .expect("an applied claw-back replays harmlessly"); + assert!( + replay.replayed, + "the applied claw-back returns the prior posting (replayed)" + ); + assert_eq!( + settlement_counter(&raw, &s, "PAY-CBP", "refunded_minor").await, + Some(0), + "no second decrement on replay" + ); +} + +/// Idempotency-conflict capture (AC #19, Z14-1): a same-key +/// `(psp_refund_id, phase)` quarantine re-intake with a DIFFERENT payload is a +/// `IdempotencyConflict` and the conflict is captured on the secured-audit sink; an +/// IDENTICAL re-intake is idempotent (`AlreadyQuarantined`, no error, no new row, no +/// new audit). Drives the shared `capture_idempotency_conflict` + both Replay arms. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn quarantine_conflict_payload_is_idempotency_conflict() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let spy = Arc::new(SpyAuditSink::default()); + let handler = RefundHandler::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())) + .with_audit_sink(Arc::clone(&spy) as Arc); + + // First quarantine (origin PAY-QC-NONE absent). + let first = handler + .record_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-QC", + "PSP-QC", + "PAY-QC-NONE", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("first quarantine"); + assert!(matches!(first, RefundOutcome::Quarantined(_))); + assert_eq!(quarantine_queue_rows(&raw, &s, "QUEUED").await, 1); + + // Re-quarantine the SAME (psp_refund_id, phase) with a DIFFERENT payload (amount + // 500 ≠ 300) ⇒ IdempotencyConflict + a secured-audit capture, no second row. + let conflict = handler + .record_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-QC", + "PSP-QC", + "PAY-QC-NONE", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 500, + ), + ) + .await + .expect_err("a different payload under the same key is a conflict"); + assert!( + matches!(conflict, DomainError::IdempotencyConflict(_)), + "expected IdempotencyConflict, got {conflict:?}" + ); + assert_eq!( + quarantine_queue_rows(&raw, &s, "QUEUED").await, + 1, + "the conflicting re-intake created no second queue row" + ); + assert_eq!( + spy.count.load(Ordering::SeqCst), + 1, + "the conflict was captured on the secured-audit sink (exactly once)" + ); + let call = spy.calls.lock().unwrap()[0].clone(); + assert_eq!(call.tenant, s.tenant); + assert_eq!(call.event_type, "MANUAL_ADJUSTMENT"); + assert_eq!(call.reason_code.as_deref(), Some("IDEMPOTENCY_CONFLICT")); + assert_eq!(call.before_after["event"], "IDEMPOTENCY_CONFLICT"); + + // An IDENTICAL re-intake is idempotent: AlreadyQuarantined ⇒ Ok, no new row, no + // new audit record. + let replay = handler + .record_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-QC", + "PSP-QC", + "PAY-QC-NONE", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("an identical re-quarantine is idempotent"); + assert!(matches!(replay, RefundOutcome::Quarantined(_))); + assert_eq!(quarantine_queue_rows(&raw, &s, "QUEUED").await, 1); + assert_eq!( + spy.count.load(Ordering::SeqCst), + 1, + "an idempotent re-quarantine appends no second audit record" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_refund_dispute_hold.rs b/gears/bss/ledger/ledger/tests/postgres_refund_dispute_hold.rs new file mode 100644 index 000000000..ac9b57d62 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_refund_dispute_hold.rs @@ -0,0 +1,1018 @@ +//! Postgres-only integration: the Slice-3 / §5 REFUND DISPUTE-HOLD control +//! ([`DomainError::RefundDisputeHeld`]), driven through the REAL foundation engine +//! (`SettlementService` + `RefundHandler`) against a real settled payment with an +//! OPEN dispute seeded directly into `bss.ledger_dispute`. +//! +//! A refund is money-OUT; a forward money-OUT post on a payment whose origin has an +//! OPEN dispute is sub judice — the disputed funds may be clawed back, so paying the +//! refund out now would double-spend. The handler's dispute-hold gate (Z5-2) HOLDS +//! such a refund: it claims a `(tenant, REFUND_DISPUTE_HOLD, psp_refund_id:phase)` +//! dedup row `QUEUED`, inserts the work-state queue row, posts NOTHING, and returns +//! `Err(DomainError::RefundDisputeHeld(token))`. The exclusion is by +//! `is_dispute_holdable`: only a FORWARD money-OUT post is held — a claw-back +//! (money-IN), a `rejected`/`voided` reversal (cap release), and the +//! `unknown_final` write-off are NOT. +//! +//! This file covers the two control arms that had ZERO executable coverage: +//! - a forward stage-1 `initiated` refund on a payment with an OPEN dispute is HELD +//! (the dedup/queue row lands, the books DO NOT move, the money-out cap is +//! unchanged); +//! - a CLAW-BACK on the SAME open-dispute setup is NOT held (it skips the hold gate +//! — `is_dispute_holdable` is false for money-IN — and falls through to its own +//! defer path), locking the money-IN exclusion. +//! +//! Ignored by default; run with `-- --ignored` (needs Docker / testcontainers). +//! Mirrors the helpers + setup of `postgres_refund.rs` (self-contained by +//! convention — duplication across these test files is normal). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::similar_names, + clippy::too_many_arguments +)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use bss_ledger::domain::adjustment::refund::{ + RefundDirection, RefundPattern, RefundPhase, RefundRequest, +}; +use bss_ledger::domain::approval::intent::ApprovalIntent; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::adjustment::refund_service::RefundHandler; +use bss_ledger::infra::approval::service::{ApprovalExecutor, ApprovalService}; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::{Datelike, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// Provisioned seller for the refund flow: the chart classes a refund touches — +/// `UNALLOCATED` (the settle pool, Pattern A debit), `AR` (Pattern B restore), +/// `REFUND_CLEARING` (the two-stage clearing), `CASH_CLEARING` (the disbursement) — +/// plus `PSP_FEE_EXPENSE` for the settle fee leg. +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + refund_clearing: Uuid, + ar: Uuid, + psp_fee: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot, migrate, seed USD@2 + an OPEN period (current month) + the refund chart. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider, Seller) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + refund_clearing: Uuid::now_v7(), + ar: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.refund_clearing, + AccountClass::RefundClearing, + Side::Credit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (raw, provider, s) +} + +fn settle_svc(provider: &DBProvider) -> SettlementService { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) +} + +fn refund_handler(provider: &DBProvider) -> RefundHandler { + RefundHandler::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())) +} + +/// Settle `gross` (fee 0) for `payment_id` — seeds the `payment_settlement` row +/// (`settled_minor = gross`) the refund resolves as its origin. +async fn settle(provider: &DBProvider, s: &Seller, payment_id: &str, gross: i64) { + settle_svc(provider) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle must succeed"); +} + +async fn bal(raw: &DatabaseConnection, s: &Seller, account: Uuid) -> Option { + scalar_i64( + raw, + &format!( + "SELECT balance_minor FROM bss.ledger_account_balance \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + s.tenant, account + ), + ) + .await +} + +/// `refund` row count for a `(psp_refund_id, phase)`. +async fn refund_rows(raw: &DatabaseConnection, s: &Seller, psp: &str, phase: &str) -> Option { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_refund \ + WHERE tenant_id='{}' AND psp_refund_id='{psp}' AND phase='{phase}'", + s.tenant + ), + ) + .await +} + +/// Read a `payment_settlement` counter column for `(tenant, payment_id)` — the cap +/// basis Group C maintains (here used to assert a HELD refund moved NO cap). +async fn settlement_counter( + raw: &DatabaseConnection, + s: &Seller, + payment_id: &str, + col: &str, +) -> Option { + scalar_i64( + raw, + &format!( + "SELECT {col} FROM bss.ledger_payment_settlement \ + WHERE tenant_id='{}' AND payment_id='{payment_id}'", + s.tenant + ), + ) + .await +} + +/// The `clearing_state` of a `(psp_refund_id, phase)` refund row (used to confirm a +/// HELD refund left NO posted row at all — `None`). +async fn refund_clearing_state( + raw: &DatabaseConnection, + s: &Seller, + psp: &str, + phase: &str, +) -> Option { + raw.query_one(pg(format!( + "SELECT clearing_state FROM bss.ledger_refund \ + WHERE tenant_id='{}' AND psp_refund_id='{psp}' AND phase='{phase}'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +/// `count(*)` of `REFUND_DISPUTE_HOLD` ENGINE dedup rows in `status` for the tenant, +/// keyed on the refund's `(psp_refund_id, phase)` business id. The hold intake +/// claims this row `QUEUED` before inserting the work-state queue row. +async fn dispute_hold_dedup_rows( + raw: &DatabaseConnection, + s: &Seller, + psp: &str, + phase: &str, + status: &str, +) -> i64 { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_idempotency_dedup \ + WHERE tenant_id='{}' AND flow='REFUND_DISPUTE_HOLD' \ + AND business_id='{psp}:{phase}' AND status='{status}'", + s.tenant + ), + ) + .await + .unwrap_or(0) +} + +/// `count(*)` of `REFUND_DISPUTE_HOLD` work-state QUEUE rows in `status` for the +/// tenant (the durable hold the drain later re-reads). +async fn dispute_hold_queue_rows(raw: &DatabaseConnection, s: &Seller, status: &str) -> i64 { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_pending_event_queue \ + WHERE tenant_id='{}' AND flow='REFUND_DISPUTE_HOLD' AND status='{status}'", + s.tenant + ), + ) + .await + .unwrap_or(0) +} + +/// Seed an OPEN dispute on `payment_id` directly (the simplest reliable way to put +/// the origin payment sub judice). `last_phase = 'OPENED'` is what +/// `read_open_dispute_for_payment` filters on; `variant = 'CASH_HOLD'` + +/// `cash_hold_minor <= disputed_amount_minor` satisfy the table CHECKs. +async fn open_dispute( + raw: &DatabaseConnection, + s: &Seller, + dispute_id: &str, + payment_id: &str, + disputed: i64, +) { + raw.execute(pg(format!( + "INSERT INTO bss.ledger_dispute \ + (tenant_id, dispute_id, payment_id, currency, variant, last_phase, cycle, \ + disputed_amount_minor, cash_hold_minor, version) \ + VALUES ('{}','{dispute_id}','{payment_id}','USD','CASH_HOLD','OPENED',1,{disputed},{disputed},0)", + s.tenant + ))) + .await + .unwrap(); +} + +/// Resolve an existing dispute to a terminal `last_phase` (e.g. `WON`) — flips the +/// row so a subsequent `read_open_dispute_for_payment` finds NO open dispute. +async fn resolve_dispute(raw: &DatabaseConnection, s: &Seller, dispute_id: &str, last_phase: &str) { + raw.execute(pg(format!( + "UPDATE bss.ledger_dispute SET last_phase='{last_phase}', version = version + 1 \ + WHERE tenant_id='{}' AND dispute_id='{dispute_id}'", + s.tenant + ))) + .await + .unwrap(); +} + +fn refund_req( + s: &Seller, + refund_id: &str, + psp_refund_id: &str, + payment_id: &str, + pattern: RefundPattern, + phase: RefundPhase, + invoice_id: Option<&str>, + amount: i64, +) -> RefundRequest { + RefundRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + refund_id: refund_id.to_owned(), + psp_refund_id: psp_refund_id.to_owned(), + phase, + pattern, + payment_id: payment_id.to_owned(), + invoice_id: invoice_id.map(ToOwned::to_owned), + currency: "USD".to_owned(), + amount_minor: amount, + two_stage: true, + // First-order OUTBOUND refund by default; the claw-back test builds the + // `Clawback` variant via `clawback_req`. + relates_to_refund_id: None, + direction: RefundDirection::Outbound, + } +} + +/// A refund-of-refund CLAW-BACK request: references a prior refund + the `Clawback` +/// direction (money-IN). `validate_shape` requires the `relates_to_refund_id` link +/// for a `Clawback`. Mirrors `postgres_refund.rs`'s `clawback_req`. +#[allow(clippy::too_many_arguments)] +fn clawback_req( + s: &Seller, + refund_id: &str, + psp_refund_id: &str, + payment_id: &str, + pattern: RefundPattern, + phase: RefundPhase, + invoice_id: Option<&str>, + amount: i64, + relates_to: &str, +) -> RefundRequest { + RefundRequest { + relates_to_refund_id: Some(relates_to.to_owned()), + direction: RefundDirection::Clawback, + ..refund_req( + s, + refund_id, + psp_refund_id, + payment_id, + pattern, + phase, + invoice_id, + amount, + ) + } +} + +/// A forward stage-1 `initiated` refund on a payment with an OPEN dispute is HELD: +/// it returns `RefundDisputeHeld`, claims the `REFUND_DISPUTE_HOLD` dedup row + +/// inserts the work-state queue row, and posts NOTHING (no `REFUND_CLEARING` +/// balance, no `refund` record row, the money-out cap unchanged). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn forward_refund_on_payment_with_open_dispute_is_held() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle 1000 → UNALLOCATED holds 1000 (CR), CASH_CLEARING holds 1000 (DR). + settle(&provider, &s, "PAY-DH", 1000).await; + assert_eq!(bal(&raw, &s, s.unallocated).await, Some(1000)); + + // Open a dispute on PAY-DH (the disputed funds are now sub judice). + open_dispute(&raw, &s, "DISP-DH", "PAY-DH", 1000).await; + + // A forward stage-1 `initiated` refund of 300 must be HELD (not posted). + let err = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-DH1", + "PSP-DH", + "PAY-DH", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect_err("a forward refund on a payment with an OPEN dispute must be held"); + assert!( + matches!(err, DomainError::RefundDisputeHeld(_)), + "expected RefundDisputeHeld, got {err:?}" + ); + + // The hold is DURABLE: the engine dedup row is QUEUED and the work-state queue + // row landed (both under the REFUND_DISPUTE_HOLD flow, keyed on PSP-DH:initiated). + assert_eq!( + dispute_hold_dedup_rows(&raw, &s, "PSP-DH", "initiated", "QUEUED").await, + 1, + "the hold claimed exactly one QUEUED REFUND_DISPUTE_HOLD dedup row" + ); + assert_eq!( + dispute_hold_queue_rows(&raw, &s, "QUEUED").await, + 1, + "the hold inserted exactly one QUEUED work-state queue row" + ); + + // NOTHING posted: no REFUND_CLEARING balance, no `refund` record row, the + // money-out cap untouched (the books did not move). + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + None, + "a held refund opens NO REFUND_CLEARING balance" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(1000), + "a held refund does NOT draw the UNALLOCATED pool down" + ); + assert_eq!( + refund_rows(&raw, &s, "PSP-DH", "initiated").await, + Some(0), + "a held refund persists NO `refund` record row" + ); + assert_eq!( + refund_clearing_state(&raw, &s, "PSP-DH", "initiated").await, + None, + "a held refund has no clearing_state (no row at all)" + ); + assert_eq!( + settlement_counter(&raw, &s, "PAY-DH", "refunded_minor").await, + Some(0), + "a held refund reserves NO money-out cap (refunded_minor unchanged)" + ); + assert_eq!( + settlement_counter(&raw, &s, "PAY-DH", "refunded_unallocated_minor").await, + Some(0), + "a held refund moves NO spendable-headroom cap either" + ); + + // Sanity: once the dispute resolves WON, the gate no longer holds — a fresh + // forward refund posts inline (this is the same gated path the hold drain + // re-drives through). Proves the hold is specifically the OPEN-dispute state, + // not a permanent block on the payment. + resolve_dispute(&raw, &s, "DISP-DH", "WON").await; + refund_handler(&provider) + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-DH2", + "PSP-DH-WON", + "PAY-DH", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await + .expect("with the dispute resolved WON the forward refund posts inline"); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(300), + "post-resolution the stage-1 REFUND_CLEARING balance opens" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(700), + "post-resolution UNALLOCATED is drawn down by the now-allowed refund" + ); +} + +/// A CLAW-BACK (money-IN refund-of-refund) on the SAME open-dispute setup is NOT +/// dispute-held: `is_dispute_holdable` is false for a claw-back, so the hold gate is +/// skipped and the claw-back falls through to its own defer path +/// (`RefundClawbackDeferred`, because no matching outbound refund exists to +/// decrement). The key assertion is the NEGATIVE: it is NOT `RefundDisputeHeld`, and +/// NO `REFUND_DISPUTE_HOLD` row is created. This locks the money-IN exclusion. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn clawback_on_payment_with_open_dispute_is_not_held() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + settle(&provider, &s, "PAY-CBDH", 1000).await; + // Same OPEN-dispute setup as the held test. + open_dispute(&raw, &s, "DISP-CBDH", "PAY-CBDH", 1000).await; + + // A claw-back stage-1 (money-IN) referencing a prior refund. There is no matching + // outbound refund stage-1, so the money-out decrement would underflow → the + // handler DEFERS it (Group E). Crucially it is NOT dispute-held: the claw-back + // skips the hold gate entirely (money-IN does not pay the customer). + let err = refund_handler(&provider) + .post_refund( + &ctx, + &scope, + clawback_req( + &s, + "RF-CBDH1", + "PSP-CBDH", + "PAY-CBDH", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 400, + "RF-PRIOR-OUTBOUND", + ), + ) + .await + .expect_err("a claw-back with no matching outbound refund defers"); + + // The money-IN exclusion: a claw-back is NEVER dispute-held, even with an OPEN + // dispute on the origin payment. + assert!( + !matches!(err, DomainError::RefundDisputeHeld(_)), + "a claw-back must NOT be dispute-held (money-IN is excluded), got {err:?}" + ); + // It took a NON-held path (an Err, not a posted refund). By design the money-IN + // outcome is the underflow defer (`RefundClawbackDeferred`): the refund + // sidecar's cap/underflow CHECK now runs BEFORE balance projection (the + // `run_before_projection` fix), so an out-of-order claw-back surfaces the + // canonical defer, not a raw `NegativeBalance` from the projector's no-negative + // guard. + assert!( + matches!(err, DomainError::RefundClawbackDeferred(_)), + "expected the money-IN underflow defer (RefundClawbackDeferred), got {err:?}" + ); + + // No dispute-hold artefacts were created (neither the dedup nor the queue row): + // the hold gate never fired for the claw-back. + assert_eq!( + dispute_hold_dedup_rows(&raw, &s, "PSP-CBDH", "initiated", "QUEUED").await, + 0, + "a claw-back creates NO REFUND_DISPUTE_HOLD dedup row" + ); + assert_eq!( + dispute_hold_queue_rows(&raw, &s, "QUEUED").await, + 0, + "a claw-back creates NO REFUND_DISPUTE_HOLD work-state queue row" + ); +} + +// --------------------------------------------------------------------------- +// §5 — dispute-hold DRAIN (Z5-2): the `drain_dispute_hold → apply_dispute_hold` +// state machine that had ZERO coverage. The five terminal shapes: WON re-drives +// + posts (`Released`), LOST cancels + never posts (`Cancelled`, double-pay +// guard), still-OPEN backs off (`StillDisputed`), aged-out escalates +// (`Escalated`), WON-but-over-D2 awaits approval (`AwaitingApproval`). +// --------------------------------------------------------------------------- + +/// An `ApprovalExecutor` that replays the held refund through the un-gated +/// `RefundHandler` (`post_refund_approved`) — the real Group-D executor arm. +/// Mirrors `postgres_refund.rs::RefundReplayExecutor`. +#[derive(Clone)] +struct RefundReplayExecutor { + refund: Arc, + calls: Arc, +} + +#[async_trait::async_trait] +impl ApprovalExecutor for RefundReplayExecutor { + async fn execute( + &self, + ctx: &SecurityContext, + scope: &AccessScope, + intent: &ApprovalIntent, + ) -> Result<(), DomainError> { + match intent { + ApprovalIntent::Refund(i) => { + let req = RefundRequest::try_from(i)?; + self.refund.post_refund_approved(ctx, scope, req).await?; + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + other => Err(DomainError::Internal(format!( + "unexpected intent in refund replay test: {other:?}" + ))), + } + } +} + +/// An authed `SecurityContext` for `subject` in `tenant` (the dual-control engine +/// reads `subject_id` / `subject_tenant_id` for the preparer/approver identity). +fn dc_ctx(subject: Uuid, tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(subject) + .subject_tenant_id(tenant) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// Build the gated handler + the approval service sharing the un-gated replay handler +/// over the same provider. Mirrors `postgres_refund.rs::dual_control_wiring`. +fn dual_control_wiring( + provider: &DBProvider, +) -> (RefundHandler, Arc, RefundReplayExecutor) { + let replay_handler = Arc::new(RefundHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + )); + let exec = RefundReplayExecutor { + refund: Arc::clone(&replay_handler), + calls: Arc::new(AtomicUsize::new(0)), + }; + let svc = Arc::new(ApprovalService::new( + provider.clone(), + Arc::new(exec.clone()), + Arc::new(NoopLedgerMetrics), + bss_ledger::config::FxConfig::default(), + )); + let gated = RefundHandler::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())) + .with_approval(Arc::clone(&svc)); + (gated, svc, exec) +} + +/// `count(*)` of PENDING REFUND approvals for the tenant. +async fn pending_refund_approvals(raw: &DatabaseConnection, s: &Seller) -> i64 { + scalar_i64( + raw, + &format!( + "SELECT count(*) FROM bss.ledger_approval \ + WHERE tenant_id='{}' AND kind='REFUND' AND state='PENDING'", + s.tenant + ), + ) + .await + .unwrap_or(0) +} + +/// Force the REFUND_DISPUTE_HOLD queue row to look aged: backdate `queued_at` well +/// past the 30-day dispute-hold aging horizon AND clear `apply_after`. Mirrors +/// `postgres_refund.rs::age_clawback_row`. +async fn age_dispute_hold_row(raw: &DatabaseConnection, s: &Seller) { + raw.execute(pg(format!( + "UPDATE bss.ledger_pending_event_queue \ + SET queued_at = now() - interval '60 days', apply_after = NULL \ + WHERE tenant_id='{}' AND flow='REFUND_DISPUTE_HOLD'", + s.tenant + ))) + .await + .unwrap(); +} + +/// Hold a forward refund behind an OPEN dispute, then resolve it WON: the next drain +/// re-reads the dispute, re-drives the (now-allowed) refund through the gated path, +/// posts it, and flips the hold row `→APPLIED` (`Released`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn dispute_hold_drain_won_redrives_and_posts() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + settle(&provider, &s, "PAY-WON", 1000).await; + open_dispute(&raw, &s, "DISP-WON", "PAY-WON", 1000).await; + + // Forward refund on the disputed payment is HELD (nothing posts). + let held = handler + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-WON", + "PSP-WON", + "PAY-WON", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await; + assert!(matches!(held, Err(DomainError::RefundDisputeHeld(_)))); + assert_eq!(dispute_hold_queue_rows(&raw, &s, "QUEUED").await, 1); + assert_eq!(bal(&raw, &s, s.refund_clearing).await, None); + + // The dispute resolves WON (the payment stands — the refund is owed). + resolve_dispute(&raw, &s, "DISP-WON", "WON").await; + + let report = handler + .drain_dispute_hold(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.released, 1, "the WON-resolved refund posted"); + assert_eq!(report.still_disputed, 0); + assert_eq!(report.cancelled, 0); + assert_eq!( + dispute_hold_queue_rows(&raw, &s, "APPLIED").await, + 1, + "the hold row flipped →APPLIED" + ); + assert_eq!(dispute_hold_queue_rows(&raw, &s, "QUEUED").await, 0); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + Some(300), + "the released stage-1 opened REFUND_CLEARING" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(700), + "UNALLOCATED drawn down by the now-posted refund" + ); + assert_eq!(refund_rows(&raw, &s, "PSP-WON", "initiated").await, Some(1)); +} + +/// Hold a forward refund behind an OPEN dispute, then resolve it LOST: the drain +/// CANCELS the hold and NEVER posts (a lost chargeback already returned the money — +/// posting the refund too would double-pay). The Critical `RefundQuarantined` alarm +/// fires via the noop publisher; the durable CANCELLED transition is the assertion. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn dispute_hold_drain_lost_cancels_never_posts() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + settle(&provider, &s, "PAY-LOST", 1000).await; + open_dispute(&raw, &s, "DISP-LOST", "PAY-LOST", 1000).await; + + let held = handler + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-LOST", + "PSP-LOST", + "PAY-LOST", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await; + assert!(matches!(held, Err(DomainError::RefundDisputeHeld(_)))); + assert_eq!(dispute_hold_queue_rows(&raw, &s, "QUEUED").await, 1); + + // The dispute resolves LOST (a chargeback already returned the money). + resolve_dispute(&raw, &s, "DISP-LOST", "LOST").await; + + let report = handler + .drain_dispute_hold(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.cancelled, 1, "a LOST dispute cancels the hold"); + assert_eq!(report.released, 0, "a LOST dispute NEVER posts the refund"); + assert_eq!( + dispute_hold_queue_rows(&raw, &s, "CANCELLED").await, + 1, + "the hold row flipped →CANCELLED" + ); + assert_eq!(dispute_hold_queue_rows(&raw, &s, "QUEUED").await, 0); + // Double-pay guard: nothing posted, the books are untouched. + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + None, + "a LOST-cancelled refund posts nothing" + ); + assert_eq!( + bal(&raw, &s, s.unallocated).await, + Some(1000), + "the UNALLOCATED pool is untouched" + ); + assert_eq!( + refund_rows(&raw, &s, "PSP-LOST", "initiated").await, + Some(0) + ); +} + +/// Hold a forward refund behind an OPEN dispute that is STILL open at drain time: the +/// drain backs off (`StillDisputed`), the row stays `QUEUED`, the cash stays held. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn dispute_hold_drain_still_open_backs_off() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + settle(&provider, &s, "PAY-OPEN", 1000).await; + open_dispute(&raw, &s, "DISP-OPEN", "PAY-OPEN", 1000).await; + + let held = handler + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-OPEN", + "PSP-OPEN", + "PAY-OPEN", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await; + assert!(matches!(held, Err(DomainError::RefundDisputeHeld(_)))); + assert_eq!(dispute_hold_queue_rows(&raw, &s, "QUEUED").await, 1); + + // Drain WITHOUT resolving: the dispute is still OPENED, the row is fresh ⇒ back off. + let report = handler + .drain_dispute_hold(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.still_disputed, 1, "a still-OPEN dispute backs off"); + assert_eq!(report.released, 0); + assert_eq!(report.cancelled, 0); + assert_eq!(report.escalated, 0); + assert_eq!( + dispute_hold_queue_rows(&raw, &s, "QUEUED").await, + 1, + "the row stays QUEUED (the cash stays held)" + ); + assert_eq!(bal(&raw, &s, s.refund_clearing).await, None); +} + +/// Hold a forward refund behind an OPEN dispute that NEVER resolves past the 30-day +/// aging horizon: the drain CANCELS the hold + escalates (`Escalated`). NEVER posts +/// (the dispute is still OPEN). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn dispute_hold_aged_out_escalates() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let handler = refund_handler(&provider); + + settle(&provider, &s, "PAY-AGE", 1000).await; + open_dispute(&raw, &s, "DISP-AGE", "PAY-AGE", 1000).await; + + let held = handler + .post_refund( + &ctx, + &scope, + refund_req( + &s, + "RF-AGE", + "PSP-AGE", + "PAY-AGE", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 300, + ), + ) + .await; + assert!(matches!(held, Err(DomainError::RefundDisputeHeld(_)))); + assert_eq!(dispute_hold_queue_rows(&raw, &s, "QUEUED").await, 1); + + // Age the hold row past 30 days; the dispute is STILL OPEN ⇒ aged-out escalate. + age_dispute_hold_row(&raw, &s).await; + let report = handler + .drain_dispute_hold(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!(report.escalated, 1, "the never-resolved hold escalated"); + assert_eq!(report.released, 0); + assert_eq!(report.cancelled, 0); + assert_eq!( + dispute_hold_queue_rows(&raw, &s, "CANCELLED").await, + 1, + "the aged-out hold row flipped →CANCELLED" + ); + assert_eq!(dispute_hold_queue_rows(&raw, &s, "QUEUED").await, 0); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + None, + "an aged-out hold posts nothing" + ); +} + +/// Hold an OVER-D2 forward refund behind an OPEN dispute, then resolve it WON: the +/// gated drain re-drives but the refund now crosses D2 ⇒ it opens an approval +/// (`AwaitingApproval`) and the row stays `QUEUED` — it NEVER auto-posts over threshold. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn dispute_hold_won_over_threshold_awaits_approval() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider, s) = setup(&url).await; + // The drain re-drives through the GATED post path, where the gate creates a + // PENDING approval keyed on the acting subject — so the drive MUST carry an + // AUTHED context (an anonymous ctx has no subject and the gate no-ops). + let ctx = dc_ctx(Uuid::now_v7(), s.tenant); + let scope = AccessScope::for_tenant(s.tenant); + // The GATED handler holds at intake (dispute) and re-drives over D2 at drain. + let (gated, _svc, _exec) = dual_control_wiring(&provider); + + settle(&provider, &s, "PAY-WONDC", 200_000).await; + open_dispute(&raw, &s, "DISP-WONDC", "PAY-WONDC", 200_000).await; + + // A 150_000 forward refund is HELD by the dispute (the hold precedes the gate). + let held = gated + .post_refund( + &dc_ctx(Uuid::now_v7(), s.tenant), + &scope, + refund_req( + &s, + "RF-WONDC", + "PSP-WONDC", + "PAY-WONDC", + RefundPattern::AUnallocated, + RefundPhase::Initiated, + None, + 150_000, + ), + ) + .await; + assert!(matches!(held, Err(DomainError::RefundDisputeHeld(_)))); + assert_eq!(dispute_hold_queue_rows(&raw, &s, "QUEUED").await, 1); + + // Dispute resolves WON; the gated drain re-drives → 150_000 > 100_000 D2 ⇒ an + // approval opens, the row stays QUEUED (never auto-posts over threshold). + resolve_dispute(&raw, &s, "DISP-WONDC", "WON").await; + let report = gated + .drain_dispute_hold(&ctx, &scope, s.tenant, 100) + .await + .expect("drain succeeds"); + assert_eq!( + report.awaiting_approval, 1, + "an over-threshold WON release awaits approval" + ); + assert_eq!(report.released, 0); + assert_eq!( + pending_refund_approvals(&raw, &s).await, + 1, + "the gated drain opened exactly one PENDING REFUND approval" + ); + assert_eq!( + dispute_hold_queue_rows(&raw, &s, "QUEUED").await, + 1, + "the row stays QUEUED — it never auto-posts over threshold" + ); + assert_eq!( + bal(&raw, &s, s.refund_clearing).await, + None, + "nothing posts while awaiting approval" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_refund_fx.rs b/gears/bss/ledger/ledger/tests/postgres_refund_fx.rs new file mode 100644 index 000000000..6f907bf6a --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_refund_fx.rs @@ -0,0 +1,487 @@ +//! Postgres-only integration (Slice 5, Phase 2 F2): **functional carry-forward on +//! a cross-currency refund close**, driven through the REAL orchestrators +//! (`SettlementService` / `RefundHandler`) against the foundation engine. +//! +//! A refund unwinds a position through the two-stage `REFUND_CLEARING` entirely in +//! the transaction currency (no in-ledger conversion), so the functional cost +//! basis carries forward and each stage's functional column nets to ZERO — there +//! is **no realized FX_GAIN_LOSS line** (true EUR→USD realization is Slice-7 +//! reconciliation, owner decision 2026-06-28). These tests prove the carry-forward +//! keeps each relieved grain's functional column in lockstep with `balance_minor`: +//! +//! - `pattern_a_two_stage_refund_carries_functional_forward_no_fx`: settle 120 EUR +//! @ 1.08 (UNALLOCATED + CASH_CLEARING each carry 129.60 USD); a Pattern-A refund +//! drains UNALLOCATED → REFUND_CLEARING at `initiated` (UNALLOCATED → (0,0), +//! REFUND_CLEARING → 129.60) then REFUND_CLEARING → CASH_CLEARING at `confirmed` +//! (both → (0,0)). The 129.60 USD basis flows out; no FX line. +//! - `pattern_a_single_step_refund_carries_functional_forward_no_fx`: a single-step +//! refund posts `DR UNALLOCATED · CR CASH_CLEARING` in one move; both close to +//! (0,0); no FX line. +//! +//! Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use bss_ledger::config::FxConfig; +use bss_ledger::domain::adjustment::refund::{RefundDirection, RefundRequest}; +use bss_ledger::domain::adjustment::refund::{RefundPattern, RefundPhase}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::adjustment::refund_service::RefundHandler; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::fx::rate_locker::RateLocker; +use bss_ledger::infra::fx::rate_source::RateSource; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{FxRepo, NewFxRate, ReferenceRepo}; +use bss_ledger_sdk::AccountClass; +use chrono::{Datelike, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: &str, + currency: &str, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: currency.to_owned(), + revenue_stream: None, + normal_side: normal.to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +fn rate_locker(provider: &DBProvider, fx_config: &FxConfig) -> RateLocker { + RateLocker::new( + RateSource::new(FxRepo::new(provider.clone()), fx_config.clone()), + FxRepo::new(provider.clone()), + ) +} + +fn fx_config() -> FxConfig { + FxConfig { + provider_order: vec!["ecb".to_owned()], + ..FxConfig::default() + } +} + +struct Chart { + tenant: Uuid, + payer: Uuid, + unallocated: Uuid, + cash: Uuid, + refund_clearing: Uuid, + fx_gl: Uuid, +} + +/// Boot + provision a USD-functional / EUR-billing seller with the refund chart +/// (UNALLOCATED, CASH_CLEARING, REFUND_CLEARING in EUR; FX_GAIN_LOSS in USD), the +/// functional-currency source, an OPEN current-month period, and an EUR→USD @ 1.08 +/// rate; then settle `gross` EUR cross-currency so UNALLOCATED + CASH_CLEARING each +/// carry a functional balance. +async fn setup_and_settle( + gross: i64, + payment_id: &str, +) -> ( + testcontainers_modules::testcontainers::ContainerAsync, + DatabaseConnection, + DBProvider, + Chart, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let provider = + DBProvider::::new(connect_db(&repo_url, ConnectOpts::default()).await.unwrap()); + + let c = Chart { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + cash: Uuid::now_v7(), + refund_clearing: Uuid::now_v7(), + fx_gl: Uuid::now_v7(), + }; + let now = Utc::now(); + let period_id = format!("{:04}{:02}", now.year(), now.month()); + + let reference = ReferenceRepo::new(provider.clone()); + for ccy in ["EUR", "USD"] { + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: c.tenant, + currency: ccy.to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + } + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_calendar + (tenant_id, legal_entity_id, fiscal_tz, granularity, fy_start_month, functional_currency) + VALUES ('{}','{}','UTC','MONTH',1,'USD')", + c.tenant, c.tenant + ))) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{period_id}','UTC','OPEN')", + c.tenant, c.tenant + ))) + .await + .unwrap(); + for row in [ + account( + c.tenant, + c.unallocated, + AccountClass::Unallocated, + "CR", + "EUR", + ), + account(c.tenant, c.cash, AccountClass::CashClearing, "DR", "EUR"), + account( + c.tenant, + c.refund_clearing, + AccountClass::RefundClearing, + "CR", + "EUR", + ), + account(c.tenant, c.fx_gl, AccountClass::FxGainLoss, "DR", "USD"), + ] { + reference.insert_account(row).await.unwrap(); + } + FxRepo::new(provider.clone()) + .upsert_rate(&NewFxRate { + tenant_id: c.tenant, + base_currency: "EUR".to_owned(), + quote_currency: "USD".to_owned(), + provider: "ecb".to_owned(), + rate_micro: 1_080_000, + as_of: now, + fallback_order: 0, + }) + .await + .unwrap(); + + // Settle the receipt cross-currency @ 1.08 → UNALLOCATED + CASH_CLEARING carry. + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .with_fx(rate_locker(&provider, &fx_config())) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(c.tenant), + SettlementInput { + tenant_id: c.tenant, + payer_tenant_id: c.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: 0, + currency: "EUR".to_owned(), + effective_at: None, + }, + ) + .await + .expect("cross-currency settle must post"); + + (container, raw, provider, c) +} + +async fn acct(raw: &DatabaseConnection, tenant: Uuid, account: Uuid) -> (Option, Option) { + let bal = scalar_i64(raw, &format!( + "SELECT balance_minor FROM bss.ledger_account_balance WHERE tenant_id='{tenant}' AND account_id='{account}'" + )).await; + let func = scalar_i64(raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_account_balance WHERE tenant_id='{tenant}' AND account_id='{account}'" + )).await; + (bal, func) +} + +async fn unalloc( + raw: &DatabaseConnection, + tenant: Uuid, + payer: Uuid, +) -> (Option, Option) { + let bal = scalar_i64(raw, &format!( + "SELECT balance_minor FROM bss.ledger_unallocated_balance WHERE tenant_id='{tenant}' AND payer_tenant_id='{payer}'" + )).await; + let func = scalar_i64(raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_unallocated_balance WHERE tenant_id='{tenant}' AND payer_tenant_id='{payer}'" + )).await; + (bal, func) +} + +async fn entry_functional_net(raw: &DatabaseConnection, tenant: Uuid, entry: Uuid) -> Option { + scalar_i64(raw, &format!( + "SELECT COALESCE(SUM(CASE WHEN side='DR' THEN functional_amount_minor ELSE -functional_amount_minor END),0)::bigint \ + FROM bss.ledger_journal_line WHERE tenant_id='{tenant}' AND entry_id='{entry}'" + )).await +} + +async fn fx_line_count(raw: &DatabaseConnection, tenant: Uuid, fx_gl: Uuid) -> Option { + scalar_i64(raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_line WHERE tenant_id='{tenant}' AND account_id='{fx_gl}'" + )).await +} + +fn refund_req( + c: &Chart, + payment_id: &str, + psp_refund_id: &str, + phase: RefundPhase, + amount: i64, + two_stage: bool, +) -> RefundRequest { + RefundRequest { + tenant_id: c.tenant, + payer_tenant_id: c.payer, + // The `refund` row PK is (tenant, refund_id) — one row per phase; the + // idempotency grain is (psp_refund_id, phase). So each phase carries its own + // refund_id while sharing the psp_refund_id. + refund_id: format!("R-{psp_refund_id}-{}", phase.as_str()), + psp_refund_id: psp_refund_id.to_owned(), + phase, + pattern: RefundPattern::AUnallocated, + payment_id: payment_id.to_owned(), + invoice_id: None, + currency: "EUR".to_owned(), + amount_minor: amount, + two_stage, + relates_to_refund_id: None, + direction: RefundDirection::Outbound, + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn pattern_a_two_stage_refund_carries_functional_forward_no_fx() { + let (_c, raw, provider, chart) = setup_and_settle(12_000, "PAY-RF-1").await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(chart.tenant); + let handler = RefundHandler::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + + // Settle landed UNALLOCATED + CASH_CLEARING at 120.00 EUR / 129.60 USD each. + assert_eq!( + unalloc(&raw, chart.tenant, chart.payer).await, + (Some(12_000), Some(12_960)) + ); + assert_eq!( + acct(&raw, chart.tenant, chart.cash).await, + (Some(12_000), Some(12_960)) + ); + + // initiated: DR UNALLOCATED 12000 / CR REFUND_CLEARING 12000 — the 129.60 USD + // basis carries forward UNALLOCATED → REFUND_CLEARING. + let initiated = handler + .post_refund( + &ctx, + &scope, + refund_req( + &chart, + "PAY-RF-1", + "PSP-RF-1", + RefundPhase::Initiated, + 12_000, + true, + ), + ) + .await + .expect("initiated refund must post"); + assert_eq!( + entry_functional_net(&raw, chart.tenant, initiated.entry_id).await, + Some(0), + "initiated entry functional balances (carry-forward, no FX)" + ); + assert_eq!( + unalloc(&raw, chart.tenant, chart.payer).await, + (Some(0), Some(0)), + "UNALLOCATED drained to (0, 0) — basis left" + ); + assert_eq!( + acct(&raw, chart.tenant, chart.refund_clearing).await, + (Some(12_000), Some(12_960)), + "REFUND_CLEARING carries the 129.60 USD basis forward" + ); + + // confirmed: DR REFUND_CLEARING 12000 / CR CASH_CLEARING 12000 — basis leaves. + let confirmed = handler + .post_refund( + &ctx, + &scope, + refund_req( + &chart, + "PAY-RF-1", + "PSP-RF-1", + RefundPhase::Confirmed, + 12_000, + true, + ), + ) + .await + .expect("confirmed refund must post"); + assert_eq!( + entry_functional_net(&raw, chart.tenant, confirmed.entry_id).await, + Some(0), + "confirmed entry functional balances (carry-forward, no FX)" + ); + assert_eq!( + acct(&raw, chart.tenant, chart.refund_clearing).await, + (Some(0), Some(0)), + "REFUND_CLEARING drained to (0, 0)" + ); + assert_eq!( + acct(&raw, chart.tenant, chart.cash).await, + (Some(0), Some(0)), + "CASH_CLEARING drained to (0, 0) — the full settle→refund round-trip nets to zero in both columns" + ); + + // No realized FX line anywhere (the FX_GAIN_LOSS account exists but is untouched). + assert_eq!( + fx_line_count(&raw, chart.tenant, chart.fx_gl).await, + Some(0), + "a refund posts NO FX_GAIN_LOSS line (carry-forward, not realized FX)" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn pattern_a_single_step_refund_carries_functional_forward_no_fx() { + let (_c, raw, provider, chart) = setup_and_settle(12_000, "PAY-RF-2").await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(chart.tenant); + let handler = RefundHandler::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + + // Single-step: DR UNALLOCATED 12000 / CR CASH_CLEARING 12000 in one `initiated` + // move (two_stage = false) — carry-forward at UNALLOCATED's carried WAC. + let posted = handler + .post_refund( + &ctx, + &scope, + refund_req( + &chart, + "PAY-RF-2", + "PSP-RF-2", + RefundPhase::Initiated, + 12_000, + false, + ), + ) + .await + .expect("single-step refund must post"); + assert_eq!( + entry_functional_net(&raw, chart.tenant, posted.entry_id).await, + Some(0), + "single-step entry functional balances (carry-forward, no FX)" + ); + assert_eq!( + unalloc(&raw, chart.tenant, chart.payer).await, + (Some(0), Some(0)), + "UNALLOCATED drained to (0, 0)" + ); + assert_eq!( + acct(&raw, chart.tenant, chart.cash).await, + (Some(0), Some(0)), + "CASH_CLEARING drained to (0, 0) in both columns" + ); + assert_eq!( + fx_line_count(&raw, chart.tenant, chart.fx_gl).await, + Some(0), + "single-step refund posts NO FX_GAIN_LOSS line" + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_currency_clawback_is_rejected_not_silently_drifted() { + // A refund-of-refund CLAW-BACK restores a drawn-down position at the PRIOR + // refund's rate, which the WAC carry-forward cannot source (Slice 7). Until then + // a CROSS-CURRENCY claw-back must be REJECTED up front (`FxOperationUnsupported`) + // — never silently posted functional-NULL (the drift this remediation closes). + // A cross-currency pool is in play here (settle landed UNALLOCATED with a + // functional balance), so the stamp's cross-currency detect fires and rejects. + let (_c, raw, provider, chart) = setup_and_settle(12_000, "PAY-RF-CB").await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(chart.tenant); + let handler = RefundHandler::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + + let clawback = RefundRequest { + tenant_id: chart.tenant, + payer_tenant_id: chart.payer, + refund_id: "R-CB-1-initiated".to_owned(), + psp_refund_id: "PSP-CB-1".to_owned(), + phase: RefundPhase::Initiated, + pattern: RefundPattern::AUnallocated, + payment_id: "PAY-RF-CB".to_owned(), + invoice_id: None, + currency: "EUR".to_owned(), + amount_minor: 12_000, + two_stage: false, + // A claw-back references a prior refund (refund-of-refund); the link + the + // Clawback direction make `is_clawback()` true. + relates_to_refund_id: Some("R-PRIOR".to_owned()), + direction: RefundDirection::Clawback, + }; + let err = handler + .post_refund(&ctx, &scope, clawback) + .await + .expect_err("a cross-currency claw-back must be rejected, not posted"); + assert!( + matches!(err, DomainError::FxOperationUnsupported(_)), + "expected FxOperationUnsupported, got {err:?}" + ); + + // And nothing posted: UNALLOCATED still carries the full settled basis (the + // reject fired BEFORE any ledger effect — no silent drift). + assert_eq!( + unalloc(&raw, chart.tenant, chart.payer).await, + (Some(12_000), Some(12_960)), + "UNALLOCATED untouched — the claw-back was rejected before posting" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_retention.rs b/gears/bss/ledger/ledger/tests/postgres_retention.rs new file mode 100644 index 000000000..7927eed95 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_retention.rs @@ -0,0 +1,267 @@ +//! Postgres-only end-to-end for the dormant retention seam (Slice 6 Phase 4 +//! Group 4B): the `chain_checkpoint` writer + the §4.8/E-5 partition detach +//! gate. Boots a container, migrates, then asserts: +//! (A) `CheckpointWriter::write_checkpoint` DERIVES the covered-entry count by +//! walking the chain range — a caller no longer supplies it — +//! and rejects a non-contiguous range; +//! (B) `DetachGate::may_detach` enforces BOTH §4.8 halves: the +//! period must be fully sealed AND every sealed entry must be covered by a +//! `chain_checkpoint` range. A sealed-but-uncovered period is blocked +//! (`uncovered_count`); a covered period passes; an unsealed entry blocks +//! (`unsealed_count`). +//! +//! Test choice: rather than drive the heavy `InvoicePostService`, entries are +//! raw-INSERTed directly into `bss.ledger_journal_entry` with the DEFERRABLE +//! balanced-entry trigger disabled (`trg_journal_entry_balanced`, the same +//! trigger `postgres_cross_tenant.rs` disables). The seam is a pure read over +//! `row_hash` / `prev_hash` / `created_seq`, so a hand-seeded chain exercises it +//! exactly and keeps the test fast and deterministic. +//! +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use bss_ledger::infra::retention::{CheckpointWriter, DetachGate}; +use bss_ledger::infra::storage::entity::chain_checkpoint; +use bss_ledger::infra::storage::migrations::Migrator; +use sea_orm::{ + ColumnTrait, Condition, ConnectionTrait, Database, DatabaseConnection, EntityTrait, Statement, +}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::{AccessScope, SecureEntityExt}; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn count(conn: &DatabaseConnection, sql: &str) -> i64 { + let row = conn.query_one(pg(sql.to_owned())).await.unwrap(); + row.map_or(0, |r| r.try_get_by_index::(0).unwrap()) +} + +/// Lowercase hex of a byte slice (for `decode(... ,'hex')` literals + Rust-side +/// hash values that must match the inserted bytea byte-for-byte). +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// A distinct 32-byte hash filled with `fill`. +fn h32(fill: u8) -> Vec { + vec![fill; 32] +} + +/// Boot a container, migrate the whole chain, and return the raw connection + +/// a scoped `DBProvider`. +async fn setup(url: &str) -> (DatabaseConnection, DBProvider) { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (raw, provider) +} + +/// Raw-INSERT one `journal_entry` header into `(tenant, period)` with the given +/// `row_hash` / `prev_hash` (`bytea` SQL literals, or `NULL`). `created_seq` is +/// auto-assigned (BIGSERIAL), so entries are ordered by insertion. The +/// balanced-entry trigger is disabled around the line-less insert. +async fn insert_entry( + raw: &DatabaseConnection, + tenant: Uuid, + period: &str, + row_hash_sql: &str, + prev_hash_sql: &str, +) { + let entry_id = Uuid::now_v7(); + let actor = Uuid::now_v7(); + let correlation = Uuid::now_v7(); + raw.execute(pg( + "ALTER TABLE bss.ledger_journal_entry DISABLE TRIGGER trg_journal_entry_balanced", + )) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, \ + source_doc_type, source_business_id, posted_at_utc, effective_at, \ + origin, posted_by_actor_id, correlation_id, rounding_evidence, \ + row_hash, prev_hash) \ + VALUES ('{entry_id}','{tenant}','{tenant}','{period}','USD', \ + 'INVOICE_POST','INV-RET', now(), '2026-06-01', \ + 'SYSTEM','{actor}','{correlation}', '{{}}'::jsonb, {row_hash_sql}, {prev_hash_sql})" + ))) + .await + .unwrap(); + raw.execute(pg( + "ALTER TABLE bss.ledger_journal_entry ENABLE TRIGGER trg_journal_entry_balanced", + )) + .await + .unwrap(); +} + +fn bytea(hash: &[u8]) -> String { + format!("decode('{}','hex')", hex(hash)) +} + +/// (A) `write_checkpoint` DERIVES `covered_entry_count` by walking the chain +/// range, and rejects a non-contiguous range. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn checkpoint_derives_count_and_rejects_noncontiguous() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + + let tenant = Uuid::now_v7(); + let period = "202606"; + let scope = AccessScope::for_tenant(tenant); + let writer = CheckpointWriter::new(provider.clone()); + + // A 3-link chain: h1 (genesis prev) ← h2 ← h3. + let (h1, h2, h3) = (h32(0xa1), h32(0xb2), h32(0xc3)); + insert_entry(&raw, tenant, period, &bytea(&h1), "NULL").await; + insert_entry(&raw, tenant, period, &bytea(&h2), &bytea(&h1)).await; + insert_entry(&raw, tenant, period, &bytea(&h3), &bytea(&h2)).await; + + // Checkpoint the full range h1..h3 → derived count = 3 (caller passes NO count). + let checkpoint_id = writer + .write_checkpoint(&scope, tenant, h1.clone(), h3.clone()) + .await + .expect("write_checkpoint over a contiguous range"); + + let total = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.chain_checkpoint \ + WHERE checkpoint_id='{checkpoint_id}' AND tenant_id='{tenant}' \ + AND covered_entry_count=3 AND signature IS NULL" + ), + ) + .await; + assert_eq!( + total, 1, + "covered_entry_count is DERIVED as 3, not caller-supplied" + ); + + // Read back through the secure ORM: range round-trips, signature unset. + let conn = writer.db().conn().expect("conn"); + let row = chain_checkpoint::Entity::find() + .secure() + .scope_with(&scope) + .filter(Condition::all().add(chain_checkpoint::Column::CheckpointId.eq(checkpoint_id))) + .one(&conn) + .await + .expect("read chain_checkpoint") + .expect("the checkpoint row is found under its tenant scope"); + assert_eq!(row.from_row_hash, h1, "from_row_hash round-trips"); + assert_eq!(row.to_row_hash, h3, "to_row_hash round-trips"); + assert_eq!(row.covered_entry_count, 3, "derived covered_entry_count"); + assert!(row.signature.is_none(), "an MVP checkpoint is unsigned"); + + // A sub-range h2..h3 derives count = 2. + let sub = writer + .write_checkpoint(&scope, tenant, h2.clone(), h3.clone()) + .await + .expect("sub-range checkpoint"); + let sub_ok = count( + &raw, + &format!( + "SELECT COUNT(*) FROM bss.chain_checkpoint \ + WHERE checkpoint_id='{sub}' AND covered_entry_count=2" + ), + ) + .await; + assert_eq!(sub_ok, 1, "sub-range derives count = 2"); + + // A non-contiguous range (from = an unknown hash) is rejected. + let unknown = h32(0xff); + let err = writer + .write_checkpoint(&scope, tenant, unknown, h3.clone()) + .await + .expect_err("a non-contiguous range must be rejected"); + assert!( + format!("{err:?}").contains("not contiguous"), + "non-contiguous range error: {err:?}" + ); +} + +/// (B) The detach gate enforces both §4.8 halves: sealed AND checkpoint-covered. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn detach_gate_requires_sealed_and_checkpoint_covered() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let (raw, provider) = setup(&url).await; + + let tenant = Uuid::now_v7(); + let period = "202606"; + let scope = AccessScope::for_tenant(tenant); + let writer = CheckpointWriter::new(provider.clone()); + let gate = DetachGate::new(provider.clone()); + + // Two sealed entries forming a chain in the period. + let (h1, h2) = (h32(0x11), h32(0x22)); + insert_entry(&raw, tenant, period, &bytea(&h1), "NULL").await; + insert_entry(&raw, tenant, period, &bytea(&h2), &bytea(&h1)).await; + + // Sealed but NOT yet checkpoint-covered → blocked, naming the uncovered count. + let err = gate + .may_detach(&scope, tenant, period) + .await + .expect_err("a sealed but un-anchored period must be blocked"); + assert_eq!(err.unsealed_count, 0, "all entries are sealed"); + assert_eq!( + err.uncovered_count, 2, + "neither entry is checkpoint-covered yet" + ); + assert!( + err.to_string().contains("PARTITION_DETACH_BLOCKED"), + "the Display references the alarm token: {err}" + ); + + // Write a checkpoint covering h1..h2 → both halves satisfied → detach allowed. + writer + .write_checkpoint(&scope, tenant, h1.clone(), h2.clone()) + .await + .expect("write covering checkpoint"); + gate.may_detach(&scope, tenant, period) + .await + .expect("a sealed + checkpoint-covered period may detach"); + + // Add one unsealed entry → the sealed half fails first (unsealed_count = 1). + insert_entry(&raw, tenant, period, "NULL", &bytea(&h2)).await; + let err = gate + .may_detach(&scope, tenant, period) + .await + .expect_err("an unsealed entry blocks detach"); + assert_eq!(err.unsealed_count, 1, "exactly one unsealed entry"); + assert_eq!(err.tenant_id, tenant, "the block names the tenant"); + assert_eq!(err.period_id, period, "the block names the period"); + + // A foreign-tenant scope sees no rows, so its (empty) period trivially passes + // (SQL-level BOLA — the rows are invisible to it). + let foreign = AccessScope::for_tenant(Uuid::now_v7()); + gate.may_detach(&foreign, tenant, period) + .await + .expect("a foreign scope sees no rows"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_revaluation_fx.rs b/gears/bss/ledger/ledger/tests/postgres_revaluation_fx.rs new file mode 100644 index 000000000..60910938d --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_revaluation_fx.rs @@ -0,0 +1,610 @@ +//! Postgres-only integration (Slice 5, Phase 3 H/I/J): **unrealized Mode-B +//! revaluation + next-period reversal** of a cross-currency monetary position, +//! driven through the real `UnrealizedRevaluationRun` against the foundation +//! engine — the design §4.5 worked example. +//! +//! A USD-**functional** seller (functional currency on its `fiscal_calendar`, +//! S5-F3) holds an open **EUR** receivable: +//! 1. post an EUR invoice @ 1.10 → AR carried 120.00 EUR / **132.00 USD**; +//! 2. at period end the rate is 1.05 → the AR is worth only 120.00 × 1.05 = +//! **126.00 USD**; +//! 3. **forward revaluation** (AR scope): the carrying value falls 6.00 USD → +//! **CR AR 6.00 / DR FX_UNREALIZED 6.00** (functional-only, `amount_minor = 0`), +//! moving the AR grain's functional balance to 126.00 and booking a 6.00 USD +//! unrealized loss; +//! 4. the period CLOSES and the next one opens; +//! 5. **reversal** (first of the next OPEN period, `FX_REVAL_REVERSAL`): the +//! negation **DR AR 6.00 / CR FX_UNREALIZED 6.00** restores the AR grain to its +//! historical 132.00 USD carried basis and unwinds the FX_UNREALIZED contra to +//! zero — only realized FX is permanent (decision 7). +//! +//! Asserts the forward entry shape + functional balance move, that the dual-column +//! commit trigger accepted both functional-only entries, idempotent re-run, and +//! that the reversal restores the carried basis in the next period. +//! +//! Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::period::{next_period_id, period_end_utc}; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::fx::revaluation_run::UnrealizedRevaluationRun; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{FxRepo, NewFxRate, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: &str, + currency: &str, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: currency.to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_currency_revaluation_then_next_period_reversal() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let provider = + DBProvider::::new(connect_db(&repo_url, ConnectOpts::default()).await.unwrap()); + + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let ar = Uuid::now_v7(); + let revenue = Uuid::now_v7(); + let fx_unrealized = Uuid::now_v7(); + let now = Utc::now(); + let period_id = format!("{:04}{:02}", now.year(), now.month()); + let next_period = next_period_id(&period_id).unwrap(); + // The period-end instant drives the rate `as_of` (so the resolve sees a fresh, + // non-stale period-end rate) and the run's rate lookup. + let period_end = period_end_utc(&period_id).unwrap(); + + let reference = ReferenceRepo::new(provider.clone()); + for ccy in ["EUR", "USD"] { + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: ccy.to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + } + // S5-F3: USD functional currency — activates the cross-currency FX path. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_calendar + (tenant_id, legal_entity_id, fiscal_tz, granularity, fy_start_month, functional_currency) + VALUES ('{tenant}','{tenant}','UTC','MONTH',1,'USD')" + ))) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{tenant}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + // EUR transaction chart (AR / REVENUE) + the USD functional FX_UNREALIZED + // account the revaluation binds. + for row in [ + account(tenant, ar, AccountClass::Ar, "DR", "EUR", None), + account( + tenant, + revenue, + AccountClass::Revenue, + "CR", + "EUR", + Some("subscription"), + ), + account( + tenant, + fx_unrealized, + AccountClass::FxUnrealized, + "DR", + "USD", + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(tenant); + + // ── 1. EUR invoice @ 1.10 → AR carried 120.00 EUR / 132.00 USD ────────────── + FxRepo::new(provider.clone()) + .upsert_rate(&NewFxRate { + tenant_id: tenant, + base_currency: "EUR".to_owned(), + quote_currency: "USD".to_owned(), + provider: "ecb".to_owned(), + rate_micro: 1_100_000, + as_of: now, + fallback_order: 0, + }) + .await + .unwrap(); + + let fx_config = FxConfig { + provider_order: vec!["ecb".to_owned()], + revaluation_enabled: true, + ..FxConfig::default() + }; + let invoice_svc = InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + RecognitionConfig::default(), + fx_config.clone(), + ); + let inv = PostedInvoice { + invoice_id: "INV-REVAL-1".to_owned(), + payer_tenant_id: payer, + resource_tenant_id: None, + seller_tenant_id: tenant, + effective_at: now.date_naive(), + due_date: Some(naive(2026, 12, 1)), + period_id: period_id.clone(), + items: vec![InvoiceItem { + amount_minor_ex_tax: 12_000, + deferred_minor: 0, + currency: "EUR".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }], + tax: vec![], + posted_by_actor_id: tenant, + correlation_id: tenant, + }; + invoice_svc + .post_invoice(&ctx, &scope, &inv, true) + .await + .expect("cross-currency invoice must post"); + + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-REVAL-1'" + )).await, + Some(13_200), + "AR carried functional = 132.00 USD (120.00 EUR * 1.10)" + ); + + // ── 2. Period-end rate 1.05 (as_of = period end, so the resolve is fresh) ──── + FxRepo::new(provider.clone()) + .upsert_rate(&NewFxRate { + tenant_id: tenant, + base_currency: "EUR".to_owned(), + quote_currency: "USD".to_owned(), + provider: "ecb".to_owned(), + rate_micro: 1_050_000, + as_of: period_end, + fallback_order: 0, + }) + .await + .unwrap(); + + // ── 3. Forward revaluation (AR scope) ─────────────────────────────────────── + let runner = UnrealizedRevaluationRun::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + fx_config.clone(), + ); + runner + .run_period(&ctx, &scope, tenant, &period_id, true) + .await + .expect("forward revaluation must post (functional column balances)"); + + // The AR grain's functional carrying value falls to 126.00 USD (120.00 * 1.05). + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-REVAL-1'" + )).await, + Some(12_600), + "AR functional remeasured to 126.00 USD" + ); + // The transaction balance is UNTOUCHED (revaluation is functional-only). + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-REVAL-1'" + )).await, + Some(12_000), + "AR transaction balance unchanged (120.00 EUR)" + ); + // The FX_UNREALIZED account carries the 6.00 USD unrealized loss (DR-normal). + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_account_balance WHERE tenant_id='{tenant}' AND account_id='{fx_unrealized}'" + )).await, + Some(600), + "FX_UNREALIZED functional balance = 6.00 USD unrealized loss" + ); + // The forward entry exists, is a FX_REVALUATION doc, and is functional-only + // (every line amount_minor = 0) and balances in the functional column. + let reval_lines = format!( + "FROM bss.ledger_journal_line l JOIN bss.ledger_journal_entry e \ + ON l.tenant_id=e.tenant_id AND l.entry_id=e.entry_id \ + WHERE l.tenant_id='{tenant}' AND e.source_doc_type='FX_REVALUATION'" + ); + assert_eq!( + scalar_i64(&raw, &format!("SELECT count(*) {reval_lines}")).await, + Some(2), + "forward reval = CR AR + DR FX_UNREALIZED (two functional-only lines)" + ); + assert_eq!( + scalar_i64( + &raw, + &format!("SELECT COALESCE(SUM(l.amount_minor),0)::bigint {reval_lines}") + ) + .await, + Some(0), + "every revaluation line is functional-only (transaction amount_minor = 0)" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT COALESCE(SUM(CASE WHEN l.side='DR' THEN l.functional_amount_minor ELSE -l.functional_amount_minor END),0)::bigint {reval_lines}" + )).await, + Some(0), + "revaluation entry functional column balances (DR == CR)" + ); + + // ── 3b. Idempotent re-run posts nothing new ───────────────────────────────── + runner + .run_period(&ctx, &scope, tenant, &period_id, true) + .await + .expect("re-run is an idempotent replay"); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(DISTINCT e.entry_id) FROM bss.ledger_journal_entry e WHERE e.tenant_id='{tenant}' AND e.source_doc_type='FX_REVALUATION'" + )).await, + Some(1), + "the AR-scope revaluation posted exactly ONE entry (idempotent on period:scope)" + ); + + // ── 4. Close the period, open the next ────────────────────────────────────── + raw.execute(pg(format!( + "UPDATE bss.ledger_fiscal_period SET status='CLOSED' WHERE tenant_id='{tenant}' AND period_id='{period_id}'" + ))) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{tenant}','{next_period}','UTC','OPEN')" + ))) + .await + .unwrap(); + + // ── 5. Reversal in the next OPEN period (FX_REVAL_REVERSAL) ────────────────── + runner + .reverse_period(&ctx, &scope, tenant, &period_id, true) + .await + .expect("reversal must post into the next open period"); + + // The reversal restores the AR grain to its historical 132.00 USD carried basis. + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-REVAL-1'" + )).await, + Some(13_200), + "reversal restores AR functional to 132.00 USD (historical basis)" + ); + // The FX_UNREALIZED contra unwinds to zero (only realized FX is permanent). + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_account_balance WHERE tenant_id='{tenant}' AND account_id='{fx_unrealized}'" + )).await, + Some(0), + "FX_UNREALIZED unwinds to 0 after the reversal" + ); + // The reversal is a fresh FX_REVAL_REVERSAL entry in the NEXT period. + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_entry WHERE tenant_id='{tenant}' AND source_doc_type='FX_REVAL_REVERSAL' AND period_id='{next_period}'" + )).await, + Some(1), + "exactly one FX_REVAL_REVERSAL entry posted in the next period" + ); + + // ── 5b. Idempotent reversal re-run posts nothing new ──────────────────────── + runner + .reverse_period(&ctx, &scope, tenant, &period_id, true) + .await + .expect("reversal re-run is an idempotent replay"); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_entry WHERE tenant_id='{tenant}' AND source_doc_type='FX_REVAL_REVERSAL'" + )).await, + Some(1), + "reversal idempotent on (tenant, FX_REVAL_REVERSAL, period:scope)" + ); +} + +/// `FxRateUnavailable` at run time: a USD-functional seller holds an OPEN +/// cross-currency EUR receivable (a revalue-able grain: `functional_currency` set, +/// `balance_minor > 0`), but the EUR→USD pair has NO `ledger_fx_rate` row at all. +/// The revaluation enumerates the grain (step 1), then the period-end rate resolve +/// (step 2) finds zero candidates and the run fails [`DomainError::FxRateUnavailable`] +/// BEFORE any post — no `FX_REVALUATION` entry, the AR grain untouched. +/// +/// This is a real, reachable deployment state (the FX feed has not published a +/// period-end quote for the pair) the happy-path test does not cover. The EUR +/// position is seeded by posting `DR AR (EUR 120.00 / functional USD 132.00) / +/// CR REVENUE (same)` DIRECTLY through the engine with the functional amounts +/// supplied on the lines — so a cross-currency AR grain materializes WITHOUT any +/// rate row existing (the engine resolves only per-line scale, never an FX rate; +/// the higher-level invoice path is what would resolve one, and we bypass it). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn revaluation_with_no_period_end_rate_is_fx_rate_unavailable() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let provider = + DBProvider::::new(connect_db(&repo_url, ConnectOpts::default()).await.unwrap()); + + let tenant = Uuid::now_v7(); + let payer = Uuid::now_v7(); + let ar = Uuid::now_v7(); + let revenue = Uuid::now_v7(); + let fx_unrealized = Uuid::now_v7(); + let now = Utc::now(); + let period_id = format!("{:04}{:02}", now.year(), now.month()); + + let reference = ReferenceRepo::new(provider.clone()); + for ccy in ["EUR", "USD"] { + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: ccy.to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + } + // USD-functional seller — activates the cross-currency FX revaluation path. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_calendar + (tenant_id, legal_entity_id, fiscal_tz, granularity, fy_start_month, functional_currency) + VALUES ('{tenant}','{tenant}','UTC','MONTH',1,'USD')" + ))) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{tenant}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + for row in [ + account(tenant, ar, AccountClass::Ar, "DR", "EUR", None), + account( + tenant, + revenue, + AccountClass::Revenue, + "CR", + "EUR", + Some("subscription"), + ), + account( + tenant, + fx_unrealized, + AccountClass::FxUnrealized, + "DR", + "USD", + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(tenant); + + // Post the OPEN EUR receivable DIRECTLY (no FX rate seeded): both legs EUR + // (transaction column balances 12000=12000) carry an explicit USD functional + // amount (13200=13200, so the dual-column commit trigger passes). The AR grain + // is left with `functional_currency='USD'` + balance 12000 ⇒ revalue-able. + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + legal_entity_id: tenant, + period_id: period_id.clone(), + entry_currency: "EUR".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "INV-NORATE-1".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: now, + effective_at: now.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let fx_line = |account_id: Uuid, + class: AccountClass, + side: Side, + invoice_id: Option<&str>, + stream: Option<&str>| NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: payer, + seller_tenant_id: Some(tenant), + resource_tenant_id: None, + account_id, + account_class: class, + gl_code: None, + side, + amount_minor: 12_000, + currency: "EUR".to_owned(), + currency_scale: 2, + invoice_id: invoice_id.map(str::to_owned), + due_date: None, + revenue_stream: stream.map(str::to_owned), + mapping_status: MappingStatus::Resolved, + // The functional (USD) leg supplied on the line — no rate resolve at post. + functional_amount_minor: Some(13_200), + functional_currency: Some("USD".to_owned()), + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }; + posting + .post( + &ctx, + &scope, + entry, + vec![ + fx_line( + ar, + AccountClass::Ar, + Side::Debit, + Some("INV-NORATE-1"), + None, + ), + fx_line( + revenue, + AccountClass::Revenue, + Side::Credit, + None, + Some("subscription"), + ), + ], + None, + ) + .await + .expect("seed cross-currency EUR receivable (functional supplied)"); + + // Sanity: the AR grain is cross-currency + open ⇒ the revaluation will enumerate + // it, then hit the rate resolve. + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-NORATE-1'" + )).await, + Some(12_000), + "AR carries the open EUR balance" + ); + + // NO EUR→USD rate row seeded anywhere. The revaluation enumerates the open + // grain, then the period-end resolve finds zero candidates ⇒ FxRateUnavailable. + let fx_config = FxConfig { + provider_order: vec!["ecb".to_owned()], + revaluation_enabled: true, + ..FxConfig::default() + }; + let runner = UnrealizedRevaluationRun::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + fx_config, + ); + let err = runner + .run_period(&ctx, &scope, tenant, &period_id, true) + .await + .expect_err("a revaluation with no period-end rate must fail, not post"); + assert!( + matches!(err, DomainError::FxRateUnavailable(_)), + "expected FxRateUnavailable, got {err:?}" + ); + + // The run errored BEFORE posting: no FX_REVALUATION entry, AR functional untouched. + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_entry WHERE tenant_id='{tenant}' AND source_doc_type='FX_REVALUATION'" + )).await, + Some(0), + "no revaluation entry posted when the rate is unavailable" + ); + assert_eq!( + scalar_i64(&raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_ar_invoice_balance WHERE tenant_id='{tenant}' AND invoice_id='INV-NORATE-1'" + )).await, + Some(13_200), + "AR functional carrying value untouched (still the posted 132.00 USD)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_scale_lock.rs b/gears/bss/ledger/ledger/tests/postgres_scale_lock.rs new file mode 100644 index 000000000..3d95306a6 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_scale_lock.rs @@ -0,0 +1,159 @@ +//! Postgres-only: the scale-immutability guard. Once a journal_line +//! exists for a (tenant, currency), a changed scale is rejected +//! (`CurrencyScaleLocked`); the same scale is idempotent; a currency with +//! no postings is freely (re)scalable. Ignored by default; run with +//! `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown +)] + +use bss_ledger::domain::model::{CurrencyScaleRow, RepoError}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use sea_orm::{ConnectionTrait, Database, Statement, TransactionTrait}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn scale_locked_once_postings_exist() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + // sea_orm connection for the migrator + raw seed SQL (bss-qualified). + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + // The repo's connection sets search_path=bss (as the gear config does in + // prod) so its unqualified entity queries resolve into the bss schema. + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + + let tenant = Uuid::now_v7(); + let entry = Uuid::now_v7(); + let reference = ReferenceRepo::new(DBProvider::::new(tdb)); + + // Register USD@2 and post a balanced USD entry (DR AR / CR CASH_CLEARING). + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + + // Seed entry + lines in one transaction so the deferred balance trigger + // sees both lines at COMMIT (an autocommitted empty header would fail). + let seed = raw.begin().await.unwrap(); + seed.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, + source_doc_type, source_business_id, posted_at_utc, effective_at, + origin, posted_by_actor_id, correlation_id) + VALUES ('{entry}','{tenant}','{tenant}','202606','USD', + 'MANUAL_ADJUSTMENT','biz-1', now(), CURRENT_DATE, + 'SYSTEM','{tenant}','{tenant}')" + ))) + .await + .unwrap(); + for (side, class) in [("DR", "AR"), ("CR", "CASH_CLEARING")] { + let line = Uuid::now_v7(); + seed.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, + account_class, side, amount_minor, currency, currency_scale, mapping_status) + VALUES ('{line}','{entry}','{tenant}','202606','{tenant}','{tenant}', + '{class}','{side}', 1000, 'USD', 2, 'RESOLVED')" + ))) + .await + .unwrap(); + } + seed.commit().await.unwrap(); + + // Same scale -> idempotent no-op. + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .expect("same scale must be a no-op"); + + // Changed scale with postings present -> locked. + let locked = reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 3, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap_err(); + assert!( + matches!(locked, RepoError::CurrencyScaleLocked(_)), + "got {locked:?}" + ); + + // A currency with no postings is freely scalable. + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "EUR".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .expect("EUR has no postings; upsert must succeed"); + + // Defense-in-depth: a DIRECT SQL UPDATE bypassing the app-level lock is + // rejected by the `trg_currency_scale_immutable` trigger once a posting + // exists — USD has a posted line, so re-denominating its scale must fail. + let raw_update = raw + .execute(pg(format!( + "UPDATE bss.ledger_currency_scale_registry SET minor_units = 3 \ + WHERE tenant_id = '{tenant}' AND currency = 'USD'" + ))) + .await; + assert!( + raw_update.is_err(), + "direct UPDATE of a locked scale must be rejected by the DB trigger" + ); + + // The trigger fires only on a genuine change: a same-value UPDATE passes. + raw.execute(pg(format!( + "UPDATE bss.ledger_currency_scale_registry SET minor_units = 2 \ + WHERE tenant_id = '{tenant}' AND currency = 'USD'" + ))) + .await + .expect("same-value UPDATE must pass the trigger"); + + // And a currency with no postings can be re-denominated by direct SQL. + raw.execute(pg(format!( + "UPDATE bss.ledger_currency_scale_registry SET minor_units = 3 \ + WHERE tenant_id = '{tenant}' AND currency = 'EUR'" + ))) + .await + .expect("EUR has no postings; direct UPDATE must pass the trigger"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_schema.rs b/gears/bss/ledger/ledger/tests/postgres_schema.rs new file mode 100644 index 000000000..a9f072193 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_schema.rs @@ -0,0 +1,39 @@ +//! Postgres-only: running the migrator creates the `bss` schema. +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown +)] + +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; + +use bss_ledger::infra::storage::migrations::Migrator; + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn migrator_creates_bss_schema() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let db = Database::connect(&url).await.unwrap(); + + Migrator::up(&db, None).await.unwrap(); + + let row = db + .query_one(Statement::from_string( + db.get_database_backend(), + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'bss'" + .to_owned(), + )) + .await + .unwrap(); + assert!(row.is_some(), "bss schema must exist after migration"); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_settlement_return_fx.rs b/gears/bss/ledger/ledger/tests/postgres_settlement_return_fx.rs new file mode 100644 index 000000000..52aec92f6 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_settlement_return_fx.rs @@ -0,0 +1,340 @@ +//! Postgres-only integration (Slice 5 remediation): **functional carry-forward on +//! a cross-currency settlement-return**, driven through the REAL orchestrators +//! (`SettlementService` / `SettlementReturnService`) against the foundation engine. +//! +//! A settlement-return is the SYMMETRIC reverse of a cross-currency settle: it +//! claws the gross back out of the `UNALLOCATED` pool and relieves the cash + fee +//! legs at the pool's carried (settle-time) rate — a reversal, NOT a realized-FX +//! point, so the functional column nets to ZERO with no `FX_GAIN_LOSS` line (the +//! Slice-5 remediation fix: before it, the return posted transaction-only and left +//! the functional balances drifting). This test proves a full settle→return +//! round-trip drains every grain to `(0, 0)` in BOTH columns, including the fee leg +//! whose functional is the residual `dr_func − fee_func`. +//! +//! Ignored by default; run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use bss_ledger::config::FxConfig; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::payment::settlement_return::SettlementReturnInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::fx::rate_locker::RateLocker; +use bss_ledger::infra::fx::rate_source::RateSource; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::payment::settlement_return::SettlementReturnService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{FxRepo, NewFxRate, ReferenceRepo}; +use bss_ledger_sdk::AccountClass; +use chrono::{Datelike, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +async fn scalar_i64(conn: &DatabaseConnection, sql: &str) -> Option { + conn.query_one(pg(sql.to_owned())) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()) +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: &str, + currency: &str, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: currency.to_owned(), + revenue_stream: None, + normal_side: normal.to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +fn fx_config() -> FxConfig { + FxConfig { + provider_order: vec!["ecb".to_owned()], + ..FxConfig::default() + } +} + +fn rate_locker(provider: &DBProvider) -> RateLocker { + RateLocker::new( + RateSource::new(FxRepo::new(provider.clone()), fx_config()), + FxRepo::new(provider.clone()), + ) +} + +struct Chart { + tenant: Uuid, + payer: Uuid, + unallocated: Uuid, + cash: Uuid, + psp_fee: Uuid, + fx_gl: Uuid, +} + +async fn acct(raw: &DatabaseConnection, tenant: Uuid, account: Uuid) -> (Option, Option) { + let bal = scalar_i64(raw, &format!( + "SELECT balance_minor FROM bss.ledger_account_balance WHERE tenant_id='{tenant}' AND account_id='{account}'" + )).await; + let func = scalar_i64(raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_account_balance WHERE tenant_id='{tenant}' AND account_id='{account}'" + )).await; + (bal, func) +} + +async fn unalloc( + raw: &DatabaseConnection, + tenant: Uuid, + payer: Uuid, +) -> (Option, Option) { + let bal = scalar_i64(raw, &format!( + "SELECT balance_minor FROM bss.ledger_unallocated_balance WHERE tenant_id='{tenant}' AND payer_tenant_id='{payer}'" + )).await; + let func = scalar_i64(raw, &format!( + "SELECT functional_balance_minor FROM bss.ledger_unallocated_balance WHERE tenant_id='{tenant}' AND payer_tenant_id='{payer}'" + )).await; + (bal, func) +} + +async fn entry_functional_net(raw: &DatabaseConnection, tenant: Uuid, entry: Uuid) -> Option { + scalar_i64(raw, &format!( + "SELECT COALESCE(SUM(CASE WHEN side='DR' THEN functional_amount_minor ELSE -functional_amount_minor END),0)::bigint \ + FROM bss.ledger_journal_line WHERE tenant_id='{tenant}' AND entry_id='{entry}'" + )).await +} + +async fn fx_line_count(raw: &DatabaseConnection, tenant: Uuid, fx_gl: Uuid) -> Option { + scalar_i64(raw, &format!( + "SELECT count(*) FROM bss.ledger_journal_line WHERE tenant_id='{tenant}' AND account_id='{fx_gl}'" + )).await +} + +/// Boot + provision a USD-functional / EUR-billing seller with the settle/return +/// chart (UNALLOCATED, CASH_CLEARING, PSP_FEE_EXPENSE in EUR; FX_GAIN_LOSS in USD), +/// the functional-currency source, an OPEN current-month period, and an EUR→USD @ +/// 1.08 rate; then settle `gross` EUR (with `fee`) cross-currency so UNALLOCATED, +/// CASH_CLEARING and PSP_FEE_EXPENSE each carry a functional balance. +async fn setup_and_settle( + gross: i64, + fee: i64, + payment_id: &str, +) -> ( + testcontainers_modules::testcontainers::ContainerAsync, + DatabaseConnection, + DBProvider, + Chart, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let provider = + DBProvider::::new(connect_db(&repo_url, ConnectOpts::default()).await.unwrap()); + + let c = Chart { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + cash: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + fx_gl: Uuid::now_v7(), + }; + let now = Utc::now(); + let period_id = format!("{:04}{:02}", now.year(), now.month()); + + let reference = ReferenceRepo::new(provider.clone()); + for ccy in ["EUR", "USD"] { + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: c.tenant, + currency: ccy.to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + } + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_calendar + (tenant_id, legal_entity_id, fiscal_tz, granularity, fy_start_month, functional_currency) + VALUES ('{}','{}','UTC','MONTH',1,'USD')", + c.tenant, c.tenant + ))) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{period_id}','UTC','OPEN')", + c.tenant, c.tenant + ))) + .await + .unwrap(); + for row in [ + account( + c.tenant, + c.unallocated, + AccountClass::Unallocated, + "CR", + "EUR", + ), + account(c.tenant, c.cash, AccountClass::CashClearing, "DR", "EUR"), + account( + c.tenant, + c.psp_fee, + AccountClass::PspFeeExpense, + "DR", + "EUR", + ), + account(c.tenant, c.fx_gl, AccountClass::FxGainLoss, "DR", "USD"), + ] { + reference.insert_account(row).await.unwrap(); + } + FxRepo::new(provider.clone()) + .upsert_rate(&NewFxRate { + tenant_id: c.tenant, + base_currency: "EUR".to_owned(), + quote_currency: "USD".to_owned(), + provider: "ecb".to_owned(), + rate_micro: 1_080_000, + as_of: now, + fallback_order: 0, + }) + .await + .unwrap(); + + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .with_fx(rate_locker(&provider)) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(c.tenant), + SettlementInput { + tenant_id: c.tenant, + payer_tenant_id: c.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: fee, + currency: "EUR".to_owned(), + effective_at: None, + }, + ) + .await + .expect("cross-currency settle must post"); + + (container, raw, provider, c) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn full_settlement_return_carries_functional_forward_no_fx() { + // Settle 120.00 EUR gross / 20.00 EUR fee @ 1.08 → net cash 100.00 EUR. + // UNALLOCATED carries (12000, 12960), CASH (10000, 10800), PSP_FEE (2000, 2160). + let (_c, raw, provider, chart) = setup_and_settle(12_000, 2_000, "PAY-SR-1").await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(chart.tenant); + + assert_eq!( + unalloc(&raw, chart.tenant, chart.payer).await, + (Some(12_000), Some(12_960)), + "settle stamped UNALLOCATED gross functional" + ); + assert_eq!( + acct(&raw, chart.tenant, chart.cash).await, + (Some(10_000), Some(10_800)), + "settle stamped CASH_CLEARING net functional" + ); + assert_eq!( + acct(&raw, chart.tenant, chart.psp_fee).await, + (Some(2_000), Some(2_160)), + "settle stamped PSP_FEE_EXPENSE fee functional" + ); + + // Full return: DR UNALLOCATED 12000 / CR CASH_CLEARING 10000 / CR PSP_FEE 2000. + // The pool's carried 129.60 USD basis carries forward onto every leg (cash leg = + // the exact residual), so the functional column nets to zero and each grain + // drains to (0, 0) — no FX line. + let reference = SettlementReturnService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .return_settlement( + &ctx, + &scope, + SettlementReturnInput { + tenant_id: chart.tenant, + payer_tenant_id: chart.payer, + payment_id: "PAY-SR-1".to_owned(), + psp_return_id: "PSP-SR-1".to_owned(), + amount_minor: 12_000, + currency: "EUR".to_owned(), + effective_at: None, + }, + ) + .await + .expect("cross-currency settlement-return must post"); + + assert_eq!( + entry_functional_net(&raw, chart.tenant, reference.entry_id).await, + Some(0), + "return entry functional column balances (carry-forward, no FX)" + ); + assert_eq!( + unalloc(&raw, chart.tenant, chart.payer).await, + (Some(0), Some(0)), + "UNALLOCATED drained to (0, 0) — pool basis relieved" + ); + assert_eq!( + acct(&raw, chart.tenant, chart.cash).await, + (Some(0), Some(0)), + "CASH_CLEARING drained to (0, 0) — cash leg took the residual functional" + ); + assert_eq!( + acct(&raw, chart.tenant, chart.psp_fee).await, + (Some(0), Some(0)), + "PSP_FEE_EXPENSE drained to (0, 0) — fee leg relieved pro-rata" + ); + assert_eq!( + fx_line_count(&raw, chart.tenant, chart.fx_gl).await, + Some(0), + "a settlement-return posts NO FX_GAIN_LOSS line (carry-forward, not realized FX)" + ); +} diff --git a/gears/bss/ledger/ledger/tests/postgres_tieout.rs b/gears/bss/ledger/ledger/tests/postgres_tieout.rs new file mode 100644 index 000000000..beee60a04 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/postgres_tieout.rs @@ -0,0 +1,1136 @@ +//! Postgres-only integration tests for `TieOutJob::tie_out_tenant` — the +//! per-tenant, all-time self-reconciliation. Boots a container, migrates, seeds +//! reference data + an OPEN period, posts ONE balanced entry through the real +//! `PostingService` (no-op publisher), then drives the four defect classes: +//! +//! * clean books tie out; +//! * an `account_balance` cache drift surfaces as one variance; +//! * a PENDING-mapped line is flagged; +//! * an imbalanced entry (inserted past the deferrable commit trigger) is +//! caught by the entry-balance backstop. +//! +//! The seed/post harness is copied from `tests/postgres_posting.rs` (each +//! integration test is its own binary, so the helpers can't be shared). +//! Ignored by default; run with +//! `cargo test -p bss-ledger --test postgres_tieout -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::jobs::tieout::TieOutJob; +use bss_ledger::infra::payment::allocate::{AllocateRequest, AllocationOutcome, AllocationService}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{PaymentRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement, TransactionTrait}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +struct Fixture { + tenant: Uuid, + ar_account: Uuid, + cash_account: Uuid, + legal_entity: Uuid, + period_id: String, +} + +/// Boot, migrate, seed USD@2 + OPEN period + AR/CASH accounts; return the +/// migrate connection, the posting service over a search_path-scoped provider, +/// the provider itself, and the fixture ids. (Copied from +/// `tests/postgres_posting.rs::setup`.) +async fn setup( + container_url: &str, +) -> ( + DatabaseConnection, + PostingService, + DBProvider, + Fixture, +) { + let raw = Database::connect(container_url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{container_url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = "202606".to_owned(); + let ar_account = Uuid::now_v7(); + let cash_account = Uuid::now_v7(); + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + + // OPEN fiscal period (raw, bss-qualified). + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{legal_entity}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + + reference + .insert_account(AccountRow { + account_id: ar_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + reference + .insert_account(AccountRow { + account_id: cash_account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "CASH_CLEARING".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "CR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + let service = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + ( + raw, + service, + provider, + Fixture { + tenant, + ar_account, + cash_account, + legal_entity, + period_id, + }, + ) +} + +/// Build a balanced entry for `fixture` with `business_id`: DR AR / CR CASH, +/// each `amount`. (Copied from `tests/postgres_posting.rs::balanced_entry`.) +fn balanced_entry(f: &Fixture, business_id: &str, amount: i64) -> (NewEntry, Vec) { + let entry_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id, + tenant_id: f.tenant, + legal_entity_id: f.legal_entity, + period_id: f.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: business_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: f.tenant, + correlation_id: f.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ + line(f, f.ar_account, AccountClass::Ar, Side::Debit, amount), + line( + f, + f.cash_account, + AccountClass::CashClearing, + Side::Credit, + amount, + ), + ]; + (entry, lines) +} + +fn line(f: &Fixture, account: Uuid, class: AccountClass, side: Side, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: f.tenant, + seller_tenant_id: None, + resource_tenant_id: None, + account_id: account, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Boot + seed + post ONE balanced entry; return the raw conn, the provider, +/// and the fixture. Shared by every test that needs clean populated books. +async fn setup_with_one_balanced_post( + url: &str, +) -> (DatabaseConnection, DBProvider, Fixture) { + let (raw, service, provider, f) = setup(url).await; + let scope = AccessScope::for_tenant(f.tenant); + let ctx = SecurityContext::anonymous(); + let (entry, lines) = balanced_entry(&f, "biz-1", 1000); + service + .post(&ctx, &scope, entry, lines, None) + .await + .expect("balanced post must succeed"); + (raw, provider, f) +} + +fn noop_publisher() -> Arc { + Arc::new(LedgerEventPublisher::noop()) +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn clean_tenant_ties_out() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (_raw, provider, f) = setup_with_one_balanced_post(&url).await; + + let report = TieOutJob::new(provider, noop_publisher()) + .tie_out_tenant(f.tenant) + .await + .expect("tie-out must succeed"); + + assert!( + report.is_clean(), + "clean books must tie out: {}", + report.summary() + ); + assert!(report.account_balance_variances.is_empty()); + assert!(report.imbalanced_entries.is_empty()); + assert!(report.negative_grains.is_empty()); + assert_eq!(report.pending_lines, 0); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn account_balance_drift_is_detected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, f) = setup_with_one_balanced_post(&url).await; + + // Corrupt exactly one grain's cached balance (the AR row). `account_balance` + // carries no append-only trigger, so a plain UPDATE is fine; the no-negative + // CHECK is satisfied by adding to the positive AR balance. + raw.execute(pg(format!( + "UPDATE bss.ledger_account_balance SET balance_minor = balance_minor + 1 \ + WHERE tenant_id='{}' AND account_id='{}' AND currency='USD'", + f.tenant, f.ar_account + ))) + .await + .unwrap(); + + let report = TieOutJob::new(provider, noop_publisher()) + .tie_out_tenant(f.tenant) + .await + .expect("tie-out must succeed"); + + assert!(!report.is_clean(), "drifted books must not tie out"); + assert_eq!( + report.account_balance_variances.len(), + 1, + "exactly one grain diverged: {:?}", + report.account_balance_variances + ); + let v = &report.account_balance_variances[0]; + assert_ne!(v.computed, v.cached, "computed must differ from cached"); + assert_eq!(v.account_id, f.ar_account, "the AR grain diverged"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn ar_sub_grain_drift_is_detected() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + // The balanced post's AR (DR) line populates `ar_payer_balance`. Corrupt + // that cache (no append-only trigger; balance stays positive) and confirm + // the sub-grain recompute — not just `account_balance` — catches it. + let (raw, provider, f) = setup_with_one_balanced_post(&url).await; + raw.execute(pg(format!( + "UPDATE bss.ledger_ar_payer_balance SET balance_minor = balance_minor + 1 \ + WHERE tenant_id='{}' AND account_id='{}'", + f.tenant, f.ar_account + ))) + .await + .unwrap(); + + let report = TieOutJob::new(provider, noop_publisher()) + .tie_out_tenant(f.tenant) + .await + .expect("tie-out must succeed"); + + assert!(!report.is_clean(), "an AR sub-grain drift must not tie out"); + assert!( + report + .sub_grain_variances + .iter() + .any(|v| v.grain == "ar_payer_balance" && v.computed != v.cached), + "the ar_payer_balance drift must surface as a sub-grain variance: {:?}", + report.sub_grain_variances + ); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn pending_mapping_is_flagged() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, provider, f) = setup_with_one_balanced_post(&url).await; + + // `journal_line` carries the append-only reject-mutation trigger + // (BEFORE UPDATE), so a direct UPDATE is rejected. `session_replication_role + // = replica` skips regular + constraint triggers (superuser; testcontainers + // runs as `postgres`). It MUST be `SET LOCAL` inside a transaction so the + // setting and the UPDATE share one pinned connection — `Database::connect` + // is a pool, so a plain `SET` lands on a different connection than the DML. + let txn = raw.begin().await.unwrap(); + txn.execute(pg("SET LOCAL session_replication_role = replica")) + .await + .unwrap(); + txn.execute(pg(format!( + "UPDATE bss.ledger_journal_line SET mapping_status='PENDING' WHERE tenant_id='{}'", + f.tenant + ))) + .await + .unwrap(); + txn.commit().await.unwrap(); + + let report = TieOutJob::new(provider, noop_publisher()) + .tie_out_tenant(f.tenant) + .await + .expect("tie-out must succeed"); + + assert!(report.pending_lines > 0, "PENDING lines must be counted"); + assert!(!report.is_clean(), "PENDING lines block a clean report"); +} + +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn entry_balance_backstop_catches_imbalanced_entry() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + // Reuse the migrate + provider boot, but DON'T post anything — we hand-craft + // a malformed entry for a FRESH tenant below. + let (raw, _service, provider, base) = setup(&url).await; + + // A brand-new tenant whose only entry is a single, unbalanced DR line. + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = base.period_id.clone(); + let account = Uuid::now_v7(); + let entry_id = Uuid::now_v7(); + let line_id = Uuid::now_v7(); + + // Seed the account so the line's `normal_side` resolves on read (AR is + // DR-normal). Use the repo so the secure-insert path is exercised. + ReferenceRepo::new(provider.clone()) + .insert_account(AccountRow { + account_id: account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + // BYPASS the P1 deferrable balanced-entry constraint trigger (a DEFERRABLE + // CONSTRAINT TRIGGER AFTER INSERT, fired at COMMIT) so a deliberately + // imbalanced entry can land: a single DR line with no offsetting CR, so + // SUM(DR) - SUM(CR) = +500 (net != 0). `session_replication_role = replica` + // skips regular + constraint triggers (superuser; testcontainers runs as + // `postgres`). It MUST be `SET LOCAL` inside the SAME transaction as the + // inserts so the setting holds through the deferred-trigger fire at COMMIT + // and shares one pinned connection (`Database::connect` is a pool). + let txn = raw.begin().await.unwrap(); + txn.execute(pg("SET LOCAL session_replication_role = replica")) + .await + .unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, \ + source_doc_type, source_business_id, posted_at_utc, effective_at, \ + origin, posted_by_actor_id, correlation_id) \ + VALUES ('{entry_id}','{tenant}','{legal_entity}','{period_id}','USD', \ + 'MANUAL_ADJUSTMENT','biz-bad', now(), DATE '2026-06-01', \ + 'SYSTEM','{tenant}','{tenant}')" + ))) + .await + .unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, \ + account_class, side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{line_id}','{entry_id}','{tenant}','{period_id}','{tenant}','{account}', \ + 'AR','DR',500,'USD',2,'RESOLVED')" + ))) + .await + .unwrap(); + txn.commit().await.unwrap(); + + let report = TieOutJob::new(provider, noop_publisher()) + .tie_out_tenant(tenant) + .await + .expect("tie-out must succeed"); + + assert!(!report.is_clean(), "an imbalanced entry must not tie out"); + assert!( + !report.imbalanced_entries.is_empty(), + "the entry-balance backstop must flag the malformed entry" + ); + let imbalanced = report + .imbalanced_entries + .iter() + .find(|e| e.entry_id == entry_id) + .expect("the malformed entry must be among the imbalanced entries"); + assert_ne!(imbalanced.net_minor, 0, "net DR-CR must be non-zero"); +} + +/// The entry-balance backstop also catches a BALANCED entry whose lines span +/// more than one payer (`payer_count > 1`) — the app-level safety net for the +/// case the DB single-payer trigger is bypassed. The existing test exercises +/// only `net_minor != 0`; this pins the mixed-payer arm. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn entry_backstop_catches_mixed_payer_entry() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, _service, provider, base) = setup(&url).await; + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = base.period_id.clone(); + let account = Uuid::now_v7(); + let entry_id = Uuid::now_v7(); + let payer_a = Uuid::now_v7(); + let payer_b = Uuid::now_v7(); + + ReferenceRepo::new(provider.clone()) + .insert_account(AccountRow { + account_id: account, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + // BYPASS the deferrable single-payer/balance trigger (replica role) so a + // BALANCED but cross-payer entry can land: DR 500 (payer A) + CR 500 + // (payer B). net = 0 (NOT imbalanced), but two distinct payers. + let txn = raw.begin().await.unwrap(); + txn.execute(pg("SET LOCAL session_replication_role = replica")) + .await + .unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, \ + source_doc_type, source_business_id, posted_at_utc, effective_at, \ + origin, posted_by_actor_id, correlation_id) \ + VALUES ('{entry_id}','{tenant}','{legal_entity}','{period_id}','USD', \ + 'MANUAL_ADJUSTMENT','biz-mixed', now(), DATE '2026-06-01', \ + 'SYSTEM','{tenant}','{tenant}')" + ))) + .await + .unwrap(); + for (payer, side) in [(payer_a, "DR"), (payer_b, "CR")] { + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, \ + account_class, side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{}','{entry_id}','{tenant}','{period_id}','{payer}','{account}', \ + 'AR','{side}',500,'USD',2,'RESOLVED')", + Uuid::now_v7() + ))) + .await + .unwrap(); + } + txn.commit().await.unwrap(); + + let report = TieOutJob::new(provider, noop_publisher()) + .tie_out_tenant(tenant) + .await + .expect("tie-out must succeed"); + + assert!(!report.is_clean(), "a mixed-payer entry must not tie out"); + let flagged = report + .imbalanced_entries + .iter() + .find(|e| e.entry_id == entry_id) + .expect("the mixed-payer entry must be flagged by the backstop"); + assert_eq!( + flagged.payer_count, 2, + "two distinct payers must be reported" + ); + assert_eq!( + flagged.net_minor, 0, + "the entry is balanced — flagged for mixed-payer, not imbalance" + ); +} + +/// `run()` (the cross-tenant sweep) reaching its `EntryImbalance` AND +/// `NegativeBalanceViolation` emit arms — the two emit dispatches the existing +/// `tieout_tests::run_over_drifted_tenant_emits_alarm` does NOT exercise (it +/// drives only the `TieOutVariance` arm via an `account_balance` cache drift). +/// +/// One tenant is seeded with BOTH defect classes at once, then swept by +/// `run()`: (a) a guarded `account_balance` grain driven NEGATIVE (the +/// no-negative backstop's target) and (b) a single-DR-line imbalanced entry (the +/// entry-balance backstop's target). Both require the `session_replication_role +/// = replica` superuser bypass to land — the SAME established technique the +/// `entry_balance_backstop_catches_imbalanced_entry` test uses, simulating the +/// real failure tie-out exists to catch (a bypassed/buggy projector or DB CHECK +/// letting a malformed state through). `run()` must complete `Ok` (it +/// logs/alarms, never errors) and `tie_out_tenant` independently confirms BOTH +/// defect classes are present — proving the `run()` loop reached both emit arms. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn run_emits_negative_grain_and_entry_imbalance_arms() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + // Migrate + provider boot; seed a FRESH tenant by hand (no clean post — this + // tenant exists only to carry the two defects). + let (raw, _service, provider, _base) = setup(&url).await; + + let tenant = Uuid::now_v7(); + let legal_entity = tenant; + let period_id = "202606".to_owned(); + let guarded_account = Uuid::now_v7(); // AR — goes negative (a defect) + let bad_entry_account = Uuid::now_v7(); // AR — carries the lone unbalanced DR line + let bad_entry_id = Uuid::now_v7(); + + // The OPEN period so the would-be post target exists (and the tenant is + // enumerated). Seed the two AR accounts via the secure repo. + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{tenant}','{legal_entity}','{period_id}','UTC','OPEN')" + ))) + .await + .unwrap(); + let reference = ReferenceRepo::new(provider.clone()); + // Two distinct AR probe accounts. The CoA uniqueness index is + // (tenant, legal_entity, account_class, currency, COALESCE(revenue_stream,'-')), + // so two AR/USD accounts must differ on `revenue_stream` to coexist. The stream + // only differentiates the registration row; the injected cache/line carry their + // own values, so tie-out detection (negative grain on `guarded_account`, + // imbalanced entry on `bad_entry_account`) is unaffected. + for (account_id, revenue_stream) in [ + (guarded_account, None), + (bad_entry_account, Some("imbalance-probe".to_owned())), + ] { + reference + .insert_account(AccountRow { + account_id, + tenant_id: tenant, + legal_entity_id: legal_entity, + account_class: "AR".to_owned(), + currency: "USD".to_owned(), + revenue_stream, + normal_side: "DR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + } + + // BOTH defects land under one replica-role txn: `session_replication_role = + // replica` disables the deferrable balanced-entry constraint *trigger* for the + // unbalanced line (b). It does NOT, however, disable CHECK constraints, so the + // negative-grain injection (a) additionally drops the no-negative CHECK — this + // simulates the cache-drift / bypassed-CHECK scenario the tie-out's independent + // no-negative backstop is meant to catch (app-level detection is unaffected). + let txn = raw.begin().await.unwrap(); + txn.execute(pg("SET LOCAL session_replication_role = replica")) + .await + .unwrap(); + // (a) A NEGATIVE guarded (AR) account_balance grain — the no-negative + // backstop's `NegativeBalanceViolation` defect. Drop the DB CHECK first + // (replica role does not skip CHECKs); the container is throwaway. + txn.execute(pg("ALTER TABLE bss.ledger_account_balance \ + DROP CONSTRAINT IF EXISTS chk_account_balance_no_negative")) + .await + .unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_account_balance \ + (tenant_id, account_id, currency, account_class, normal_side, balance_minor) \ + VALUES ('{tenant}','{guarded_account}','USD','AR','DR',-500)" + ))) + .await + .unwrap(); + // (b) A single, unbalanced DR line (net +700) — the entry-balance backstop's + // `EntryImbalance` defect. + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_entry \ + (entry_id, tenant_id, legal_entity_id, period_id, entry_currency, \ + source_doc_type, source_business_id, posted_at_utc, effective_at, \ + origin, posted_by_actor_id, correlation_id) \ + VALUES ('{bad_entry_id}','{tenant}','{legal_entity}','{period_id}','USD', \ + 'MANUAL_ADJUSTMENT','biz-imbalance', now(), DATE '2026-06-01', \ + 'SYSTEM','{tenant}','{tenant}')" + ))) + .await + .unwrap(); + txn.execute(pg(format!( + "INSERT INTO bss.ledger_journal_line \ + (line_id, entry_id, tenant_id, period_id, payer_tenant_id, account_id, \ + account_class, side, amount_minor, currency, currency_scale, mapping_status) \ + VALUES ('{}','{bad_entry_id}','{tenant}','{period_id}','{tenant}','{bad_entry_account}', \ + 'AR','DR',700,'USD',2,'RESOLVED')", + Uuid::now_v7() + ))) + .await + .unwrap(); + txn.commit().await.unwrap(); + + // The cross-tenant sweep completes Ok even with a doubly-defective tenant — + // it logs/alarms (reaching the EntryImbalance + NegativeBalanceViolation emit + // arms), never errors. + TieOutJob::new(provider.clone(), noop_publisher()) + .run() + .await + .expect("run must complete Ok even over a defective tenant"); + + // Independently confirm BOTH defect classes are present — the report the + // sweep folded into its two emit dispatches. + let report = TieOutJob::new(provider, noop_publisher()) + .tie_out_tenant(tenant) + .await + .expect("tie-out must succeed"); + assert!( + !report.is_clean(), + "the doubly-defective tenant must not tie out" + ); + assert!( + report + .negative_grains + .iter() + .any(|g| g.account_id == guarded_account && g.balance_minor == -500), + "the negative guarded AR grain must be flagged: {:?}", + report.negative_grains + ); + let imbalanced = report + .imbalanced_entries + .iter() + .find(|e| e.entry_id == bad_entry_id) + .expect("the imbalanced entry must be flagged"); + assert_eq!(imbalanced.net_minor, 700, "net DR-CR is +700"); +} + +// ── Payment-counter reconcile through a REAL settle + allocate ─────────────── +// +// Drives the real `SettlementService` + `AllocationService` so the +// `payment_settlement` / `payment_allocation` rows + the `PAYMENT_SETTLE` +// journal all materialise, then ties out the tenant through the FULL +// `tie_out_tenant` (which wires `recompute_payment_counter_variances` over those +// real rows). A `setup_seller` mirrors `postgres_payments.rs`: USD@2, an OPEN +// period for the CURRENT month (settle/allocate derive `period_id` from +// `Utc::now()`), and the four payment-flow chart accounts. + +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + period_id: String, +} + +fn seller_account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +async fn setup_seller(raw: &DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: Uuid::now_v7(), + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + for row in [ + seller_account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + seller_account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + seller_account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + seller_account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +/// Seed an OPEN AR invoice by posting `DR AR (invoice_id) / CR PSP_FEE_EXPENSE` +/// directly (PSP_FEE is unguarded, so the CR from zero is allowed). Mirrors +/// `postgres_payments.rs::seed_ar_invoice`. +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, +) { + let posting = PostingService::new(provider.clone(), noop_publisher()); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: Utc::now().date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let ar_line = NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }; + let psp_credit = NewLine { + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + side: Side::Credit, + invoice_id: None, + due_date: None, + line_id: Uuid::now_v7(), + ..ar_line.clone() + }; + posting + .post(&ctx, &scope, entry, vec![ar_line, psp_credit], None) + .await + .expect("seed AR invoice post must succeed"); +} + +/// A clean ledger built from a REAL settle + allocate ties out (the payment- +/// counter reconcile runs over the real `payment_settlement` / +/// `payment_allocation` rows and the `PAYMENT_SETTLE` journal, and passes); +/// corrupting the cached `allocated_minor` then surfaces exactly one +/// `PaymentCounterVariance` — proving `recompute_payment_counter_variances` is +/// wired through the full `tie_out_tenant`, not just unit-tested in memory. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn payment_counter_reconcile_through_full_tie_out() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, _service, provider, _base) = setup(&url).await; + let s = setup_seller(&raw, &provider).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + + // Settle gross=1000 fee=30 (DR CASH 970 / DR PSP_FEE 30 / CR UNALLOCATED 1000), + // seed an open AR invoice (300), then allocate 300 onto it. + let settle = SettlementService::new( + provider.clone(), + noop_publisher(), + Arc::new(NoopLedgerMetrics), + ); + settle + .settle( + &ctx, + &scope, + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-TIE-1".to_owned(), + gross_minor: 1000, + fee_minor: 30, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle must succeed"); + seed_ar_invoice(&provider, &s, "INV-TIE", 300).await; + let allocate = AllocationService::new( + provider.clone(), + noop_publisher(), + Arc::new(NoopLedgerMetrics), + ); + let outcome = allocate + .allocate( + &ctx, + &scope, + AllocateRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-TIE-1".to_owned(), + allocation_id: Uuid::now_v7(), + lump_minor: 300, + currency: "USD".to_owned(), + hint_invoice_id: None, + caller_splits: None, + }, + ) + .await + .expect("allocate must succeed"); + assert!( + matches!(outcome, AllocationOutcome::Applied(_)), + "a settled payment allocates inline" + ); + + // Clean books tie out — the payment-counter reconcile ran and passed + // (settled/fee/allocated all agree with the journal + allocation rows). + let clean = TieOutJob::new(provider.clone(), noop_publisher()) + .tie_out_tenant(s.tenant) + .await + .expect("tie-out must succeed"); + assert!( + clean.is_clean(), + "a real settle+allocate must tie out clean: {}", + clean.summary() + ); + assert!(clean.payment_counter_variances.is_empty()); + + // Corrupt the cached `allocated_minor` (the allocation rows are the truth, so + // the recompute now disagrees). `payment_settlement` carries no append-only + // trigger, so a plain UPDATE suffices. + let repo = PaymentRepo::new(provider.clone()); + let before = repo + .read_settlement(&scope, s.tenant, "PAY-TIE-1") + .await + .unwrap() + .expect("settlement row present"); + assert_eq!( + before.allocated_minor, 300, + "allocated counter seeded to 300" + ); + raw.execute(pg(format!( + "UPDATE bss.ledger_payment_settlement SET allocated_minor = 250 \ + WHERE tenant_id='{}' AND payment_id='PAY-TIE-1'", + s.tenant + ))) + .await + .unwrap(); + + let drifted = TieOutJob::new(provider, noop_publisher()) + .tie_out_tenant(s.tenant) + .await + .expect("tie-out must succeed"); + assert!(!drifted.is_clean(), "the counter drift must not tie out"); + let v = drifted + .payment_counter_variances + .iter() + .find(|v| v.payment_id == "PAY-TIE-1" && v.counter == "allocated_minor") + .expect("the allocated_minor counter must diverge"); + assert_eq!( + (v.computed, v.cached), + (300, 250), + "computed (rows=300) vs corrupted cache (250)" + ); +} + +/// Incremental tie-out over a REUSABLE_CREDIT grain across a CLOSED + OPEN period +/// split — exercises the wallet arms of `fold_grains` / `cache_grains` and +/// `IncrementalReport::into_tie_out_report`, which the existing incremental tests +/// (account / unallocated grains only) do not reach. Posts `DR CASH / CR +/// REUSABLE_CREDIT` in period 1, closes it + snapshots the baseline (what +/// period-close does), posts another wallet credit in period 2, then verifies the +/// incremental path (baseline[p1] + open-fold[p2]) reconciles clean, agrees with +/// the full fold, and adapts to a clean `TieOutReport`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn incremental_tie_out_covers_reusable_credit_grain() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + let (raw, service, provider, f) = setup(&url).await; + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(f.tenant); + + // A CR-normal REUSABLE_CREDIT wallet account; the fixture's CASH (CR-normal) + // would net negative on a DR, so add a DR-normal CASH-style account to offset + // the wallet credit. Reuse the fixture's AR (DR-normal, guarded) as the debit + // leg: DR AR raises it (non-negative), CR REUSABLE_CREDIT raises the wallet. + let wallet = Uuid::now_v7(); + ReferenceRepo::new(provider.clone()) + .insert_account(AccountRow { + account_id: wallet, + tenant_id: f.tenant, + legal_entity_id: f.legal_entity, + account_class: "REUSABLE_CREDIT".to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: "CR".to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + }) + .await + .unwrap(); + + // A balanced DR AR / CR REUSABLE_CREDIT wallet credit for `period`, with the + // wallet sub-grain bucket (`credit_grant_event_type`) the projector requires. + let wallet_entry = |business_id: &str, period: &str, effective: NaiveDate, amount: i64| { + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: f.tenant, + legal_entity_id: f.legal_entity, + period_id: period.to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::ManualAdjustment, + source_business_id: business_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: effective, + origin: "SYSTEM".to_owned(), + posted_by_actor_id: f.tenant, + correlation_id: f.tenant, + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let ar_debit = line(&f, f.ar_account, AccountClass::Ar, Side::Debit, amount); + let mut wallet_credit = line( + &f, + wallet, + AccountClass::ReusableCredit, + Side::Credit, + amount, + ); + wallet_credit.credit_grant_event_type = Some("promo".to_owned()); + (entry, vec![ar_debit, wallet_credit]) + }; + + // Period 1 (the fixture's 202606): credit 1000 into the wallet, then close + + // snapshot the baseline (mirrors period-close). + let (e1, l1) = wallet_entry( + "wallet-p1", + &f.period_id, + NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + 1000, + ); + service.post(&ctx, &scope, e1, l1, None).await.unwrap(); + raw.execute(pg(format!( + "UPDATE bss.ledger_fiscal_period SET status='CLOSED' \ + WHERE tenant_id='{}' AND period_id='{}'", + f.tenant, f.period_id + ))) + .await + .unwrap(); + let conn = provider.conn().unwrap(); + let job = TieOutJob::new(provider.clone(), noop_publisher()); + job.snapshot_baseline(&conn, f.tenant, &f.period_id) + .await + .expect("snapshot baseline at close"); + + // Period 2: open it, credit another 400 into the wallet (a second event-type + // bucket would also work; the same bucket keeps the grain a single key). + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) \ + VALUES ('{}','{}','202607','UTC','OPEN')", + f.tenant, f.legal_entity + ))) + .await + .unwrap(); + let (e2, l2) = wallet_entry( + "wallet-p2", + "202607", + NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(), + 400, + ); + service.post(&ctx, &scope, e2, l2, None).await.unwrap(); + + // Incremental reconciles clean: baseline[p1] + open-fold[p2] == cache[all], + // INCLUDING the reusable_credit wallet grain (folded + projected through the + // wallet arms of `fold_grains` / `cache_grains`). + let inc = job + .tie_out_incremental(&conn, f.tenant) + .await + .unwrap() + .expect("a stored baseline ⇒ the incremental path runs"); + assert!( + inc.is_clean(), + "incremental clean on a consistent wallet ledger: {:?}", + inc.sub_grain_variances + ); + + // The full all-time fold agrees (and the wallet sub-grain ties out there too). + let full = job.tie_out_on(&conn, f.tenant).await.unwrap(); + assert!( + full.is_clean(), + "the full fold is clean too: {}", + full.summary() + ); + + // The wallet cache carries the all-time total (1000 + 400 = 1400) — confirms + // both credits projected onto the reusable_credit sub-grain. + let wallet_balance = raw + .query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_reusable_credit_subbalance \ + WHERE tenant_id='{}' AND account_id='{wallet}' AND credit_grant_event_type='promo'", + f.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()); + assert_eq!(wallet_balance, Some(1400), "wallet sub-grain = 1000 + 400"); + + // `into_tie_out_report` adapts the clean incremental result to a clean + // `TieOutReport` (open-line count carried; full-only defect classes empty). + let report = inc.into_tie_out_report(f.tenant); + assert!(report.is_clean(), "the adapted report is clean"); + assert_eq!(report.tenant_id, f.tenant); + assert!( + report.posted_line_count >= 2, + "the open-period fold carried period-2's lines: {}", + report.posted_line_count + ); +} diff --git a/gears/bss/ledger/ledger/tests/rest_adjustments.rs b/gears/bss/ledger/ledger/tests/rest_adjustments.rs new file mode 100644 index 000000000..cf73b489f --- /dev/null +++ b/gears/bss/ledger/ledger/tests/rest_adjustments.rs @@ -0,0 +1,924 @@ +//! API-level (router) tests for the adjustment REST surface (Slice 3, Group E + +//! Group-6 / Phase-3 manual adjustments): `POST /bss-ledger/v1/credit-notes`, +//! `POST /bss-ledger/v1/debit-notes`, `POST /bss-ledger/v1/manual-adjustments`, and +//! `GET /bss-ledger/v1/invoices/{invoice_id}/exposure` (tenant in body for the +//! writes, in the query for the read). +//! +//! Like `rest_credit.rs` / `rest_payments.rs`, these drive the router against a +//! REAL testcontainer Postgres so a credit/debit note runs end-to-end through the +//! foundation engine (`PostingService` + the in-txn sidecars) and the headroom +//! CHECK. The adjustment handlers are CONCRETE orchestrators (not behind +//! `LedgerClientV1`), so the router's `ApiState` is built directly over real +//! `CreditNoteHandler` / `DebitNoteHandler` / `AdjustmentRepo` (no in-test client +//! wrapper). An always-allow `PolicyEnforcer` fake (echoing the subject tenant as +//! an `owner_tenant_id` `In` constraint) is layered as an `Extension`, mirroring +//! `register_rest`; a deny fake covers the 403. +//! +//! Cases: +//! - a deferred credit note → 201, an idempotent re-post (same `credit_note_id`) +//! → 200 (`replayed = true`); +//! - an over-headroom credit note → 400 `CREDIT_NOTE_EXCEEDS_HEADROOM`; +//! - a credit note requesting a deferred part against a point-in-time invoice +//! (no obligation to reduce) → 400 `CREDIT_NOTE_SPLIT_AMBIGUOUS`; +//! - a deferred debit note → 201, and it RAISES the invoice's headroom (a credit +//! note that was over-cap before now fits → 201); +//! - `GET …/exposure` → 200 with the headroom counters + remaining AR; +//! - a credit note whose body `tenant_id` is outside the caller's scope → 403; +//! - a governed manual adjustment (DR SUSPENSE / CR CASH_CLEARING) → 201, an +//! idempotent re-post (same `adjustment_id`) → 200 (`replayed = true`); +//! - a manual adjustment with a leg outside the action's allow-list (TAX_PAYABLE) +//! → 400 `MANUAL_ADJUSTMENT_NOT_ALLOWED`; +//! - a manual adjustment shaped as a disguised write-off (DR CONTRA_REVENUE / CR AR) +//! → 400 `MANUAL_ADJUSTMENT_NOT_ALLOWED`. +//! +//! The over-D2 dual-control gate (→ 409) + the manual-adjustment foreign-tenant 403 +//! are not re-exercised here: the gate path needs the `ApprovalService` wired (it is +//! covered by the Group-5 service tests) and the cross-tenant write-gate is the SAME +//! `access_scope` path the credit-note 403 already covers. +//! +//! Ignored by default (needs Docker); run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::needless_pass_by_value +)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use bss_ledger::api::rest::adjustments::{ApiState, router}; +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::recognition::input::{RecognitionInput, RecognitionTiming}; +use bss_ledger::infra::adjustment::credit_note_service::CreditNoteHandler; +use bss_ledger::infra::adjustment::debit_note_service::DebitNoteHandler; +use bss_ledger::infra::adjustment::manual_adjustment_service::ManualAdjustmentHandler; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::metrics::test_harness::MetricsHarness; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{AdjustmentRepo, ReferenceRepo}; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::{Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +// ── PDP fakes (mirror rest_credit.rs / rest_recognition.rs) ────────────────── + +/// The authenticated caller's tenant — also the provisioned seller and the only +/// tenant the allow fake authorizes. The body `tenant_id` MUST equal this so the +/// handler write-gate's `contains_uuid` membership check passes. +const SUBJECT_TENANT: Uuid = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); +const SUBJECT_ID: Uuid = uuid::uuid!("11111111-2222-3333-4444-555555555555"); +/// A tenant the caller is NOT authorized for (the 403 target). +const FOREIGN_TENANT: Uuid = uuid::uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff"); + +/// Always-allow fake emitting a flat `In([SUBJECT_TENANT])` over `owner_tenant_id` +/// — the degraded-mode shape this gear's PEP compiles. A `tenant_id` equal to +/// `SUBJECT_TENANT` passes the gate's write-membership assert; a foreign one fails +/// it (the gate denies a cross-tenant write). +struct AllowInResolver; + +#[async_trait] +impl AuthZResolverClient for AllowInResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::In(InPredicate::new( + toolkit_security::pep_properties::OWNER_TENANT_ID, + vec![SUBJECT_TENANT], + ))], + }], + deny_reason: None, + }, + }) + } +} + +/// Always-deny fake — the PDP refuses, so the gate maps it to 403. +struct DenyResolver; + +#[async_trait] +impl AuthZResolverClient for DenyResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext { + constraints: vec![], + deny_reason: None, + }, + }) + } +} + +fn allow_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(AllowInResolver)) +} + +fn deny_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(DenyResolver)) +} + +// ── DB + seller setup ──────────────────────────────────────────────────────── + +/// Provisioned seller ids (the full Slice-3 chart: AR / REVENUE(sub) / +/// CONTRACT_LIABILITY(sub) / CONTRA_REVENUE / GOODWILL / REUSABLE_CREDIT / TAX / +/// SUSPENSE / CASH_CLEARING). `tenant` is fixed to `SUBJECT_TENANT` so the handler +/// authz gate passes. `period_id` is the CURRENT month (the notes / adjustments +/// post into it). +struct Seller { + tenant: Uuid, + payer: Uuid, + ar: Uuid, + revenue: Uuid, + contract_liability: Uuid, + contra_revenue: Uuid, + goodwill: Uuid, + reusable_credit: Uuid, + tax: Uuid, + suspense: Uuid, + cash_clearing: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Boot a container, migrate, seed USD@2 + a CURRENT-month OPEN period + the full +/// chart, and return a `bss`-search-path `DBProvider`. Mirrors +/// `postgres_credit_note.rs::setup` but with the current-month period (the notes +/// derive their post period from `Utc::now`). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, + Seller, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let now = Utc::now(); + let s = Seller { + tenant: SUBJECT_TENANT, + payer: Uuid::now_v7(), + ar: Uuid::now_v7(), + revenue: Uuid::now_v7(), + contract_liability: Uuid::now_v7(), + contra_revenue: Uuid::now_v7(), + goodwill: Uuid::now_v7(), + reusable_credit: Uuid::now_v7(), + tax: Uuid::now_v7(), + suspense: Uuid::now_v7(), + cash_clearing: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.revenue, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contract_liability, + AccountClass::ContractLiability, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contra_revenue, + AccountClass::ContraRevenue, + Side::Debit, + None, + ), + account( + s.tenant, + s.goodwill, + AccountClass::Goodwill, + Side::Debit, + None, + ), + account( + s.tenant, + s.reusable_credit, + AccountClass::ReusableCredit, + Side::Credit, + None, + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + account( + s.tenant, + s.suspense, + AccountClass::Suspense, + Side::Credit, + None, + ), + account( + s.tenant, + s.cash_clearing, + AccountClass::CashClearing, + Side::Debit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (container, raw, provider, s) +} + +// ── Invoice / handler helpers (mirror postgres_credit_note.rs) ─────────────── + +/// A `subscription` item, straight-line deferred over `periods` (the credited +/// obligation), or fully point-in-time when `periods == 0`. +fn item(amount: i64, periods: u32, item_ref: &str) -> InvoiceItem { + InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: if periods == 0 { + RecognitionTiming::PointInTime + } else { + RecognitionTiming::StraightLine { + periods, + first_period_id: None, + } + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some(item_ref.to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + } +} + +fn invoice(s: &Seller, invoice_id: &str, items: Vec) -> PostedInvoice { + PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items, + tax: Vec::::new(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + } +} + +/// Post `inv` through the real `InvoicePostService` (the credited / charged +/// obligation), so the credit/debit notes have a posted invoice + schedule to act +/// against. +async fn post_invoice(provider: &DBProvider, s: &Seller, inv: &PostedInvoice) { + let harness = MetricsHarness::new(); + let svc = InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(harness.metrics()), + RecognitionConfig::default(), + FxConfig::default(), + ); + svc.post_invoice( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + inv, + true, + ) + .await + .expect("seed invoice post must succeed"); +} + +// ── Router / context / request helpers (mirror rest_credit.rs) ─────────────── + +/// Build the adjustment router over REAL handlers (no client wrapper — the +/// handlers are concrete) with the given enforcer layered as an `Extension`, as +/// `register_rest` does. +fn router_with(provider: DBProvider, enforcer: PolicyEnforcer) -> Router { + let state = Arc::new(ApiState { + credit: Arc::new(CreditNoteHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + )), + debit: Arc::new(DebitNoteHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::domain::ports::metrics::NoopLedgerMetrics), + RecognitionConfig::default(), + )), + // The governed manual-adjustment handler. Built WITHOUT `.with_approval` (no + // dual-control engine): these cases exercise the pure `govern` gate + // (allow-list / write-off → 400) + the happy post / idempotent replay, none of + // which depend on the D2 threshold. The over-D2 → 409 gate path needs the + // ApprovalService wired and is covered by the Group-5 service tests. + manual: Arc::new(ManualAdjustmentHandler::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(bss_ledger::infra::audit::secured_audit_sink::NoopSecuredAuditSink::new()), + )), + exposure_repo: AdjustmentRepo::new(provider), + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + router(state, &openapi).layer(axum::Extension(enforcer)) +} + +fn authed_context() -> SecurityContext { + SecurityContext::builder() + .subject_id(SUBJECT_ID) + .subject_tenant_id(SUBJECT_TENANT) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +fn credit_body( + s: &Seller, + credit_note_id: &str, + invoice_id: &str, + amount_minor: i64, + requested_deferred_minor: i64, +) -> serde_json::Value { + serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "credit_note_id": credit_note_id, + "origin_invoice_id": invoice_id, + "origin_invoice_item_ref": "item-1", + "po_allocation_group": "grp-1", + "revenue_stream": "subscription", + "currency": "USD", + "scale": 2, + "amount_minor": amount_minor, + "tax_minor": 0, + "tax": [], + "requested_deferred_minor": requested_deferred_minor, + "reason_code": "CUSTOMER_GOODWILL" + }) +} + +fn debit_body( + s: &Seller, + debit_note_id: &str, + invoice_id: &str, + amount_minor: i64, + deferred_minor: i64, + periods: u32, +) -> serde_json::Value { + let recognition = if deferred_minor > 0 { + serde_json::json!({ + "policy_ref": "policy.sl.v1", + "timing": "straight_line", + "periods": periods, + "po_allocation_group": "grp-1", + "subscription_ref": "sub-1" + }) + } else { + serde_json::Value::Null + }; + serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "debit_note_id": debit_note_id, + "origin_invoice_id": invoice_id, + "origin_invoice_item_ref": "item-1", + "revenue_stream": "subscription", + "currency": "USD", + "scale": 2, + "amount_minor": amount_minor, + "tax_minor": 0, + "tax": [], + "deferred_minor": deferred_minor, + "reason_code": "ADDITIONAL_USAGE", + "recognition": recognition + }) +} + +/// A governed manual-adjustment body: `action` + a two-leg balanced set +/// (`legs[i] = (account_class, side, amount_minor)`), no payer (the parking / +/// clearing classes are payer-less), mandatory `reason_code`, no tax. The preparer +/// actor is NOT in the body — it is the authenticated subject the handler stamps. +fn manual_body( + s: &Seller, + adjustment_id: &str, + action: &str, + legs: &[(AccountClass, Side, i64)], +) -> serde_json::Value { + let legs: Vec = legs + .iter() + .map(|(class, side, amount)| { + serde_json::json!({ + "account_class": class.as_str(), + "side": side.as_str(), + "amount_minor": amount, + "revenue_stream": serde_json::Value::Null, + }) + }) + .collect(); + serde_json::json!({ + "tenant_id": s.tenant, + "adjustment_id": adjustment_id, + "action": action, + "currency": "USD", + "legs": legs, + "reason_code": "ROUNDING_RESIDUE", + "tax": [] + }) +} + +async fn send( + router: Router, + method: &str, + uri: &str, + body: Option, +) -> (StatusCode, serde_json::Value) { + let mut builder = Request::builder().method(method).uri(uri); + let req = if let Some(b) = body { + builder = builder.header(header::CONTENT_TYPE, "application/json"); + builder.body(Body::from(b.to_string())).expect("build req") + } else { + builder.body(Body::empty()).expect("build req") + }; + let response = router.oneshot(req).await.expect("send"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).expect("body must be JSON") + }; + (status, value) +} + +/// Assert the RFC 9457 `problem+json` body carries the expected machine code +/// (`field_violation.reason` / `aborted.reason`). Matched as a substring of the +/// serialized body, the `rest_credit.rs` idiom (the exact field placement varies +/// by canonical category, but the wire code is always present in the body). +fn assert_problem_code(body: &serde_json::Value, code: &str) { + let rendered = body.to_string(); + assert!( + rendered.contains(code), + "expected {code} in problem body, got {rendered}" + ); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// A deferred credit note → 201; an identical re-post (same `credit_note_id`) → +/// 200 (`replayed = true`), no second entry. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn credit_note_posts_201_then_replays_200() { + let (_c, _raw, provider, s) = boot().await; + // 1200 ex-tax subscription, straight-line over 12 ⇒ the whole 1200 defers; + // AR = 1200; headroom original = 1200. + post_invoice( + &provider, + &s, + &invoice(&s, "INV-CN", vec![item(1200, 12, "item-1")]), + ) + .await; + + let app = + router_with(provider.clone(), allow_enforcer()).layer(axum::Extension(authed_context())); + // Credit 300 entirely against the deferred balance. + let (status, body) = send( + app, + "POST", + "/bss-ledger/v1/credit-notes", + Some(credit_body(&s, "CN-1", "INV-CN", 300, 300)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "fresh credit note 201: {body}"); + assert_eq!(body["replayed"], serde_json::json!(false)); + + // Idempotent replay (same credit_note_id) → 200 replayed. + let app2 = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, body) = send( + app2, + "POST", + "/bss-ledger/v1/credit-notes", + Some(credit_body(&s, "CN-1", "INV-CN", 300, 300)), + ) + .await; + assert_eq!(status, StatusCode::OK, "replay 200: {body}"); + assert_eq!(body["replayed"], serde_json::json!(true)); +} + +/// A credit note whose incl-tax amount exceeds the invoice's headroom (no prior +/// debit note raised it) → 400 `CREDIT_NOTE_EXCEEDS_HEADROOM`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn credit_note_over_headroom_rejected() { + let (_c, _raw, provider, s) = boot().await; + // original headroom = 700 (posted AR). + post_invoice( + &provider, + &s, + &invoice(&s, "INV-CAP", vec![item(700, 12, "item-1")]), + ) + .await; + + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + // Credit 800 against the deferred balance — over the 700 headroom. + let (status, body) = send( + app, + "POST", + "/bss-ledger/v1/credit-notes", + Some(credit_body(&s, "CN-OVER", "INV-CAP", 800, 800)), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "over-headroom 400: {body}"); + assert_problem_code(&body, "CREDIT_NOTE_EXCEEDS_HEADROOM"); +} + +/// A credit note requesting a deferred part against a POINT-IN-TIME invoice (no +/// deferred schedule, so no obligation to reduce) → 400 +/// `CREDIT_NOTE_SPLIT_AMBIGUOUS` (block-on-ambiguous, never a silent pro-rata). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn credit_note_split_ambiguous_rejected() { + let (_c, _raw, provider, s) = boot().await; + // A fully point-in-time invoice ⇒ NO deferred schedule on `subscription`. + post_invoice( + &provider, + &s, + &invoice(&s, "INV-PIT", vec![item(500, 0, "item-1")]), + ) + .await; + + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + // Request a deferred part (200) with no obligation to reduce ⇒ ambiguous. + let (status, body) = send( + app, + "POST", + "/bss-ledger/v1/credit-notes", + Some(credit_body(&s, "CN-AMB", "INV-PIT", 200, 200)), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "ambiguous 400: {body}"); + assert_problem_code(&body, "CREDIT_NOTE_SPLIT_AMBIGUOUS"); +} + +/// A deferred debit note → 201, and it RAISES the invoice's headroom: a credit +/// note that would have been over-cap against the original AR now fits (201). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn debit_note_posts_201_and_raises_headroom() { + let (_c, _raw, provider, s) = boot().await; + // original headroom = 500 (posted AR). + post_invoice( + &provider, + &s, + &invoice(&s, "INV-DN", vec![item(500, 12, "item-1")]), + ) + .await; + + // Debit note +600 (deferred over 6) ⇒ headroom now 500 + 600 = 1100. + let app = + router_with(provider.clone(), allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, body) = send( + app, + "POST", + "/bss-ledger/v1/debit-notes", + Some(debit_body(&s, "DN-1", "INV-DN", 600, 600, 6)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "fresh debit note 201: {body}"); + assert_eq!(body["replayed"], serde_json::json!(false)); + + // A credit note of 900 (> original 500, <= raised 1100) now fits. + let app2 = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, body) = send( + app2, + "POST", + "/bss-ledger/v1/credit-notes", + Some(credit_body(&s, "CN-AFTER-DN", "INV-DN", 900, 900)), + ) + .await; + assert_eq!( + status, + StatusCode::CREATED, + "credit note within raised headroom 201: {body}" + ); +} + +/// `GET …/exposure` after a credit note → 200 with the headroom counters +/// (`original`, `credit_note_total`, `remaining_headroom = original − credit`) + +/// the remaining open AR. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn get_exposure_returns_headroom_and_open_ar() { + let (_c, _raw, provider, s) = boot().await; + post_invoice( + &provider, + &s, + &invoice(&s, "INV-EXP", vec![item(1000, 12, "item-1")]), + ) + .await; + + // Post a 300 credit note so the exposure row is seeded + bumped. + let app = + router_with(provider.clone(), allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, _) = send( + app, + "POST", + "/bss-ledger/v1/credit-notes", + Some(credit_body(&s, "CN-EXP", "INV-EXP", 300, 300)), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + + let app2 = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, body) = send( + app2, + "GET", + &format!( + "/bss-ledger/v1/invoices/INV-EXP/exposure?tenant_id={}", + s.tenant + ), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "exposure 200: {body}"); + assert_eq!(body["original_total_minor"], serde_json::json!(1000)); + assert_eq!(body["credit_note_total_minor"], serde_json::json!(300)); + assert_eq!(body["debit_note_total_minor"], serde_json::json!(0)); + assert_eq!( + body["remaining_headroom_minor"], + serde_json::json!(700), + "1000 − 300" + ); + // Open AR net down by the 300 credit (1000 − 300 = 700). + assert_eq!( + body["open_ar_minor"], + serde_json::json!(700), + "body: {body}" + ); +} + +/// `GET …/exposure` for an invoice with no note posted yet (no exposure row) → +/// 404 (no existence leak). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn get_exposure_absent_returns_404() { + let (_c, _raw, provider, s) = boot().await; + post_invoice( + &provider, + &s, + &invoice(&s, "INV-NONE", vec![item(1000, 12, "item-1")]), + ) + .await; + // No credit/debit note posted ⇒ no invoice_exposure row. + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, _body) = send( + app, + "GET", + &format!( + "/bss-ledger/v1/invoices/INV-NONE/exposure?tenant_id={}", + s.tenant + ), + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +/// A credit note whose body `tenant_id` is outside the caller's authorized scope +/// → 403 (the write-gate's cross-tenant membership assert denies it). Uses the +/// allow fake (which authorizes ONLY `SUBJECT_TENANT`) with a FOREIGN body tenant, +/// matching how `rest_credit.rs` tests the cross-tenant deny. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn credit_note_foreign_tenant_denied_403() { + let (_c, _raw, provider, s) = boot().await; + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let mut body = credit_body(&s, "CN-FOREIGN", "INV-X", 100, 0); + body["tenant_id"] = serde_json::json!(FOREIGN_TENANT); + let (status, _b) = send(app, "POST", "/bss-ledger/v1/credit-notes", Some(body)).await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +/// A PDP deny → 403 (the gate maps the refusal). Mirrors +/// `rest_recognition.rs::trigger_recognition_run_pdp_deny_returns_403`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn credit_note_pdp_deny_403() { + let (_c, _raw, provider, s) = boot().await; + let app = router_with(provider, deny_enforcer()).layer(axum::Extension(authed_context())); + let (status, _b) = send( + app, + "POST", + "/bss-ledger/v1/credit-notes", + Some(credit_body(&s, "CN-DENY", "INV-X", 100, 0)), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +// ── Manual adjustments (Group 6 / Phase 3) ─────────────────────────────────── + +/// A governed rounding correction (DR CASH_CLEARING 1 / CR SUSPENSE 1, both in the +/// `ROUNDING_CORRECTION` allow-list, payer-less) → 201; an identical re-post (same +/// `adjustment_id`) → 200 (`replayed = true`), no second entry. The guarded +/// `CASH_CLEARING` (debit-normal in `boot`) is DEBITED so its projected balance +/// stays non-negative; `SUSPENSE` (credit-normal, un-guarded) takes the credit. +/// Mirrors `postgres_manual_adjustment::rounding_correction_posts_then_replays_idempotently` +/// driven through the REST surface. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn manual_adjustment_posts_201_then_replays_200() { + let (_c, _raw, provider, s) = boot().await; + let app = + router_with(provider.clone(), allow_enforcer()).layer(axum::Extension(authed_context())); + let legs = [ + (AccountClass::CashClearing, Side::Debit, 1), + (AccountClass::Suspense, Side::Credit, 1), + ]; + let (status, body) = send( + app, + "POST", + "/bss-ledger/v1/manual-adjustments", + Some(manual_body(&s, "ADJ-RC-1", "ROUNDING_CORRECTION", &legs)), + ) + .await; + assert_eq!( + status, + StatusCode::CREATED, + "fresh manual adjustment 201: {body}" + ); + assert_eq!(body["replayed"], serde_json::json!(false)); + + // Idempotent replay (same adjustment_id) → 200 replayed. + let app2 = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, body) = send( + app2, + "POST", + "/bss-ledger/v1/manual-adjustments", + Some(manual_body(&s, "ADJ-RC-1", "ROUNDING_CORRECTION", &legs)), + ) + .await; + assert_eq!(status, StatusCode::OK, "replay 200: {body}"); + assert_eq!(body["replayed"], serde_json::json!(true)); +} + +/// A balanced adjustment with a leg OUTSIDE the action's allow-list (DR SUSPENSE 5 / +/// CR TAX_PAYABLE 5 — `TAX_PAYABLE` is in no allow-list) → 400 +/// `MANUAL_ADJUSTMENT_NOT_ALLOWED`, no books effect (the pure `govern` gate rejects +/// before the post). Mirrors `postgres_manual_adjustment::class_outside_allow_list_is_not_allowed`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn manual_adjustment_class_outside_allow_list_rejected() { + let (_c, _raw, provider, s) = boot().await; + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let legs = [ + (AccountClass::Suspense, Side::Debit, 5), + (AccountClass::TaxPayable, Side::Credit, 5), + ]; + let (status, body) = send( + app, + "POST", + "/bss-ledger/v1/manual-adjustments", + Some(manual_body( + &s, + "ADJ-NOTALLOWED", + "ROUNDING_CORRECTION", + &legs, + )), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "not-allowed 400: {body}"); + assert_problem_code(&body, "MANUAL_ADJUSTMENT_NOT_ALLOWED"); +} + +/// A disguised write-off shape (DR CONTRA_REVENUE 5 / CR AR 5 — an unpaired +/// CONTRA_REVENUE leg with no same-stream recognized-REVENUE reduction) → 400 +/// `MANUAL_ADJUSTMENT_NOT_ALLOWED` (the `govern` write-off structural guard fires; +/// the handler additionally captures + pages out-of-band, but the wire result is the +/// same canonical 400). `payer_tenant_id` is supplied because the AR leg is +/// payer-scoped — but the write-off guard rejects it before the payer gate matters. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn manual_adjustment_write_off_rejected() { + let (_c, _raw, provider, s) = boot().await; + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let legs = [ + (AccountClass::ContraRevenue, Side::Debit, 5), + (AccountClass::Ar, Side::Credit, 5), + ]; + let mut body = manual_body(&s, "ADJ-WRITEOFF", "SUSPENSE_CLEAR", &legs); + // The AR leg is payer-scoped; supply the payer so the write-off guard (not the + // payer gate) is the rejecting check. + body["payer_tenant_id"] = serde_json::json!(s.payer); + let (status, body) = send(app, "POST", "/bss-ledger/v1/manual-adjustments", Some(body)).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "write-off 400: {body}"); + assert_problem_code(&body, "MANUAL_ADJUSTMENT_NOT_ALLOWED"); +} diff --git a/gears/bss/ledger/ledger/tests/rest_audit.rs b/gears/bss/ledger/ledger/tests/rest_audit.rs new file mode 100644 index 000000000..e6427fce0 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/rest_audit.rs @@ -0,0 +1,287 @@ +//! API-level (router) §11 tests for the audit surface's cross-tenant elevation +//! seam. Drives the real `audit::router` over a migrated +//! Postgres (testcontainers) with a fake `PolicyEnforcer`, asserting the +//! cross-tenant deny paths at the HTTP seam: +//! +//! (1) a cross-tenant `tamper-status` read whose caller is NOT entitled to the +//! TARGET tenant ⇒ 403 `CROSS_TENANT_ACCESS_DENIED` (the PDP deny flows +//! through `cross_tenant_role_authorized` → the gateway, in a real txn); +//! (2) an AUTHORIZED cross-tenant `erasure` with NO investigation reason ⇒ 400 +//! `MISSING_INVESTIGATION_REASON`, rejected PRE-read (before any DB write); +//! (3) an UNAUTHORIZED cross-tenant `erasure` ⇒ 403 `CROSS_TENANT_ACCESS_DENIED` +//! even with no reason — proving role is checked BEFORE reason (§5), the +//! order the shared `resolve_action_scope` enforces for every write path. +//! +//! These are the §11 cases that were previously missing — they would have +//! caught the original cross-tenant BOLA. A real DB backs `ApiState`; path (2)/(3) return +//! before the txn, path (1) opens a serializable txn the gateway rolls back. +//! +//! Ignored by default; run with `cargo test -p bss-ledger -- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use bss_ledger::api::rest::audit::{ApiState, router}; +use bss_ledger::infra::audit::retrieval::AuditRetrievalReader; +use bss_ledger::infra::authz::cross_tenant::CrossTenantGateway; +use bss_ledger::infra::inquiry::AuditPackExporter; +use bss_ledger::infra::pii::ErasureService; +use bss_ledger::infra::storage::migrations::Migrator; +use sea_orm::Database; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit::api::OpenApiRegistryImpl; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::{SecurityContext, pep_properties}; +use tower::ServiceExt; +use uuid::Uuid; + +/// Flat-`In` PDP fake authorizing one or more tenants (mirrors the unit-test +/// `audit_tests::FlatInResolver`): an authorized tenant's gate passes; any other +/// `targetScope` is outside the returned `In` constraint, so the cross-tenant +/// role check resolves `Ok(false)` → `CROSS_TENANT_ACCESS_DENIED`. +struct FlatInResolver { + allowed: Vec, +} + +#[async_trait] +impl AuthZResolverClient for FlatInResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::In(InPredicate::new( + pep_properties::OWNER_TENANT_ID, + self.allowed.clone(), + ))], + }], + deny_reason: None, + }, + }) + } +} + +fn enforcer_allowing(tenant: Uuid) -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(FlatInResolver { + allowed: vec![tenant], + })) +} + +/// PDP fake authorizing several tenants (the caller's home plus a cross-tenant +/// target), so the cross-tenant role check passes and a downstream gate (e.g. a +/// missing reason) is what rejects. +fn enforcer_allowing_many(tenants: Vec) -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(FlatInResolver { allowed: tenants })) +} + +fn ctx_for(tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::now_v7()) + .subject_tenant_id(tenant) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// Boot a container, migrate, and build the audit router with a real `ApiState` +/// over the migrated DB plus the given enforcer + caller context. +fn audit_router( + provider: DBProvider, + enforcer: PolicyEnforcer, + ctx: SecurityContext, +) -> Router { + let state = Arc::new(ApiState { + reader: AuditRetrievalReader::new(provider.clone()), + gateway: CrossTenantGateway::new(), + exporter: AuditPackExporter::new(provider.clone()), + erasure: ErasureService::new(), + db: provider, + }); + let openapi = OpenApiRegistryImpl::new(); + router(state, &openapi) + .layer(axum::Extension(enforcer)) + .layer(axum::Extension(ctx)) +} + +async fn provider_for(url: &str) -> DBProvider { + let raw = Database::connect(url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + DBProvider::::new(tdb) +} + +async fn body_string(response: axum::http::Response) -> String { + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +/// (1) A cross-tenant tamper-status read by a caller NOT entitled to the target +/// ⇒ 403 `CROSS_TENANT_ACCESS_DENIED` at the REST seam (the BOLA deny path). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_tamper_status_denied_returns_403() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let provider = provider_for(&url).await; + + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + // The enforcer authorizes ONLY the home tenant: the home audit_read gate + // passes, but the cross-tenant role check for `target` resolves false. + let router = audit_router(provider, enforcer_allowing(home), ctx_for(home)); + + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/bss-ledger/v1/ledger/audit/tamper-status?target_scope={target}&reason_code=DISPUTE" + )) + .header("X-Investigation-Reason", "investigate") + .body(Body::empty()) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "a caller not entitled to the target tenant must be denied" + ); + let body = body_string(response).await; + assert!( + body.contains("CROSS_TENANT_ACCESS_DENIED"), + "the problem body must carry CROSS_TENANT_ACCESS_DENIED; got {body}" + ); +} + +/// (2) A cross-tenant erasure whose caller IS authorized for the target but +/// omits the investigation reason ⇒ 400 `MISSING_INVESTIGATION_REASON`, rejected +/// before any read/write. The caller must be authorized for the target so the +/// role check passes first (§5: role BEFORE reason — see the 403 test below). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_erasure_without_reason_returns_400() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let provider = provider_for(&url).await; + + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + let payer = Uuid::now_v7(); + // The enforcer authorizes BOTH the home tenant (the erase gate) and the + // target (the cross-tenant role check), so the role gate passes and the + // missing X-Investigation-Reason header is what rejects, pre-read. + let router = audit_router( + provider, + enforcer_allowing_many(vec![home, target]), + ctx_for(home), + ); + + let body = serde_json::json!({ + "payer_tenant_id": payer, + "target_scope": target, + }) + .to_string(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/bss-ledger/v1/ledger/audit/erasure") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "an authorized cross-tenant erasure without a reason must be rejected pre-read" + ); + let body = body_string(response).await; + assert!( + body.contains("MISSING_INVESTIGATION_REASON"), + "the problem body must carry MISSING_INVESTIGATION_REASON; got {body}" + ); +} + +/// (3) A cross-tenant erasure whose caller is NOT entitled to the TARGET tenant +/// ⇒ 403 `CROSS_TENANT_ACCESS_DENIED`. The role is checked BEFORE the reason +/// (§5), so an unauthorized caller is denied even with no reason — it never +/// learns whether a reason would have sufficed. Guards the role-first ordering +/// that the shared `resolve_action_scope` enforces. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_erasure_unauthorized_returns_403() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let provider = provider_for(&url).await; + + let home = Uuid::now_v7(); + let target = Uuid::now_v7(); + let payer = Uuid::now_v7(); + // Only the home tenant is authorized; the target is outside the caller's + // compiled scope, so the cross-tenant role check fails. No reason is sent, + // proving role is evaluated first. + let router = audit_router(provider, enforcer_allowing(home), ctx_for(home)); + + let body = serde_json::json!({ + "payer_tenant_id": payer, + "target_scope": target, + }) + .to_string(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/bss-ledger/v1/ledger/audit/erasure") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "an unauthorized cross-tenant erasure must be denied on the role, not the reason" + ); + let body = body_string(response).await; + assert!( + body.contains("CROSS_TENANT_ACCESS_DENIED"), + "the problem body must carry CROSS_TENANT_ACCESS_DENIED; got {body}" + ); +} diff --git a/gears/bss/ledger/ledger/tests/rest_credit.rs b/gears/bss/ledger/ledger/tests/rest_credit.rs new file mode 100644 index 000000000..539b52453 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/rest_credit.rs @@ -0,0 +1,969 @@ +//! API-level (router) tests for the credit-application REST surface (Group E): +//! `POST /bss-ledger/v1/credit-applications` (grant | apply, tenant in body). +//! +//! Like `rest_payments.rs`, these drive the router against a REAL testcontainer +//! Postgres so grant / apply run end-to-end through the foundation engine. +//! `LedgerLocalClient::new` is `pub(crate)` and unreachable from this out-of-crate +//! test, so the router's `ApiState` is built over a small in-test +//! `LedgerClientV1` (`RealCreditClient`) whose `post_credit_application` mirrors +//! the production local client verbatim: it matches the `CreditApplication` enum, +//! drives the SAME `pub` `CreditApplicationService` (grant_credit / apply_credit), +//! and maps the `CreditApplicationOutcome` back to the SDK `CreditApplicationApplied`. +//! The non-credit trait methods are `unimplemented!()` (the inverse of +//! `rest_payments.rs`). An always-allow `PolicyEnforcer` fake is layered as an +//! `Extension`, mirroring `register_rest`. +//! +//! Cases: a grant → 201; an apply (fund + open AR first) → 201 with the +//! per-sub-grain debits + per-invoice applications; an over-pool grant → 400 +//! `GRANT_EXCEEDS_UNALLOCATED`; an over-open-AR apply → 400 `CREDIT_EXCEEDS_OPEN_AR`; +//! a credit-application whose body `tenant_id` is outside the caller's scope → 403. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::needless_pass_by_value +)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use bss_ledger::api::rest::credit::{ApiState, router}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::credit::{ApplyRequest, CreditApplicationService, GrantRequest}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use bss_ledger_sdk::api::LedgerClientV1; +use bss_ledger_sdk::posting::{ + AllocateOutcome, AllocatePayment, AllocationView, ArInvoiceBalanceView, BalanceView, + CreditApplication, CreditApplicationApplied, CreditDebitView, EntryView, LineView, ODataQuery, + Page, PostEntry, PostingRef, SettlePayment, UnallocatedView, +}; +use bss_ledger_sdk::{ + AccountClass, AllocationSplit, MappingStatus, ProvisionOutcome, ProvisionRequest, Side, + SourceDocType, +}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +// ── The in-test client: delegates the credit method to the real orchestrator ─ + +/// A `LedgerClientV1` over a real `DBProvider` that drives the SAME `pub` +/// `CreditApplicationService` the in-process `LedgerLocalClient` uses, so the +/// router runs end-to-end against the testcontainer DB. The PEP gate the +/// production client performs internally is exercised at the HANDLER layer here +/// (the allow-all enforcer extension), so the service is called with a plain +/// per-tenant `AccessScope`, mirroring `rest_payments.rs`. Only the credit method +/// is implemented. +struct RealCreditClient { + provider: DBProvider, +} + +impl RealCreditClient { + fn credit_svc(&self) -> CreditApplicationService { + CreditApplicationService::new( + self.provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + } +} + +#[async_trait::async_trait] +impl LedgerClientV1 for RealCreditClient { + async fn return_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::ReturnPayment, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn record_dispute_phase( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::RecordDisputePhase, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn post_credit_application( + &self, + ctx: &SecurityContext, + req: CreditApplication, + ) -> Result { + // Scope: the handler-layer enforcer does the write gate, so (mirroring + // `rest_payments.rs`) build a plain per-tenant scope for the SQL BOLA + // filter from the operation's target tenant. + let scope = AccessScope::for_tenant(req.tenant_id()); + // Dispatch grant vs apply on the enum arm — the SAME mapping the + // production `local_client::post_credit_application` performs (`scale` is + // advisory and dropped; the per-line scale resolver is authoritative). + let outcome = match req { + CreditApplication::Grant(g) => { + self.credit_svc() + .grant_credit( + ctx, + &scope, + GrantRequest { + tenant_id: g.tenant_id, + payer_tenant_id: g.payer_tenant_id, + credit_application_id: g.credit_application_id, + currency: g.currency, + amount_minor: g.amount_minor, + credit_grant_event_type: g.credit_grant_event_type, + }, + ) + .await + } + CreditApplication::Apply(a) => { + self.credit_svc() + .apply_credit( + ctx, + &scope, + ApplyRequest { + tenant_id: a.tenant_id, + payer_tenant_id: a.payer_tenant_id, + credit_application_id: a.credit_application_id, + currency: a.currency, + targets: a + .targets + .into_iter() + .map(|s| bss_ledger::domain::payment::precedence::Allocated { + invoice_id: s.invoice_id, + amount_minor: s.amount_minor, + }) + .collect(), + }, + ) + .await + } + } + .map_err(CanonicalError::from)?; + // Map the domain outcome to the SDK shape: `debits` → per-sub-grain wallet + // draw-downs, `applications` ← the validated per-invoice targets. A grant + // leaves both empty. + Ok(CreditApplicationApplied { + posting: outcome.posting, + debits: outcome + .debits + .into_iter() + .map(|d| CreditDebitView { + credit_grant_event_type: d.credit_grant_event_type, + amount_minor: d.amount_minor, + }) + .collect(), + applications: outcome + .targets + .into_iter() + .map(|t| AllocationSplit { + invoice_id: t.invoice_id, + amount_minor: t.amount_minor, + }) + .collect(), + }) + } + + // ── The non-credit surface is not exercised by these router tests. ── + + async fn settle_payment( + &self, + _ctx: &SecurityContext, + _req: SettlePayment, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn allocate_payment( + &self, + _ctx: &SecurityContext, + _req: AllocatePayment, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn list_payment_allocations( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payment_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the credit router tests") + } + + async fn read_unallocated( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Uuid, + _currency: String, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn post_balanced_entry( + &self, + _ctx: &SecurityContext, + _entry: PostEntry, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn read_account_balance( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _account_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the credit router tests") + } + + async fn list_accounts( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the credit router tests") + } + + async fn get_entry( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _entry_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the credit router tests") + } + + async fn list_lines( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the credit router tests") + } + + async fn list_balances( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the credit router tests") + } + + async fn list_ar_invoice_balances( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Option, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the credit router tests") + } + + async fn provision( + &self, + _ctx: &SecurityContext, + _req: ProvisionRequest, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn close_period( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _period_id: String, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn trigger_recognition_run( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::TriggerRecognitionRun, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn list_revenue_disaggregation( + &self, + _ctx: &SecurityContext, + _query: bss_ledger_sdk::RevenueDisaggregationQuery, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn change_recognition_schedule( + &self, + _ctx: &SecurityContext, + _cmd: bss_ledger_sdk::ChangeRecognitionSchedule, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } + + async fn get_recognition_schedule( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _schedule_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the credit router tests") + } + + async fn list_recognition_schedules( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _invoice_id: Option, + _revenue_stream: Option, + ) -> Result { + unimplemented!("not exercised by the credit router tests") + } +} + +// ── Authz fakes (mirror rest_payments.rs) ───────────────────────────────────── + +fn subject_tenant_id(request: &EvaluationRequest) -> Uuid { + request + .subject + .properties + .get("tenant_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(Uuid::nil) +} + +fn tenant_in_constraint(tenant_id: Uuid) -> Constraint { + Constraint { + predicates: vec![Predicate::In(InPredicate::new( + toolkit_security::pep_properties::OWNER_TENANT_ID, + [tenant_id], + ))], + } +} + +/// Always-allow fake that echoes the subject's tenant as an `owner_tenant_id` +/// `In` constraint (so the handler write-gate's target-membership check passes +/// when the body `tenant_id` equals the subject tenant). +struct AllowAuthZ; + +#[async_trait] +impl AuthZResolverClient for AllowAuthZ { + async fn evaluate( + &self, + request: EvaluationRequest, + ) -> Result { + let tenant_id = subject_tenant_id(&request); + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![tenant_in_constraint(tenant_id)], + deny_reason: None, + }, + }) + } +} + +fn allow_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(AllowAuthZ)) +} + +// ── DB + seller setup (mirror rest_payments.rs) ────────────────────────────── + +/// The authenticated caller's tenant — also the provisioned seller and the only +/// tenant the allow fake authorizes. The body `tenant_id` MUST equal this so the +/// handler write-gate's `contains_uuid` membership check passes. +const SUBJECT_TENANT: Uuid = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); +const SUBJECT_ID: Uuid = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + +/// Boot a container, migrate on a raw connection, and return a `bss`-search-path +/// `DBProvider` for the services (the provisioning-test idiom). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller ids. `tenant` is fixed to `SUBJECT_TENANT` so the handler +/// authz gate (body `tenant_id` ∈ the caller's scope) passes. +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + reusable_credit: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision the seller (= `SUBJECT_TENANT`): USD@2 scale, an OPEN fiscal period +/// for the current month, the four payment-flow chart accounts, and a stream-less +/// REUSABLE_CREDIT credit account. Mirrors `rest_payments.rs::setup_seller`. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: SUBJECT_TENANT, + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + reusable_credit: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + account( + s.tenant, + s.reusable_credit, + AccountClass::ReusableCredit, + Side::Credit, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Seed an OPEN AR invoice by posting `DR AR (invoice_id) / CR PSP_FEE_EXPENSE` +/// directly through the engine (mirrors `rest_payments.rs::seed_ar_invoice`). +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +/// Settle a fee-less payment of `gross` to fund the payer's unallocated pool +/// (the grant cap basis / wallet funding source). +async fn fund_pool(provider: &DBProvider, s: &Seller, payment_id: &str, gross: i64) { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle to fund the pool"); +} + +/// Grant `amount` into the named wallet sub-grain directly through the service +/// (fund a wallet for the apply tests). +async fn grant_wallet( + provider: &DBProvider, + s: &Seller, + credit_application_id: &str, + event_type: &str, + amount: i64, +) { + CreditApplicationService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .grant_credit( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + GrantRequest { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + credit_application_id: credit_application_id.to_owned(), + currency: "USD".to_owned(), + amount_minor: amount, + credit_grant_event_type: event_type.to_owned(), + }, + ) + .await + .expect("grant to fund the wallet"); +} + +// ── Router / context helpers ───────────────────────────────────────────────── + +/// Build the credit router over the `RealCreditClient` (real DB) with the +/// always-allow enforcer layered as an `Extension`, as `register_rest` does. +fn router_with_db(provider: DBProvider) -> Router { + let state = Arc::new(ApiState { + client: Arc::new(RealCreditClient { provider }) as Arc, + approval: None, + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + router(state, &openapi).layer(axum::Extension(allow_enforcer())) +} + +fn authed_context() -> SecurityContext { + SecurityContext::builder() + .subject_id(SUBJECT_ID) + .subject_tenant_id(SUBJECT_TENANT) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +fn grant_body( + s: &Seller, + credit_application_id: &str, + event_type: &str, + amount: i64, +) -> serde_json::Value { + serde_json::json!({ + "kind": "grant", + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "credit_application_id": credit_application_id, + "currency": "USD", + "scale": 2, + "amount_minor": amount, + "credit_grant_event_type": event_type + }) +} + +fn apply_body( + s: &Seller, + credit_application_id: &str, + targets: serde_json::Value, +) -> serde_json::Value { + serde_json::json!({ + "kind": "apply", + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "credit_application_id": credit_application_id, + "currency": "USD", + "scale": 2, + "targets": targets + }) +} + +async fn send( + router: Router, + method: &str, + uri: &str, + body: Option, +) -> (StatusCode, serde_json::Value) { + let mut builder = Request::builder().method(method).uri(uri); + let req = if let Some(b) = body { + builder = builder.header(header::CONTENT_TYPE, "application/json"); + builder.body(Body::from(b.to_string())).expect("build req") + } else { + builder.body(Body::empty()).expect("build req") + }; + let response = router.oneshot(req).await.expect("send"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).expect("body must be JSON") + }; + (status, value) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// `POST /credit-applications` with `kind=grant` after a settle → 201, the +/// `replayed=false` posting and (for a grant) empty debits/applications. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn grant_returns_201() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + fund_pool(&provider, &s, "PAY-G-201", 1000).await; + + let (status, body) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/credit-applications", + Some(grant_body(&s, "CR-G-201", "promo", 600)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "grant is 201: {body}"); + assert_eq!(body["replayed"], serde_json::json!(false)); + assert_eq!( + body["debits"].as_array().map(Vec::len), + Some(0), + "a grant moves no wallet debits" + ); + assert_eq!( + body["applications"].as_array().map(Vec::len), + Some(0), + "a grant pays no receivables" + ); +} + +/// `POST /credit-applications` with `kind=apply` after funding a wallet + an open +/// AR → 201 with the per-sub-grain debits and the per-invoice applications. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn apply_returns_201() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // Fund a 500 "promo" wallet and seed an open AR invoice of 500. + fund_pool(&provider, &s, "PAY-A-201", 1000).await; + grant_wallet(&provider, &s, "CR-A-201-GRANT", "promo", 500).await; + seed_ar_invoice( + &provider, + &s, + "inv-1", + 500, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let (status, body) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/credit-applications", + Some(apply_body( + &s, + "CR-A-201", + serde_json::json!([{ "invoice_id": "inv-1", "amount_minor": 500 }]), + )), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "apply is 201: {body}"); + assert_eq!(body["replayed"], serde_json::json!(false)); + + // One debit (promo 500) and one application (inv-1 500). + let debits = body["debits"].as_array().expect("debits array"); + assert_eq!(debits.len(), 1, "one sub-grain drawn"); + assert_eq!( + debits[0]["credit_grant_event_type"], + serde_json::json!("promo") + ); + assert_eq!(debits[0]["amount_minor"], serde_json::json!(500)); + let applications = body["applications"].as_array().expect("applications array"); + assert_eq!(applications.len(), 1, "one receivable paid"); + assert_eq!(applications[0]["invoice_id"], serde_json::json!("inv-1")); + assert_eq!(applications[0]["amount_minor"], serde_json::json!(500)); +} + +/// A grant whose amount exceeds the payer's live unallocated pool → 409 +/// `GRANT_EXCEEDS_UNALLOCATED` (the `DomainError::GrantExceedsUnallocated` → +/// `aborted` mapping — an exceeded cap is a conflict, not a bad request). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn grant_over_unallocated_returns_409_with_code() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // Pool holds only 100; granting 500 exceeds it. + fund_pool(&provider, &s, "PAY-G-400", 100).await; + + let (status, problem) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/credit-applications", + Some(grant_body(&s, "CR-G-400", "promo", 500)), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "over-pool grant is 409: {problem}" + ); + assert!( + problem.to_string().contains("GRANT_EXCEEDS_UNALLOCATED"), + "expected GRANT_EXCEEDS_UNALLOCATED, got {problem}" + ); +} + +/// An apply whose targets exceed open AR → 409 `CREDIT_EXCEEDS_OPEN_AR` (the +/// `DomainError::CreditExceedsOpenAr` → `aborted` mapping, same 409 family +/// as the grant rejection). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn apply_over_open_ar_returns_409_with_code() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // Ample wallet (1000), but the open AR invoice is only 300. + fund_pool(&provider, &s, "PAY-A-400", 1000).await; + grant_wallet(&provider, &s, "CR-A-400-GRANT", "promo", 1000).await; + seed_ar_invoice( + &provider, + &s, + "inv-1", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let (status, problem) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/credit-applications", + Some(apply_body( + &s, + "CR-A-400", + serde_json::json!([{ "invoice_id": "inv-1", "amount_minor": 500 }]), + )), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "over-open-AR apply is 409: {problem}" + ); + assert!( + problem.to_string().contains("CREDIT_EXCEEDS_OPEN_AR"), + "expected CREDIT_EXCEEDS_OPEN_AR, got {problem}" + ); +} + +/// A credit-application whose body `tenant_id` is OUTSIDE the caller's authorized +/// scope (the allow fake echoes only the subject tenant as `owner_tenant_id`) → +/// the handler `(credit_application, write)` gate's target-membership assertion +/// denies it → 403. Mirrors `rest_payments.rs::settle_into_foreign_tenant_is +/// _denied_403`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn cross_tenant_target_is_forbidden_403() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // Body targets a tenant the caller is NOT authorized for (≠ SUBJECT_TENANT). + let foreign = Uuid::now_v7(); + let body = serde_json::json!({ + "kind": "grant", + "tenant_id": foreign, + "payer_tenant_id": s.payer, + "credit_application_id": "CR-FOREIGN", + "currency": "USD", + "scale": 2, + "amount_minor": 100, + "credit_grant_event_type": "promo" + }); + let (status, problem) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/credit-applications", + Some(body), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "cross-tenant credit-application must be 403: {problem}" + ); + + // No wallet sub-grain was created for the foreign tenant. + let foreign_subgrain = raw + .query_one(pg(format!( + "SELECT balance_minor FROM bss.ledger_reusable_credit_subbalance \ + WHERE tenant_id='{foreign}'" + ))) + .await + .unwrap(); + assert!( + foreign_subgrain.is_none(), + "a denied credit-application must leave no wallet sub-grain" + ); +} diff --git a/gears/bss/ledger/ledger/tests/rest_disputes.rs b/gears/bss/ledger/ledger/tests/rest_disputes.rs new file mode 100644 index 000000000..cbbdcd765 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/rest_disputes.rs @@ -0,0 +1,724 @@ +//! API-level (router) tests for the ledger's chargeback (dispute) REST surface +//! (Group D): `POST /bss-ledger/v1/disputes/{dispute_id}/phases` (record a +//! dispute phase, tenant in body, dispute id in path). +//! +//! Mirrors `rest_payments.rs`: the router drives a REAL testcontainer Postgres +//! through a small in-test `LedgerClientV1` (`RealDisputeClient`) that delegates +//! `record_dispute_phase` to the SAME `pub` `ChargebackService` the in-process +//! `LedgerLocalClient` uses (parsing the wire phase / funds literals exactly as +//! `local_client.rs` does); every other trait method is `unimplemented!()`. An +//! always-allow `PolicyEnforcer` fake (no PDP) is layered as an `Extension`, +//! echoing the subject tenant as an `owner_tenant_id` constraint so the handler +//! write-gate's target-membership check passes when the body `tenant_id` equals +//! the subject tenant. +//! +//! Cases: an `opened` cash-hold (withheld) after a settle → 201; a cross-tenant +//! record (body `tenant_id` outside the caller's scope) → 403; an out-of-order +//! `won` before any `opened` → 202 with the `dispute-phase-queued` status token +//! (mirrors the allocate 202 `allocation-queued` assertion). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use bss_ledger::api::rest::disputes::{ApiState, router}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::chargeback::{ + ChargebackOutcome, ChargebackRequest, ChargebackService, +}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{DisputeRepo, ReferenceRepo}; +use bss_ledger_sdk::api::LedgerClientV1; +use bss_ledger_sdk::posting::{ + AllocateOutcome, AllocatePayment, ArInvoiceBalanceView, BalanceView, DisputeOutcome, + DisputeQueued, DisputeRecorded, EntryView, LineView, ODataQuery, Page, PostEntry, PostingRef, + RecordDisputePhase, SettlePayment, UnallocatedView, +}; +use bss_ledger_sdk::{AccountClass, ProvisionOutcome, ProvisionRequest, Side}; +use chrono::{Datelike, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +// ── The in-test client: delegates record_dispute_phase to the real service ──── + +/// A `LedgerClientV1` over a real `DBProvider` that drives the SAME `pub` +/// `ChargebackService` the in-process `LedgerLocalClient` uses, so the router +/// runs end-to-end against the testcontainer DB. The PEP gate the production +/// client performs internally is exercised at the HANDLER layer here (the +/// allow-all enforcer extension), so the service is called with a plain +/// per-tenant `AccessScope`, mirroring `rest_payments.rs::RealPaymentClient`. +/// Only `record_dispute_phase` is implemented. +struct RealDisputeClient { + provider: DBProvider, +} + +impl RealDisputeClient { + fn chargeback_svc(&self) -> ChargebackService { + ChargebackService::new( + self.provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + } +} + +#[async_trait::async_trait] +impl LedgerClientV1 for RealDisputeClient { + async fn record_dispute_phase( + &self, + ctx: &SecurityContext, + req: RecordDisputePhase, + ) -> Result { + let scope = AccessScope::for_tenant(req.tenant_id); + // Parse the wire literals at the boundary exactly as `local_client.rs` + // does (a bad literal is `InvalidRequest` ⇒ 400, not a deep fault). + let phase = bss_ledger::domain::payment::chargeback::DisputePhase::parse(&req.phase) + .ok_or_else(|| { + CanonicalError::from(bss_ledger::domain::error::DomainError::InvalidRequest( + format!("unknown dispute phase {:?}", req.phase), + )) + })?; + let funds_at_open = + bss_ledger::domain::payment::chargeback::FundsAtOpen::parse(&req.funds_at_open) + .ok_or_else(|| { + CanonicalError::from(bss_ledger::domain::error::DomainError::InvalidRequest( + format!("unknown funds_at_open {:?}", req.funds_at_open), + )) + })?; + let request = ChargebackRequest { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id, + dispute_id: req.dispute_id, + invoice_id: req.invoice_id, + cycle: req.cycle, + phase, + funds_at_open, + disputed_amount_minor: req.disputed_amount_minor, + currency: req.currency, + effective_at: req.effective_at, + }; + match self + .chargeback_svc() + .record_phase(ctx, &scope, request) + .await + .map_err(CanonicalError::from)? + { + ChargebackOutcome::Recorded(posting) => { + Ok(DisputeOutcome::Recorded(DisputeRecorded { posting })) + } + ChargebackOutcome::Queued(queued) => Ok(DisputeOutcome::Queued(DisputeQueued { + flow: queued.flow, + business_id: queued.business_id, + queued_at: queued.queued_at, + })), + } + } + + // ── The non-dispute surface is not exercised by these router tests. ── + + async fn post_credit_application( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::CreditApplication, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn settle_payment( + &self, + _ctx: &SecurityContext, + _req: SettlePayment, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn return_payment( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::ReturnPayment, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn allocate_payment( + &self, + _ctx: &SecurityContext, + _req: AllocatePayment, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn list_payment_allocations( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payment_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the dispute router tests") + } + + async fn read_unallocated( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Uuid, + _currency: String, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn post_balanced_entry( + &self, + _ctx: &SecurityContext, + _entry: PostEntry, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn read_account_balance( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _account_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the dispute router tests") + } + + async fn list_accounts( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the dispute router tests") + } + + async fn get_entry( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _entry_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the dispute router tests") + } + + async fn list_lines( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the dispute router tests") + } + + async fn list_balances( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the dispute router tests") + } + + async fn list_ar_invoice_balances( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Option, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the dispute router tests") + } + + async fn provision( + &self, + _ctx: &SecurityContext, + _req: ProvisionRequest, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn close_period( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _period_id: String, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn trigger_recognition_run( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::TriggerRecognitionRun, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn list_revenue_disaggregation( + &self, + _ctx: &SecurityContext, + _query: bss_ledger_sdk::RevenueDisaggregationQuery, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn change_recognition_schedule( + &self, + _ctx: &SecurityContext, + _cmd: bss_ledger_sdk::ChangeRecognitionSchedule, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } + + async fn get_recognition_schedule( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _schedule_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the dispute router tests") + } + + async fn list_recognition_schedules( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _invoice_id: Option, + _revenue_stream: Option, + ) -> Result { + unimplemented!("not exercised by the dispute router tests") + } +} + +// ── Authz fakes (mirror rest_payments.rs) ───────────────────────────────────── + +fn subject_tenant_id(request: &EvaluationRequest) -> Uuid { + request + .subject + .properties + .get("tenant_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(Uuid::nil) +} + +fn tenant_in_constraint(tenant_id: Uuid) -> Constraint { + Constraint { + predicates: vec![Predicate::In(InPredicate::new( + toolkit_security::pep_properties::OWNER_TENANT_ID, + [tenant_id], + ))], + } +} + +/// Always-allow fake that echoes the subject's tenant as an `owner_tenant_id` +/// `In` constraint (so the handler write-gate's target-membership check passes +/// when the body `tenant_id` equals the subject tenant). +struct AllowAuthZ; + +#[async_trait] +impl AuthZResolverClient for AllowAuthZ { + async fn evaluate( + &self, + request: EvaluationRequest, + ) -> Result { + let tenant_id = subject_tenant_id(&request); + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![tenant_in_constraint(tenant_id)], + deny_reason: None, + }, + }) + } +} + +fn allow_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(AllowAuthZ)) +} + +// ── DB + seller setup (mirror rest_payments.rs) ────────────────────────────── + +/// The authenticated caller's tenant — also the provisioned seller and the only +/// tenant the allow fake authorizes. The body `tenant_id` MUST equal this so the +/// handler write-gate's `contains_uuid` membership check passes. +const SUBJECT_TENANT: Uuid = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); +const SUBJECT_ID: Uuid = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + +/// Boot a container, migrate on a raw connection, and return a `bss`-search-path +/// `DBProvider` for the service (the provisioning-test idiom). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller ids. `tenant` is fixed to `SUBJECT_TENANT` so the handler +/// authz gate (body `tenant_id` ∈ the caller's scope) passes. +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + dispute_hold: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision the seller (= `SUBJECT_TENANT`): USD@2 scale, an OPEN fiscal period +/// for the current month, and the cash-hold dispute chart accounts (CASH_CLEARING +/// debit, UNALLOCATED credit, DISPUTE_HOLD debit). Mirrors +/// `rest_payments.rs::setup_seller`. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: SUBJECT_TENANT, + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + dispute_hold: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + Uuid::now_v7(), + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.dispute_hold, + AccountClass::DisputeHold, + Side::Debit, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +/// Settle `gross` (fee 0) for `payment_id` directly through the service (so an +/// `opened` cash-hold has CASH_CLEARING funds to move into the hold). +async fn settle(provider: &DBProvider, s: &Seller, payment_id: &str, gross: i64) { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle must succeed"); +} + +// ── Router / context helpers ───────────────────────────────────────────────── + +/// Build the dispute router over the `RealDisputeClient` (real DB) with the +/// always-allow enforcer layered as an `Extension`, as `register_rest` does. +fn router_with_db(provider: DBProvider) -> Router { + let state = Arc::new(ApiState { + client: Arc::new(RealDisputeClient { + provider: provider.clone(), + }) as Arc, + approval: None, + dispute_repo: DisputeRepo::new(provider), + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + router(state, &openapi).layer(axum::Extension(allow_enforcer())) +} + +fn authed_context() -> SecurityContext { + SecurityContext::builder() + .subject_id(SUBJECT_ID) + .subject_tenant_id(SUBJECT_TENANT) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +async fn send( + router: Router, + method: &str, + uri: &str, + body: Option, +) -> (StatusCode, serde_json::Value) { + let mut builder = Request::builder().method(method).uri(uri); + let req = if let Some(b) = body { + builder = builder.header(header::CONTENT_TYPE, "application/json"); + builder.body(Body::from(b.to_string())).expect("build req") + } else { + builder.body(Body::empty()).expect("build req") + }; + let response = router.oneshot(req).await.expect("send"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).expect("body must be JSON") + }; + (status, value) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// `POST /disputes/{id}/phases` recording an `opened` cash-hold (withheld) after +/// a settle → 201 fresh (the LEDGER chose CASH_HOLD from `funds_at_open` and +/// posted the hold move). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn record_opened_cash_hold_returns_201() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // Fund CASH_CLEARING so the hold move (CR CASH_CLEARING) does not underflow. + settle(&provider, &s, "PAY-DSP-REST-1", 1000).await; + + let body = serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "payment_id": "PAY-DSP-REST-1", + "phase": "OPENED", + "funds_at_open": "withheld", + "disputed_amount_minor": 1000, + "currency": "USD", + "scale": 2 + }); + let (status, fresh) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/disputes/DSP-REST-1/phases", + Some(body), + ) + .await; + assert_eq!( + status, + StatusCode::CREATED, + "fresh opened cash-hold is 201: {fresh}" + ); + assert_eq!(fresh["replayed"], serde_json::json!(false)); + + // The dispute row was seeded with the chosen variant. + let variant = raw + .query_one(pg(format!( + "SELECT variant FROM bss.ledger_dispute \ + WHERE tenant_id='{}' AND dispute_id='DSP-REST-1'", + s.tenant + ))) + .await + .unwrap() + .map(|r| r.try_get_by_index::(0).unwrap()); + assert_eq!( + variant, + Some("CASH_HOLD".to_owned()), + "the LEDGER recorded variant=CASH_HOLD from funds_at_open=withheld" + ); +} + +/// A record whose body `tenant_id` is OUTSIDE the caller's authorized scope (the +/// allow fake echoes only the subject tenant as `owner_tenant_id`) → the handler +/// `(dispute, write)` gate's target-membership assertion denies it → 403, and no +/// dispute is recorded for the foreign tenant. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn record_into_foreign_tenant_is_denied_403() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // Body targets a tenant the caller is NOT authorized for (≠ SUBJECT_TENANT). + let foreign = Uuid::now_v7(); + let body = serde_json::json!({ + "tenant_id": foreign, + "payer_tenant_id": s.payer, + "payment_id": "PAY-FOREIGN", + "phase": "OPENED", + "funds_at_open": "withheld", + "disputed_amount_minor": 1000, + "currency": "USD", + "scale": 2 + }); + let (status, problem) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/disputes/DSP-FOREIGN/phases", + Some(body), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "cross-tenant record must be 403: {problem}" + ); + + // No dispute row was created for the foreign tenant. + let count = raw + .query_one(pg( + "SELECT COUNT(*) FROM bss.ledger_dispute WHERE dispute_id='DSP-FOREIGN'".to_owned(), + )) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()); + assert_eq!(count, 0, "a denied record must leave no dispute row"); +} + +/// An out-of-order `won` (no `opened` has landed for the dispute) → 202 ACCEPTED +/// with the `dispute-phase-queued` status token (§4.7): the request is durably +/// queued until its `opened` arrives, never rejected. The 202 body carries the +/// `CHARGEBACK` flow + the `dispute_id:cycle:phase` business id (mirrors the +/// allocate 202 `allocation-queued` assertion in `rest_payments.rs`). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn out_of_order_won_returns_202_queued() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // No `opened` was ever recorded for DSP-Q-REST — so a `won` queues. + let body = serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "payment_id": "PAY-Q-REST", + "phase": "WON", + "funds_at_open": "withheld", + "disputed_amount_minor": 1000, + "currency": "USD", + "scale": 2 + }); + let (status, queued) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/disputes/DSP-Q-REST/phases", + Some(body), + ) + .await; + assert_eq!( + status, + StatusCode::ACCEPTED, + "an out-of-order won is 202: {queued}" + ); + assert_eq!( + queued["status"], + serde_json::json!("dispute-phase-queued"), + "the 202 body status token: {queued}" + ); + assert_eq!( + queued["flow"], + serde_json::json!("CHARGEBACK"), + "the queued handle carries the CHARGEBACK flow" + ); + assert_eq!( + queued["business_id"], + serde_json::json!("DSP-Q-REST:1:WON"), + "the queued handle's business_id is dispute_id:cycle:phase" + ); + + // Still queued, never posted: no dispute current-state row exists yet. + let count = raw + .query_one(pg(format!( + "SELECT COUNT(*) FROM bss.ledger_dispute \ + WHERE tenant_id='{}' AND dispute_id='DSP-Q-REST'", + s.tenant + ))) + .await + .unwrap() + .map_or(0, |r| r.try_get_by_index::(0).unwrap()); + assert_eq!( + count, 0, + "a queued won posts nothing — no dispute row until its opened lands" + ); +} diff --git a/gears/bss/ledger/ledger/tests/rest_journal_entries.rs b/gears/bss/ledger/ledger/tests/rest_journal_entries.rs new file mode 100644 index 000000000..26beb76b2 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/rest_journal_entries.rs @@ -0,0 +1,1410 @@ +//! API-level (router) tests for the journal-entry / balance REST surface: +//! `POST /journal-entries` (target tenant in the body), +//! `POST /journal-entries/{entryId}/reversals` (tenant from context), +//! and the read endpoints (`GET /balances?tenant_id=…`). +//! +//! Drives the router via `tower::ServiceExt::oneshot` against a stub +//! `LedgerClientV1` + a stub `InvoicePoster` (no DB) and an in-test +//! `PolicyEnforcer` fake (no PDP). Covers the post happy path (201, allow + +//! auth), the unauthenticated path (401 problem+json), a malformed body (400 +//! problem+json), a PDP deny (403 problem+json), a reverse-of-a-reversal +//! (400 problem+json carrying `CANNOT_REVERSE_REVERSAL` — the domain rejects +//! before the post path), and a balances read (200 with the stub rows). +//! +//! It also covers the read/idempotency contracts (Task F1): an idempotent +//! replay renders `200` (not `201`) carrying the prior posting ref; +//! `GET /journal-lines` passes the cursor page through (items + `next_cursor`); +//! `GET /balances/ar-aging` returns the bucketed AR shape; and +//! `GET /journal-entries/{entryId}` carries the audit who/when/source dims +//! (`posted_by_actor_id` / `posted_at_utc` / `origin` / `source_doc_type` / +//! `source_business_id` / `correlation_id`, AC #8). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use bss_ledger::api::rest::journal_entries::{ApiState, router}; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::invoice::builder::PostedInvoice; +use bss_ledger::infra::invoice_post::InvoicePoster; +use bss_ledger_sdk::api::LedgerClientV1; +use bss_ledger_sdk::posting::{ + ArInvoiceBalanceView, BalanceView, EntryView, LineView, ODataQuery, Page, PostEntry, PostingRef, +}; +use bss_ledger_sdk::{ProvisionOutcome, ProvisionRequest, SourceDocType}; +use chrono::Utc; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit_db::secure::AccessScope; +use toolkit_odata::PageInfo; +use tower::ServiceExt; +use uuid::Uuid; + +/// The canned posted/replayed entry id the stub poster + `get_entry` return. +const STUB_ENTRY: Uuid = uuid::uuid!("dddddddd-dddd-dddd-dddd-dddddddddddd"); + +/// A terminal `PageInfo` (no further pages): the shape a single-page stub read +/// hands back. The canonical `Page` envelope always carries one. +fn complete_page_info() -> PageInfo { + PageInfo { + next_cursor: None, + prev_cursor: None, + limit: 100, + } +} + +/// Pull a `payer_tenant_id eq ` out of the parsed `$filter` AST so a stub +/// can prove the OData filter threaded through from the wire. Returns `None` +/// when the filter is absent or not that exact equality shape. +fn payer_from_filter(query: &ODataQuery) -> Option { + use toolkit_odata::ast::{CompareOperator, Expr, Value}; + match query.filter()? { + Expr::Compare(lhs, CompareOperator::Eq, rhs) => match (&**lhs, &**rhs) { + (Expr::Identifier(name), Expr::Value(Value::Uuid(u))) if name == "payer_tenant_id" => { + Some(*u) + } + _ => None, + }, + _ => None, + } +} + +// ── Stubs ──────────────────────────────────────────────────────────────────── + +/// In-test data-access stub. `get_entry` returns a `REVERSAL`-typed view (so a +/// reverse-of-it is rejected by the domain) and `list_balances` returns one +/// canned row; the other methods are not exercised by these router tests. +struct StubClient; + +#[async_trait::async_trait] +impl LedgerClientV1 for StubClient { + async fn return_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::ReturnPayment, + ) -> Result { + unimplemented!("not exercised by these router tests") + } + + async fn record_dispute_phase( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::RecordDisputePhase, + ) -> Result { + unimplemented!("not exercised by these router tests") + } + + async fn post_credit_application( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::CreditApplication, + ) -> Result { + unimplemented!("not exercised by the journal-entry router tests") + } + + async fn post_balanced_entry( + &self, + _ctx: &toolkit_security::SecurityContext, + _entry: PostEntry, + ) -> Result { + unimplemented!("posts go through the InvoicePoster, not the client") + } + + async fn read_account_balance( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _account_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the journal router tests") + } + + async fn list_accounts( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the journal router tests") + } + + async fn get_entry( + &self, + _ctx: &toolkit_security::SecurityContext, + tenant_id: Uuid, + entry_id: Uuid, + ) -> Result, CanonicalError> { + // A REVERSAL-typed view so the reverse-of-a-reversal test trips + // CannotReverseReversal before the post path. + Ok(Some(EntryView { + entry_id, + tenant_id, + period_id: "202606".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::Reversal, + source_business_id: "reverses=00000000-0000-0000-0000-000000000001".to_owned(), + reverses_entry_id: Some(uuid::uuid!("00000000-0000-0000-0000-000000000001")), + reverses_period_id: Some("202606".to_owned()), + posted_at_utc: Utc::now(), + effective_at: Utc::now().date_naive(), + posted_by_actor_id: tenant_id, + origin: "SYSTEM".to_owned(), + correlation_id: tenant_id, + created_seq: 1, + lines: Vec::new(), + })) + } + + async fn list_lines( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the journal router tests") + } + + async fn list_balances( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + Ok(Page { + items: vec![BalanceView { + account_id: uuid::uuid!("99999999-9999-9999-9999-999999999999"), + account_class: bss_ledger_sdk::AccountClass::Ar, + currency: "USD".to_owned(), + balance_minor: 1200, + functional_balance_minor: None, + functional_currency: None, + }], + page_info: complete_page_info(), + }) + } + + async fn list_ar_invoice_balances( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Option, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the journal router tests") + } + + async fn provision( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: ProvisionRequest, + ) -> Result { + unimplemented!("not exercised by the journal router tests") + } + + async fn close_period( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _period_id: String, + ) -> Result { + unimplemented!("not exercised by the journal router tests") + } + + async fn settle_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::SettlePayment, + ) -> Result { + unimplemented!("not exercised by the journal router tests") + } + + async fn allocate_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::AllocatePayment, + ) -> Result { + unimplemented!("not exercised by the journal router tests") + } + + async fn list_payment_allocations( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payment_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the journal router tests") + } + + async fn read_unallocated( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Uuid, + _currency: String, + ) -> Result { + unimplemented!("not exercised by the journal router tests") + } + + async fn trigger_recognition_run( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::TriggerRecognitionRun, + ) -> Result { + unimplemented!("not exercised by the journal router tests") + } + + async fn list_revenue_disaggregation( + &self, + _ctx: &toolkit_security::SecurityContext, + _query: bss_ledger_sdk::RevenueDisaggregationQuery, + ) -> Result { + unimplemented!("not exercised by the journal router tests") + } + + async fn change_recognition_schedule( + &self, + _ctx: &toolkit_security::SecurityContext, + _cmd: bss_ledger_sdk::ChangeRecognitionSchedule, + ) -> Result { + unimplemented!("not exercised by the journal router tests") + } + + async fn get_recognition_schedule( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _schedule_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the journal router tests") + } + + async fn list_recognition_schedules( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _invoice_id: Option, + _revenue_stream: Option, + ) -> Result { + // The post path calls this when an invoice carries recognition; the stub + // posts no schedules, so an empty list is the faithful response. + Ok(bss_ledger_sdk::RecognitionScheduleList::default()) + } +} + +/// In-test write stub: a fresh post returns a non-replayed reference (so the +/// handler renders 201); the reversal post is never reached in these tests. +struct StubPoster; + +#[async_trait] +impl InvoicePoster for StubPoster { + async fn post_invoice( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _inv: &PostedInvoice, + _payer_open: bool, + ) -> Result { + Ok(PostingRef { + entry_id: STUB_ENTRY, + created_seq: 1, + replayed: false, + }) + } + + async fn post_reversal( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _reversal: PostEntry, + _reason: Option, + ) -> Result { + unimplemented!("the reverse-of-a-reversal test rejects before the post") + } + + async fn post_correction( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _correction: PostEntry, + ) -> Result { + unimplemented!("mapping-correction is not exercised by the router tests") + } +} + +/// In-test annotation write stub: the router tests are DB-free, and no test +/// here exercises the annotation route, so this just satisfies the port. +struct StubAnnotationWriter; + +#[async_trait] +impl bss_ledger::infra::annotation::AnnotationWriter for StubAnnotationWriter { + async fn set( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _tenant: Uuid, + _target_id: Uuid, + _target_period_id: String, + _target: bss_ledger::infra::annotation::AnnotationTarget, + _description: Option, + _actor_ref: String, + _reason: Option, + _correlation_id: Option, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +/// Write stub that captures the `posted_by_actor_id` the handler lowered onto +/// the domain `PostedInvoice` — so a test can prove the actor was stamped from +/// the authenticated subject server-side (not read from the request body). +struct CapturingPoster { + seen_actor: Arc>>, +} + +#[async_trait] +impl InvoicePoster for CapturingPoster { + async fn post_invoice( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + inv: &PostedInvoice, + _payer_open: bool, + ) -> Result { + *self.seen_actor.lock().expect("lock") = Some(inv.posted_by_actor_id); + Ok(PostingRef { + entry_id: STUB_ENTRY, + created_seq: 1, + replayed: false, + }) + } + + async fn post_reversal( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _reversal: PostEntry, + _reason: Option, + ) -> Result { + unimplemented!("not exercised by the actor-stamping test") + } + + async fn post_correction( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _correction: PostEntry, + ) -> Result { + unimplemented!("not exercised by the actor-stamping test") + } +} + +/// Write stub whose `post_invoice` reports an idempotent REPLAY (`replayed: +/// true`) of a prior post — so the handler must render `200`, not `201`, with +/// the prior posting reference. The reversal / correction posts are not reached. +struct ReplayPoster; + +#[async_trait] +impl InvoicePoster for ReplayPoster { + async fn post_invoice( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _inv: &PostedInvoice, + _payer_open: bool, + ) -> Result { + Ok(PostingRef { + entry_id: STUB_ENTRY, + created_seq: 7, + replayed: true, + }) + } + + async fn post_reversal( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _reversal: PostEntry, + _reason: Option, + ) -> Result { + unimplemented!("not exercised by the replay test") + } + + async fn post_correction( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _correction: PostEntry, + ) -> Result { + unimplemented!("not exercised by the replay test") + } +} + +/// Read stub for the page / aging / audit contracts. `list_lines` returns a +/// non-terminal page (one line + a `next_cursor`); `list_ar_invoice_balances` +/// returns two past-due AR rows; `get_entry` returns an `INVOICE_POST` view +/// stamped with the audit who/when/source dims. The other reads are unused. +struct ReadStubClient; + +/// The canned audit dims `ReadStubClient::get_entry` stamps (asserted by the +/// who/when/source test). +const AUDIT_ACTOR: Uuid = uuid::uuid!("a0a0a0a0-a0a0-a0a0-a0a0-a0a0a0a0a0a0"); +const AUDIT_CORRELATION: Uuid = uuid::uuid!("c0c0c0c0-c0c0-c0c0-c0c0-c0c0c0c0c0c0"); +const AUDIT_BUSINESS_ID: &str = "INV-AUDIT-1"; +const AUDIT_ORIGIN: &str = "SYSTEM"; +/// The next-page cursor `ReadStubClient::list_lines` hands back (asserted by the +/// pagination passthrough test). +const NEXT_CURSOR: &str = "cursor-page-2"; +/// The payer the AR-aging rows belong to (asserted by the aging-shape test). +const AGING_PAYER: Uuid = uuid::uuid!("b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b0b0"); + +#[async_trait::async_trait] +impl LedgerClientV1 for ReadStubClient { + async fn return_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::ReturnPayment, + ) -> Result { + unimplemented!("not exercised by these router tests") + } + + async fn record_dispute_phase( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::RecordDisputePhase, + ) -> Result { + unimplemented!("not exercised by these router tests") + } + + async fn post_credit_application( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::CreditApplication, + ) -> Result { + unimplemented!("not exercised by the journal-entry router tests") + } + + async fn post_balanced_entry( + &self, + _ctx: &toolkit_security::SecurityContext, + _entry: PostEntry, + ) -> Result { + unimplemented!("posts go through the InvoicePoster, not the client") + } + + async fn read_account_balance( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _account_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the read-contract tests") + } + + async fn list_accounts( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the read-contract tests") + } + + async fn get_entry( + &self, + _ctx: &toolkit_security::SecurityContext, + tenant_id: Uuid, + entry_id: Uuid, + ) -> Result, CanonicalError> { + // An INVOICE_POST view carrying the audit who/when/source dims AC #8 + // requires the read DTO to expose. + Ok(Some(EntryView { + entry_id, + tenant_id, + period_id: "202606".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: AUDIT_BUSINESS_ID.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: Utc::now().date_naive(), + posted_by_actor_id: AUDIT_ACTOR, + origin: AUDIT_ORIGIN.to_owned(), + correlation_id: AUDIT_CORRELATION, + created_seq: 42, + lines: Vec::new(), + })) + } + + async fn list_lines( + &self, + _ctx: &toolkit_security::SecurityContext, + tenant_id: Uuid, + query: &ODataQuery, + ) -> Result, CanonicalError> { + // A non-terminal page: one line + a `next_cursor` in `page_info` (so the + // handler must surface BOTH the items and the continuation token). The + // line echoes the `$filter`'s `payer_tenant_id eq ` so the OData + // passthrough is observable on the wire. + let payer = payer_from_filter(query).unwrap_or(tenant_id); + Ok(Page { + items: vec![LineView { + line_id: uuid::uuid!("1e1e1e1e-1e1e-1e1e-1e1e-1e1e1e1e1e1e"), + entry_id: STUB_ENTRY, + payer_tenant_id: payer, + account_id: uuid::uuid!("acacacac-acac-acac-acac-acacacacacac"), + account_class: bss_ledger_sdk::AccountClass::Ar, + gl_code: None, + side: bss_ledger_sdk::Side::Debit, + amount_minor: 1200, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some("INV-1".to_owned()), + due_date: Some(Utc::now().date_naive()), + revenue_stream: None, + mapping_status: bss_ledger_sdk::MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + ar_status: None, + }], + page_info: PageInfo { + next_cursor: Some(NEXT_CURSOR.to_owned()), + prev_cursor: None, + limit: 1, + }, + }) + } + + async fn list_balances( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the read-contract tests") + } + + async fn list_ar_invoice_balances( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Option, + ) -> Result, CanonicalError> { + // Two open AR invoices for one payer: one ~45 days past due (1-30? no — + // 31-60) and one ~10 days past due (1-30). The handler folds these into + // the domain aging buckets; the test asserts the bucketed shape. + let today = Utc::now().date_naive(); + Ok(vec![ + ArInvoiceBalanceView { + payer_tenant_id: AGING_PAYER, + account_id: uuid::uuid!("a4a4a4a4-a4a4-a4a4-a4a4-a4a4a4a4a4a4"), + invoice_id: "INV-OLD".to_owned(), + currency: "USD".to_owned(), + balance_minor: 5000, + due_date: Some(today - chrono::Duration::days(45)), + }, + ArInvoiceBalanceView { + payer_tenant_id: AGING_PAYER, + account_id: uuid::uuid!("a4a4a4a4-a4a4-a4a4-a4a4-a4a4a4a4a4a4"), + invoice_id: "INV-NEW".to_owned(), + currency: "USD".to_owned(), + balance_minor: 3000, + due_date: Some(today - chrono::Duration::days(10)), + }, + ]) + } + + async fn provision( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: ProvisionRequest, + ) -> Result { + unimplemented!("not exercised by the read-contract tests") + } + + async fn close_period( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _period_id: String, + ) -> Result { + unimplemented!("not exercised by the read-contract tests") + } + + async fn settle_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::SettlePayment, + ) -> Result { + unimplemented!("not exercised by the read-contract tests") + } + + async fn allocate_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::AllocatePayment, + ) -> Result { + unimplemented!("not exercised by the read-contract tests") + } + + async fn list_payment_allocations( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payment_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the read-contract tests") + } + + async fn read_unallocated( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Uuid, + _currency: String, + ) -> Result { + unimplemented!("not exercised by the read-contract tests") + } + + async fn trigger_recognition_run( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::TriggerRecognitionRun, + ) -> Result { + unimplemented!("not exercised by the read-contract tests") + } + + async fn list_revenue_disaggregation( + &self, + _ctx: &toolkit_security::SecurityContext, + _query: bss_ledger_sdk::RevenueDisaggregationQuery, + ) -> Result { + unimplemented!("not exercised by the read-contract tests") + } + + async fn change_recognition_schedule( + &self, + _ctx: &toolkit_security::SecurityContext, + _cmd: bss_ledger_sdk::ChangeRecognitionSchedule, + ) -> Result { + unimplemented!("not exercised by the read-contract tests") + } + + async fn get_recognition_schedule( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _schedule_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the read-contract tests") + } + + async fn list_recognition_schedules( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _invoice_id: Option, + _revenue_stream: Option, + ) -> Result { + // The post path calls this when an invoice carries recognition; the stub + // posts no schedules, so an empty list is the faithful response. + Ok(bss_ledger_sdk::RecognitionScheduleList::default()) + } +} + +// ── Authz fakes (mirror rest_provisioning.rs) ──────────────────────────────── + +fn subject_tenant_id(request: &EvaluationRequest) -> Uuid { + request + .subject + .properties + .get("tenant_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(Uuid::nil) +} + +fn tenant_in_constraint(tenant_id: Uuid) -> Constraint { + Constraint { + predicates: vec![Predicate::In(InPredicate::new( + toolkit_security::pep_properties::OWNER_TENANT_ID, + [tenant_id], + ))], + } +} + +/// Always-allow fake that echoes the subject's tenant as an `owner_tenant_id` +/// `In` constraint. +struct AllowAuthZ; + +#[async_trait] +impl AuthZResolverClient for AllowAuthZ { + async fn evaluate( + &self, + request: EvaluationRequest, + ) -> Result { + let tenant_id = subject_tenant_id(&request); + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![tenant_in_constraint(tenant_id)], + deny_reason: None, + }, + }) + } +} + +/// Always-deny fake — models the PDP refusing the action so the gate maps it to +/// 403. +struct DenyAuthZ; + +#[async_trait] +impl AuthZResolverClient for DenyAuthZ { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext { + constraints: vec![], + deny_reason: None, + }, + }) + } +} + +fn allow_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(AllowAuthZ)) +} + +fn deny_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(DenyAuthZ)) +} + +/// Build the journal router with the stubs and the supplied enforcer (layered +/// as an `Extension`, as the production `register_rest` does). +fn router_with_enforcer(enforcer: PolicyEnforcer) -> Router { + let state = Arc::new(ApiState { + client: Arc::new(StubClient) as Arc, + posting: Arc::new(StubPoster) as Arc, + approval: None, + annotation: Arc::new(StubAnnotationWriter) + as Arc, + journal_repo: None, + posting_policy: None, + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + router(state, &openapi).layer(axum::Extension(enforcer)) +} + +fn base_router() -> Router { + router_with_enforcer(allow_enforcer()) +} + +/// Build the journal router with explicit client + poster stubs and the +/// always-allow enforcer. Lets a test swap in a `ReplayPoster` / `ReadStubClient` +/// without disturbing the default-stub router the other tests use. +fn router_with_stubs(client: Arc, posting: Arc) -> Router { + let state = Arc::new(ApiState { + client, + posting, + approval: None, + annotation: Arc::new(StubAnnotationWriter) + as Arc, + journal_repo: None, + posting_policy: None, + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + router(state, &openapi).layer(axum::Extension(allow_enforcer())) +} + +/// The authenticated caller's tenant — also the only tenant the allow fake +/// authorizes (its `In` constraint echoes it). +const SUBJECT_TENANT: Uuid = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + +/// The authenticated caller's subject id (the actor the handler must stamp onto +/// the post, regardless of any body field). +const SUBJECT_ID: Uuid = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + +fn authed_context() -> toolkit_security::SecurityContext { + toolkit_security::SecurityContext::builder() + .subject_id(SUBJECT_ID) + .subject_tenant_id(SUBJECT_TENANT) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// A valid snake_case post-invoice body (target tenant = `SUBJECT_TENANT`). +fn valid_post_body() -> serde_json::Value { + serde_json::json!({ + "tenant_id": SUBJECT_TENANT, + "invoice_id": "INV-1", + "payer_tenant_id": "22222222-2222-2222-2222-222222222222", + "effective_at": "2026-06-01", + "due_date": "2026-07-01", + "period_id": "202606", + "items": [ + { + "amount_minor_ex_tax": 1000, + "currency": "USD", + "revenue_stream": "subscription", + "catalog_class": "REVENUE", + "gl_code": "4000" + } + ], + "tax": [ + { + "amount_minor": 200, + "currency": "USD", + "tax_jurisdiction": "US-CA", + "tax_filing_period": "2026Q2" + } + ], + "correlation_id": "11111111-2222-3333-4444-555555555555" + }) +} + +fn post_uri() -> String { + "/bss-ledger/v1/journal-entries".to_owned() +} + +fn problem_content_type(response: &axum::http::Response) -> String { + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned() +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn post_invoice_happy_path_returns_201() { + let router = base_router().layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(post_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(valid_post_body().to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::CREATED); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + assert_eq!( + value["entry_id"], + serde_json::json!("dddddddd-dddd-dddd-dddd-dddddddddddd") + ); + assert_eq!(value["replayed"], serde_json::json!(false)); +} + +/// The handler stamps `posted_by_actor_id` from the AUTHENTICATED subject, never +/// from the request body (which no longer carries it): the capturing poster sees +/// the authed subject id, proving the actor is server-side, not caller-forgeable. +#[tokio::test] +async fn post_invoice_stamps_actor_from_authenticated_subject() { + let seen_actor = Arc::new(std::sync::Mutex::new(None)); + let poster = Arc::new(CapturingPoster { + seen_actor: Arc::clone(&seen_actor), + }); + let router = router_with_stubs( + Arc::new(StubClient) as Arc, + poster as Arc, + ) + .layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(post_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(valid_post_body().to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!( + *seen_actor.lock().expect("lock"), + Some(SUBJECT_ID), + "the handler must stamp the authenticated subject as the poster" + ); +} + +#[tokio::test] +async fn post_invoice_without_auth_returns_401() { + // No Extension(ctx) layer => require_authenticated fails with 401 (the + // enforcer IS layered, so it is not a missing-extension 500). + let router = base_router(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(post_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(valid_post_body().to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let ct = problem_content_type(&response); + assert!( + ct.contains("application/problem+json"), + "expected problem+json, got '{ct}'" + ); +} + +#[tokio::test] +async fn post_invoice_malformed_body_returns_400() { + let router = base_router().layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(post_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{")) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let ct = problem_content_type(&response); + assert!( + ct.contains("application/problem+json"), + "expected problem+json, got '{ct}'" + ); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + assert!( + value.to_string().contains("json_syntax_error"), + "expected the json_syntax_error reason code; got {value}" + ); +} + +#[tokio::test] +async fn post_invoice_denied_returns_403() { + let router = router_with_enforcer(deny_enforcer()).layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(post_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(valid_post_body().to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let ct = problem_content_type(&response); + assert!( + ct.contains("application/problem+json"), + "expected problem+json, got '{ct}'" + ); +} + +#[tokio::test] +async fn reverse_of_a_reversal_returns_400_cannot_reverse_reversal() { + // The stub `get_entry` returns a REVERSAL-typed view, so `build_reversal` + // rejects with CannotReverseReversal BEFORE the post path — a 400 carrying + // the CANNOT_REVERSE_REVERSAL reason. + let entry = uuid::uuid!("cccccccc-cccc-cccc-cccc-cccccccccccc"); + let router = base_router().layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/bss-ledger/v1/journal-entries/{entry}/reversals")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ "reason": "oops" }).to_string(), + )) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let ct = problem_content_type(&response); + assert!( + ct.contains("application/problem+json"), + "expected problem+json, got '{ct}'" + ); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + assert!( + value.to_string().contains("CANNOT_REVERSE_REVERSAL"), + "expected the CANNOT_REVERSE_REVERSAL reason; got {value}" + ); +} + +#[tokio::test] +async fn list_balances_returns_stub_rows() { + let router = base_router().layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/bss-ledger/v1/balances?tenant_id={SUBJECT_TENANT}" + )) + .body(Body::empty()) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + // Canonical OData `Page` envelope: rows under `items`, cursor metadata under + // `page_info` (no more bespoke `{ balances: [...] }`). + assert_eq!( + value["items"][0]["account_id"], + serde_json::json!("99999999-9999-9999-9999-999999999999") + ); + assert_eq!(value["items"][0]["balance_minor"], serde_json::json!(1200)); + assert_eq!(value["items"][0]["account_class"], serde_json::json!("AR")); + assert!( + value["page_info"].is_object(), + "the Page envelope must carry page_info, got {value}" + ); +} + +/// Idempotent replay contrast to `post_invoice_happy_path_returns_201`: when the +/// `InvoicePoster` reports `replayed: true` (a re-post of an already-posted +/// invoice), the handler renders `200 OK`, NOT `201 Created`, carrying the prior +/// posting reference — the read/idempotency contract callers rely on to tell a +/// fresh post from a replay. +#[tokio::test] +async fn post_invoice_replay_returns_200_with_prior_ref() { + let router = router_with_stubs( + Arc::new(StubClient) as Arc, + Arc::new(ReplayPoster) as Arc, + ) + .layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(post_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(valid_post_body().to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + // 200 (replay), not 201 (fresh). + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + assert_eq!( + value["entry_id"], + serde_json::json!("dddddddd-dddd-dddd-dddd-dddddddddddd"), + "the replay returns the prior posting ref" + ); + assert_eq!( + value["replayed"], + serde_json::json!(true), + "the body marks the post a replay" + ); + assert_eq!(value["created_seq"], serde_json::json!(7)); +} + +/// `GET /journal-lines` threads the OData `$filter` through and surfaces the +/// canonical `Page` envelope: the stub `list_lines` echoes the +/// `$filter=payer_tenant_id eq ` onto the returned line (proving the +/// filter reached the client) and returns a non-terminal page whose +/// `page_info.next_cursor` must reach the wire so a caller can page on. +#[tokio::test] +async fn list_lines_threads_filter_and_surfaces_page() { + let payer = uuid::uuid!("22222222-2222-2222-2222-222222222222"); + let router = router_with_stubs( + Arc::new(ReadStubClient) as Arc, + Arc::new(StubPoster) as Arc, + ) + .layer(axum::Extension(authed_context())); + // `$filter` carries the payer equality; `tenant_id` stays a plain param; + // `limit` is the plain pagination cap. `%20` encodes the spaces in the + // OData expression `payer_tenant_id eq `. + let filter = format!("payer_tenant_id%20eq%20{payer}"); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/bss-ledger/v1/journal-lines?tenant_id={SUBJECT_TENANT}&$filter={filter}&limit=1" + )) + .body(Body::empty()) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + // The items array carries the stub line, with the `$filter` payer echoed. + assert_eq!( + value["items"][0]["payer_tenant_id"], + serde_json::json!(payer.to_string()), + "the $filter payer_tenant_id threaded into the client call" + ); + assert_eq!(value["items"][0]["amount_minor"], serde_json::json!(1200)); + // The continuation token rides in `page_info.next_cursor` (canonical Page). + assert_eq!( + value["page_info"]["next_cursor"], + serde_json::json!(NEXT_CURSOR), + "the page's next_cursor must reach the wire under page_info" + ); +} + +/// `GET /balances/ar-aging` returns the bucketed AR shape: the stub +/// `list_ar_invoice_balances` returns two past-due AR rows for one payer (≈45 and +/// ≈10 days past due), and the handler folds them through +/// [`crate::domain::invoice::aging`] into `(payer, currency, bucket, amount)` +/// grains — a `31-60` and a `1-30` bucket for the payer. +#[tokio::test] +async fn ar_aging_returns_bucketed_shape() { + let router = router_with_stubs( + Arc::new(ReadStubClient) as Arc, + Arc::new(StubPoster) as Arc, + ) + .layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/bss-ledger/v1/balances/ar-aging?tenant_id={SUBJECT_TENANT}&payer={AGING_PAYER}" + )) + .body(Body::empty()) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + let buckets = value["buckets"] + .as_array() + .expect("buckets must be an array"); + // Two grains: the ≈45-day row → 31-60 (5000), the ≈10-day row → 1-30 (3000). + let find = |bucket: &str| { + buckets.iter().find(|b| { + b["bucket"] == serde_json::json!(bucket) + && b["payer_tenant_id"] == serde_json::json!(AGING_PAYER.to_string()) + }) + }; + let b_31_60 = find("31-60").expect("a 31-60 bucket for the payer"); + assert_eq!( + b_31_60["amount_minor"], + serde_json::json!(5000), + "the ≈45-day-past-due invoice ages into 31-60" + ); + let b_1_30 = find("1-30").expect("a 1-30 bucket for the payer"); + assert_eq!( + b_1_30["amount_minor"], + serde_json::json!(3000), + "the ≈10-day-past-due invoice ages into 1-30" + ); + assert_eq!(b_1_30["currency"], serde_json::json!("USD")); +} + +/// `GET /journal-entries/{entryId}` carries the audit who/when/source dims +/// (AC #8): the response DTO must surface `posted_by_actor_id`, `posted_at_utc`, +/// `origin`, `source_doc_type`, `source_business_id`, and `correlation_id` so a +/// caller can audit who posted the entry, when, and from which source document. +#[tokio::test] +async fn get_entry_carries_audit_who_when_source() { + let entry = uuid::uuid!("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + let router = router_with_stubs( + Arc::new(ReadStubClient) as Arc, + Arc::new(StubPoster) as Arc, + ) + .layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/bss-ledger/v1/journal-entries/{entry}")) + .body(Body::empty()) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + // WHO posted it. + assert_eq!( + value["posted_by_actor_id"], + serde_json::json!(AUDIT_ACTOR.to_string()), + "the actor who posted the entry must be carried" + ); + assert_eq!( + value["correlation_id"], + serde_json::json!(AUDIT_CORRELATION.to_string()) + ); + assert_eq!(value["origin"], serde_json::json!(AUDIT_ORIGIN)); + // WHEN — `posted_at_utc` must be present (a parseable RFC-3339 timestamp). + assert!( + value["posted_at_utc"].is_string(), + "posted_at_utc must be carried, got {value}" + ); + // FROM WHICH SOURCE document. + assert_eq!(value["source_doc_type"], serde_json::json!("INVOICE_POST")); + assert_eq!( + value["source_business_id"], + serde_json::json!(AUDIT_BUSINESS_ID) + ); +} + +/// Write stub that ACCEPTS reversal + correction posts (returns a fresh ref) — so +/// the reverse / mapping-correction handler happy paths reach a posting response. +/// (`ReadStubClient::get_entry` returns an INVOICE_POST entry, so `build_reversal` +/// does not trip `CANNOT_REVERSE_REVERSAL`.) +struct WorkingPoster; + +#[async_trait] +impl InvoicePoster for WorkingPoster { + async fn post_invoice( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _inv: &PostedInvoice, + _payer_open: bool, + ) -> Result { + unimplemented!("reverse / correction post via post_reversal / post_correction") + } + + async fn post_reversal( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _reversal: PostEntry, + _reason: Option, + ) -> Result { + Ok(PostingRef { + entry_id: STUB_ENTRY, + created_seq: 1, + replayed: false, + }) + } + + async fn post_correction( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &AccessScope, + _correction: PostEntry, + ) -> Result { + Ok(PostingRef { + entry_id: STUB_ENTRY, + created_seq: 2, + replayed: false, + }) + } +} + +/// The reverse handler happy path: read the original (INVOICE_POST) → gate +/// `(entry, reverse)` → build + post the reversal → a success posting response +/// (no dual-control wired ⇒ inline). +#[tokio::test] +async fn reverse_entry_happy_path_posts_reversal() { + let router = router_with_stubs(Arc::new(ReadStubClient), Arc::new(WorkingPoster)) + .layer(axum::Extension(authed_context())); + let entry = Uuid::now_v7(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/bss-ledger/v1/journal-entries/{entry}/reversals")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ "reason": "e2e reverse" }).to_string(), + )) + .expect("build req"), + ) + .await + .expect("router call"); + assert!( + response.status().is_success(), + "reverse happy path must succeed, got {}", + response.status() + ); +} + +/// The mapping-correction handler happy path: read the original → gate → reverse +/// the mis-mapped original → re-post the corrected lines → a success response. +#[tokio::test] +async fn correct_mapping_happy_path_posts_correction() { + let router = router_with_stubs(Arc::new(ReadStubClient), Arc::new(WorkingPoster)) + .layer(axum::Extension(authed_context())); + let entry = Uuid::now_v7(); + let body = serde_json::json!({ + "reason": "remap to revenue", + "corrected_items": [ + { + "amount_minor_ex_tax": 1000, + "currency": "USD", + "revenue_stream": "subscription", + "catalog_class": "REVENUE", + "gl_code": "4000" + } + ] + }); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/bss-ledger/v1/journal-entries/{entry}/mapping-corrections" + )) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("build req"), + ) + .await + .expect("router call"); + assert!( + response.status().is_success(), + "mapping-correction happy path must succeed, got {}", + response.status() + ); +} diff --git a/gears/bss/ledger/ledger/tests/rest_payments.rs b/gears/bss/ledger/ledger/tests/rest_payments.rs new file mode 100644 index 000000000..d245a9d0c --- /dev/null +++ b/gears/bss/ledger/ledger/tests/rest_payments.rs @@ -0,0 +1,1348 @@ +//! API-level (router) tests for the payment REST surface (Group E): +//! `POST /payments` (settle, tenant in body), `POST /payments/{id}/allocations` +//! (allocate), `GET /payments/{id}/allocations` (list), and +//! `GET /balances/unallocated` (read the payer's pool). +//! +//! Unlike `rest_journal_entries.rs` (pure stubs, no DB), these drive the router +//! against a REAL testcontainer Postgres so settle / allocate run end-to-end +//! through the foundation engine. `LedgerLocalClient::new` is `pub(crate)` and +//! unreachable from this out-of-crate test, so the router's `ApiState` is built +//! over a small in-test `LedgerClientV1` (`RealPaymentClient`) that delegates the +//! four payment methods to the SAME `pub` orchestrators the local client uses +//! (`SettlementService` / `AllocationService` / `PaymentRepo`) — the seam the +//! foundation already proved in `postgres_payments.rs`. The non-payment trait +//! methods are `unimplemented!()` (this router exercises only the payment +//! endpoints). An always-allow `PolicyEnforcer` fake (no PDP) is layered as an +//! `Extension`, mirroring the production `register_rest`. +//! +//! Cases: settle 1000/30 → 201, re-settle → 200 (replay); allocate after a +//! settle + an open AR → 201 with computed splits; an over-cap allocate → 400 +//! `ALLOCATION_EXCEEDS_SETTLED`; a currency-mismatched allocate → 400 +//! `ALLOCATION_CURRENCY_MISMATCH`; an over-open caller-computed split (Mode B) +//! → 400 `ALLOCATION_SPLIT_INVALID`; list allocations → the recorded shape; +//! read unallocated → the pool balance. (The >500-candidate +//! `ALLOCATION_TOO_LARGE` HTTP case is deferred — its wire code is the same 400 +//! `field_violation` shape, proven at the service layer.) + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines +)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use bss_ledger::api::rest::payments::{ApiState, router}; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::precedence::DEFAULT_PRECEDENCE_POLICY; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::NoopLedgerMetrics; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::payment::allocate::{AllocateRequest, AllocationService}; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::payment::settlement_return::SettlementReturnService; +use bss_ledger::infra::posting::service::PostingService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{PaymentRepo, ReferenceRepo}; +use bss_ledger_sdk::api::LedgerClientV1; +use bss_ledger_sdk::posting::{ + AllocateOutcome, AllocatePayment, AllocationApplied, AllocationView, ArInvoiceBalanceView, + BalanceView, EntryView, LineView, ODataQuery, Page, PostEntry, PostingRef, SettlePayment, + UnallocatedView, +}; +use bss_ledger_sdk::{ + AccountClass, MappingStatus, ProvisionOutcome, ProvisionRequest, Side, SourceDocType, +}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +// ── The in-test client: delegates the payment methods to the real orchestrators ─ + +/// A `LedgerClientV1` over a real `DBProvider` that drives the SAME `pub` +/// settle / allocate / read services the in-process `LedgerLocalClient` uses, so +/// the router runs end-to-end against the testcontainer DB. The PEP gate the +/// production client performs internally is exercised at the HANDLER layer here +/// (the allow-all enforcer extension), so the services are called with a plain +/// per-tenant `AccessScope` (the SQL-level BOLA filter), mirroring +/// `postgres_payments.rs`. Only the payment methods are implemented. +struct RealPaymentClient { + provider: DBProvider, +} + +impl RealPaymentClient { + fn settle_svc(&self) -> SettlementService { + SettlementService::new( + self.provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + } + + fn allocate_svc(&self) -> AllocationService { + AllocationService::new( + self.provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + } + + fn return_svc(&self) -> SettlementReturnService { + SettlementReturnService::new( + self.provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + } +} + +#[async_trait::async_trait] +impl LedgerClientV1 for RealPaymentClient { + async fn post_credit_application( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::CreditApplication, + ) -> Result { + unimplemented!("not exercised by the payment router tests") + } + + async fn settle_payment( + &self, + ctx: &SecurityContext, + req: SettlePayment, + ) -> Result { + let scope = AccessScope::for_tenant(req.tenant_id); + let input = SettlementInput { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id, + gross_minor: req.gross_minor, + fee_minor: req.fee_minor, + currency: req.currency, + effective_at: req.effective_at, + }; + self.settle_svc() + .settle(ctx, &scope, input) + .await + .map_err(CanonicalError::from) + } + + async fn return_payment( + &self, + ctx: &SecurityContext, + req: bss_ledger_sdk::ReturnPayment, + ) -> Result { + let scope = AccessScope::for_tenant(req.tenant_id); + let input = bss_ledger::domain::payment::settlement_return::SettlementReturnInput { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id, + psp_return_id: req.psp_return_id, + amount_minor: req.amount_minor, + currency: req.currency, + effective_at: req.effective_at, + }; + self.return_svc() + .return_settlement(ctx, &scope, input) + .await + .map_err(CanonicalError::from) + } + + async fn record_dispute_phase( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::RecordDisputePhase, + ) -> Result { + unimplemented!("not exercised by the payment router tests") + } + + async fn allocate_payment( + &self, + ctx: &SecurityContext, + req: AllocatePayment, + ) -> Result { + let scope = AccessScope::for_tenant(req.tenant_id); + let currency = req.currency.clone(); + let caller_splits = req.splits.map(|splits| { + splits + .into_iter() + .map(|s| bss_ledger::domain::payment::precedence::Allocated { + invoice_id: s.invoice_id, + amount_minor: s.amount_minor, + }) + .collect() + }); + let request = AllocateRequest { + tenant_id: req.tenant_id, + payer_tenant_id: req.payer_tenant_id, + payment_id: req.payment_id, + allocation_id: req.allocation_id, + lump_minor: req.lump_minor, + currency: req.currency, + hint_invoice_id: req.hint_invoice_id, + caller_splits, + }; + // Map the service outcome onto the SDK outcome exactly as the production + // client does: `Applied` synthesizes the per-invoice views (request + // currency + apply instant + the ref the service stamped — a precedence + // policy id, or `caller-split.v1` for a Mode B split); `Queued` carries + // the queue key + `queued_at` (the 202 surface). + match self + .allocate_svc() + .allocate(ctx, &scope, request) + .await + .map_err(CanonicalError::from)? + { + bss_ledger::infra::payment::allocate::AllocationOutcome::Applied(applied) => { + let policy_ref = applied.policy_ref; + let allocations = applied + .splits + .into_iter() + .map(|s| AllocationView { + invoice_id: s.invoice_id, + amount_minor: s.amount_minor, + currency: currency.clone(), + allocated_at_utc: Utc::now(), + precedence_policy_ref: policy_ref.clone(), + }) + .collect(); + Ok(AllocateOutcome::Applied(AllocationApplied { + posting: applied.posting, + allocations, + })) + } + bss_ledger::infra::payment::allocate::AllocationOutcome::Queued(queued) => { + Ok(AllocateOutcome::Queued(bss_ledger_sdk::AllocationQueued { + flow: queued.flow, + business_id: queued.business_id, + queued_at: queued.queued_at, + })) + } + } + } + + async fn list_payment_allocations( + &self, + _ctx: &SecurityContext, + tenant_id: Uuid, + payment_id: String, + ) -> Result, CanonicalError> { + let scope = AccessScope::for_tenant(tenant_id); + let rows = PaymentRepo::new(self.provider.clone()) + .list_payment_allocations(&scope, tenant_id, &payment_id) + .await + .map_err(|e| CanonicalError::internal(format!("list allocations: {e}")).create())?; + Ok(rows + .into_iter() + .map(|m| AllocationView { + invoice_id: m.invoice_id, + amount_minor: m.amount_minor, + currency: m.currency, + allocated_at_utc: m.allocated_at_utc, + precedence_policy_ref: m.precedence_policy_ref, + }) + .collect()) + } + + async fn read_unallocated( + &self, + _ctx: &SecurityContext, + tenant_id: Uuid, + payer_tenant_id: Uuid, + currency: String, + ) -> Result { + let scope = AccessScope::for_tenant(tenant_id); + let balance_minor = PaymentRepo::new(self.provider.clone()) + .read_unallocated(&scope, tenant_id, payer_tenant_id, ¤cy) + .await + .map_err(|e| CanonicalError::internal(format!("read unallocated: {e}")).create())?; + Ok(UnallocatedView { + payer_tenant_id, + currency, + balance_minor, + }) + } + + // ── The non-payment surface is not exercised by these router tests. ── + + async fn post_balanced_entry( + &self, + _ctx: &SecurityContext, + _entry: PostEntry, + ) -> Result { + unimplemented!("not exercised by the payment router tests") + } + + async fn read_account_balance( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _account_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the payment router tests") + } + + async fn list_accounts( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the payment router tests") + } + + async fn get_entry( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _entry_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the payment router tests") + } + + async fn list_lines( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the payment router tests") + } + + async fn list_balances( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the payment router tests") + } + + async fn list_ar_invoice_balances( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Option, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the payment router tests") + } + + async fn provision( + &self, + _ctx: &SecurityContext, + _req: ProvisionRequest, + ) -> Result { + unimplemented!("not exercised by the payment router tests") + } + + async fn close_period( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _period_id: String, + ) -> Result { + unimplemented!("not exercised by the payment router tests") + } + + async fn trigger_recognition_run( + &self, + _ctx: &SecurityContext, + _req: bss_ledger_sdk::TriggerRecognitionRun, + ) -> Result { + unimplemented!("not exercised by the payment router tests") + } + + async fn list_revenue_disaggregation( + &self, + _ctx: &SecurityContext, + _query: bss_ledger_sdk::RevenueDisaggregationQuery, + ) -> Result { + unimplemented!("not exercised by the payment router tests") + } + + async fn change_recognition_schedule( + &self, + _ctx: &SecurityContext, + _cmd: bss_ledger_sdk::ChangeRecognitionSchedule, + ) -> Result { + unimplemented!("not exercised by the payment router tests") + } + + async fn get_recognition_schedule( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _schedule_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the payment router tests") + } + + async fn list_recognition_schedules( + &self, + _ctx: &SecurityContext, + _tenant_id: Uuid, + _invoice_id: Option, + _revenue_stream: Option, + ) -> Result { + unimplemented!("not exercised by the payment router tests") + } +} + +// ── Authz fakes (mirror rest_journal_entries.rs) ───────────────────────────── + +fn subject_tenant_id(request: &EvaluationRequest) -> Uuid { + request + .subject + .properties + .get("tenant_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(Uuid::nil) +} + +fn tenant_in_constraint(tenant_id: Uuid) -> Constraint { + Constraint { + predicates: vec![Predicate::In(InPredicate::new( + toolkit_security::pep_properties::OWNER_TENANT_ID, + [tenant_id], + ))], + } +} + +/// Always-allow fake that echoes the subject's tenant as an `owner_tenant_id` +/// `In` constraint (so the handler write-gate's target-membership check passes +/// when the body `tenant_id` equals the subject tenant). +struct AllowAuthZ; + +#[async_trait] +impl AuthZResolverClient for AllowAuthZ { + async fn evaluate( + &self, + request: EvaluationRequest, + ) -> Result { + let tenant_id = subject_tenant_id(&request); + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![tenant_in_constraint(tenant_id)], + deny_reason: None, + }, + }) + } +} + +fn allow_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(AllowAuthZ)) +} + +// ── DB + seller setup (mirror postgres_payments.rs) ────────────────────────── + +/// The authenticated caller's tenant — also the provisioned seller and the only +/// tenant the allow fake authorizes. The body `tenant_id` MUST equal this so the +/// handler write-gate's `contains_uuid` membership check passes. +const SUBJECT_TENANT: Uuid = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); +const SUBJECT_ID: Uuid = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + +/// Boot a container, migrate on a raw connection, and return a `bss`-search-path +/// `DBProvider` for the services (the provisioning-test idiom). +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + (container, raw, provider) +} + +/// Provisioned seller ids. `tenant` is fixed to `SUBJECT_TENANT` so the handler +/// authz gate (body `tenant_id` ∈ the caller's scope) passes. +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + psp_fee: Uuid, + ar: Uuid, + period_id: String, +} + +fn account(tenant: Uuid, id: Uuid, class: AccountClass, normal: Side) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: None, + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +/// Provision the seller (= `SUBJECT_TENANT`): USD@2 scale, an OPEN fiscal period +/// for the current month, and the four payment-flow chart accounts. Mirrors +/// `postgres_payments.rs::setup_seller`. +async fn setup_seller(raw: &sea_orm::DatabaseConnection, provider: &DBProvider) -> Seller { + let now = Utc::now(); + let s = Seller { + tenant: SUBJECT_TENANT, + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + ar: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account(s.tenant, s.cash, AccountClass::CashClearing, Side::Debit), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + ), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit), + ] { + reference.insert_account(row).await.unwrap(); + } + s +} + +fn ar_line(s: &Seller, invoice_id: &str, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.ar, + account_class: AccountClass::Ar, + gl_code: None, + side: Side::Debit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice_id.to_owned()), + due_date: Some(NaiveDate::from_ymd_opt(2026, 12, 1).unwrap()), + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +fn psp_credit_line(s: &Seller, amount: i64) -> NewLine { + NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: s.payer, + seller_tenant_id: Some(s.tenant), + resource_tenant_id: None, + account_id: s.psp_fee, + account_class: AccountClass::PspFeeExpense, + gl_code: None, + side: Side::Credit, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: None, + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + } +} + +/// Seed an OPEN AR invoice by posting `DR AR (invoice_id) / CR PSP_FEE_EXPENSE` +/// directly through the engine (mirrors `postgres_payments.rs::seed_ar_invoice`). +async fn seed_ar_invoice( + provider: &DBProvider, + s: &Seller, + invoice_id: &str, + amount: i64, + posted_at: DateTime, +) { + let posting = PostingService::new(provider.clone(), Arc::new(LedgerEventPublisher::noop())); + let ctx = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: s.tenant, + legal_entity_id: s.tenant, + period_id: s.period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice_id.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: posted_at, + effective_at: posted_at.date_naive(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: s.tenant, + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::Value::Null, + rate_snapshot_ref: None, + }; + let lines = vec![ar_line(s, invoice_id, amount), psp_credit_line(s, amount)]; + posting + .post(&ctx, &scope, entry, lines, None) + .await + .expect("seed AR invoice post must succeed"); +} + +// ── Router / context helpers ───────────────────────────────────────────────── + +/// Build the payment router over the `RealPaymentClient` (real DB) with the +/// always-allow enforcer layered as an `Extension`, as `register_rest` does. +fn router_with_db(provider: DBProvider) -> Router { + let state = Arc::new(ApiState { + client: Arc::new(RealPaymentClient { + provider: provider.clone(), + }) as Arc, + payment_repo: PaymentRepo::new(provider), + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + router(state, &openapi).layer(axum::Extension(allow_enforcer())) +} + +fn authed_context() -> SecurityContext { + SecurityContext::builder() + .subject_id(SUBJECT_ID) + .subject_tenant_id(SUBJECT_TENANT) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +fn settle_body(s: &Seller, payment_id: &str, gross: i64, fee: i64) -> serde_json::Value { + serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "payment_id": payment_id, + "gross_minor": gross, + "fee_minor": fee, + "currency": "USD", + "scale": 2 + }) +} + +fn allocate_body(s: &Seller, lump: i64, currency: &str) -> serde_json::Value { + serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "allocation_id": Uuid::now_v7(), + "lump_minor": lump, + "currency": currency, + "scale": 2 + }) +} + +fn return_body(s: &Seller, psp_return_id: &str, amount: i64) -> serde_json::Value { + serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "psp_return_id": psp_return_id, + "amount_minor": amount, + "currency": "USD", + "scale": 2 + }) +} + +async fn send( + router: Router, + method: &str, + uri: &str, + body: Option, +) -> (StatusCode, serde_json::Value) { + let mut builder = Request::builder().method(method).uri(uri); + let req = if let Some(b) = body { + builder = builder.header(header::CONTENT_TYPE, "application/json"); + builder.body(Body::from(b.to_string())).expect("build req") + } else { + builder.body(Body::empty()).expect("build req") + }; + let response = router.oneshot(req).await.expect("send"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).expect("body must be JSON") + }; + (status, value) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// `POST /payments` settles 1000/30 → 201 fresh; re-settling the SAME +/// `payment_id` replays → 200 with the prior posting reference. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn settle_payment_returns_201_then_200_on_replay() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + let (status, fresh) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx.clone())), + "POST", + "/bss-ledger/v1/payments", + Some(settle_body(&s, "PAY-REST-1", 1000, 30)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "fresh settle is 201: {fresh}"); + assert_eq!(fresh["replayed"], serde_json::json!(false)); + let entry_id = fresh["entry_id"].clone(); + + // Re-settle the same payment ⇒ idempotent replay ⇒ 200 with the prior ref. + let (status, replay) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/payments", + Some(settle_body(&s, "PAY-REST-1", 1000, 30)), + ) + .await; + assert_eq!(status, StatusCode::OK, "replay is 200: {replay}"); + assert_eq!(replay["replayed"], serde_json::json!(true)); + assert_eq!(replay["entry_id"], entry_id, "replay returns the prior id"); + + // The pool holds the whole gross. + let repo = PaymentRepo::new(provider.clone()); + assert_eq!( + repo.read_unallocated(&AccessScope::for_tenant(s.tenant), s.tenant, s.payer, "USD") + .await + .unwrap(), + 1000 + ); +} + +/// `POST /payments/{id}/allocations` after a settle + two open AR invoices → +/// 201 with the oldest-first computed splits (INV-A fills, INV-B partial). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_payment_returns_201_with_computed_splits() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // Settle 1000 into the pool, then seed INV-A (300, earlier) + INV-B (800, later). + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-ALLOC-REST".to_owned(), + gross_minor: 1000, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle"); + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(2), + ) + .await; + seed_ar_invoice( + &provider, + &s, + "INV-B", + 800, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + // Allocate a lump of 500: INV-A fills (300), INV-B gets the remaining 200. + let (status, applied) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/payments/PAY-ALLOC-REST/allocations", + Some(allocate_body(&s, 500, "USD")), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "allocate is 201: {applied}"); + assert_eq!(applied["replayed"], serde_json::json!(false)); + let allocs = applied["allocations"] + .as_array() + .expect("allocations array"); + assert_eq!(allocs.len(), 2, "two splits"); + assert_eq!(allocs[0]["invoice_id"], serde_json::json!("INV-A")); + assert_eq!(allocs[0]["amount_minor"], serde_json::json!(300)); + assert_eq!(allocs[1]["invoice_id"], serde_json::json!("INV-B")); + assert_eq!(allocs[1]["amount_minor"], serde_json::json!(200)); + assert_eq!( + allocs[0]["precedence_policy_ref"], + serde_json::json!(DEFAULT_PRECEDENCE_POLICY) + ); +} + +/// An over-cap allocate → 409 `ALLOCATION_EXCEEDS_SETTLED`. Mirrors +/// `postgres_payments.rs::allocate_over_settled_cap_is_rejected`: settle PAY-CAP +/// at only 100, settle a second payment (500) to fund the shared pool so the +/// no-negative guard is NOT what trips, then allocate 200 against PAY-CAP — its +/// per-payment cap CHECK rejects it even though the pool is positive. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_over_cap_returns_409_exceeds_settled() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + let settle = SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ); + let sys = SecurityContext::anonymous(); + let scope = AccessScope::for_tenant(s.tenant); + let input = |pid: &str, gross: i64| SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: pid.to_owned(), + gross_minor: gross, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }; + settle + .settle(&sys, &scope, input("PAY-CAP-1", 100)) + .await + .expect("settle capped"); + settle + .settle(&sys, &scope, input("PAY-OTHER", 500)) + .await + .expect("fund the pool"); + seed_ar_invoice( + &provider, + &s, + "INV-CAP", + 200, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + let (status, problem) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/payments/PAY-CAP-1/allocations", + Some(allocate_body(&s, 200, "USD")), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "over-cap is 409: {problem}"); + assert!( + problem.to_string().contains("ALLOCATION_EXCEEDS_SETTLED"), + "expected ALLOCATION_EXCEEDS_SETTLED, got {problem}" + ); + // The rejected allocate rolled back: no allocations recorded. + let repo = PaymentRepo::new(provider.clone()); + assert!( + repo.list_payment_allocations(&scope, s.tenant, "PAY-CAP-1") + .await + .unwrap() + .is_empty() + ); +} + +/// A currency-mismatched allocate (settled USD, allocate EUR) → 400 +/// `ALLOCATION_CURRENCY_MISMATCH`. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_currency_mismatch_returns_400() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CCY-1".to_owned(), + gross_minor: 1000, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle"); + + let (status, problem) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/payments/PAY-CCY-1/allocations", + Some(allocate_body(&s, 500, "EUR")), + ) + .await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "mismatch is 400: {problem}" + ); + assert!( + problem.to_string().contains("ALLOCATION_CURRENCY_MISMATCH"), + "expected ALLOCATION_CURRENCY_MISMATCH, got {problem}" + ); +} + +/// Mode B over the wire: a caller-computed `splits` that over-allocates an +/// invoice past its open balance → 400 `ALLOCATION_SPLIT_INVALID` (the +/// `DomainError::AllocationSplitInvalid` → `field_violation` mapping, same 400 +/// family as the other allocation rejections). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn caller_split_over_open_returns_400_split_invalid() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-CS-REST".to_owned(), + gross_minor: 1000, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle"); + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + // 400 > INV-A's open 300 ⇒ rejected before any post. + let body = serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "allocation_id": Uuid::now_v7(), + "lump_minor": 1000, + "currency": "USD", + "scale": 2, + "splits": [{ "invoice_id": "INV-A", "amount_minor": 400 }] + }); + let (status, problem) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/payments/PAY-CS-REST/allocations", + Some(body), + ) + .await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "over-open caller split is 400: {problem}" + ); + assert!( + problem.to_string().contains("ALLOCATION_SPLIT_INVALID"), + "expected ALLOCATION_SPLIT_INVALID, got {problem}" + ); + // Rejected before the post: no rows recorded. + let repo = PaymentRepo::new(provider.clone()); + assert!( + repo.list_payment_allocations(&AccessScope::for_tenant(s.tenant), s.tenant, "PAY-CS-REST") + .await + .unwrap() + .is_empty() + ); +} + +/// `GET /payments/{id}/allocations` returns the recorded splits, and +/// `GET /balances/unallocated` returns the drained pool balance. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn list_allocations_and_read_unallocated() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: "PAY-LIST-1".to_owned(), + gross_minor: 1000, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle"); + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(2), + ) + .await; + seed_ar_invoice( + &provider, + &s, + "INV-B", + 800, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + + // Allocate 500 via the router (so the rows exist), then list them back. + let (status, _) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx.clone())), + "POST", + "/bss-ledger/v1/payments/PAY-LIST-1/allocations", + Some(allocate_body(&s, 500, "USD")), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + + let (status, listed) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx.clone())), + "GET", + "/bss-ledger/v1/payments/PAY-LIST-1/allocations", + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "list is 200: {listed}"); + let allocs = listed["allocations"].as_array().expect("allocations array"); + assert_eq!(allocs.len(), 2, "two recorded splits"); + // Ordered by invoice_id (repo `order_by InvoiceId Asc`). + assert_eq!(allocs[0]["invoice_id"], serde_json::json!("INV-A")); + assert_eq!(allocs[0]["amount_minor"], serde_json::json!(300)); + assert_eq!(allocs[1]["invoice_id"], serde_json::json!("INV-B")); + assert_eq!(allocs[1]["amount_minor"], serde_json::json!(200)); + + // The pool drained by exactly the allocated total (1000 - 500 = 500 left). + let (status, pool) = send( + router_with_db(provider).layer(axum::Extension(ctx)), + "GET", + &format!( + "/bss-ledger/v1/balances/unallocated?tenant_id={}&payer_tenant_id={}¤cy=USD", + s.tenant, s.payer + ), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "unallocated read is 200: {pool}"); + assert_eq!( + pool["payer_tenant_id"], + serde_json::json!(s.payer.to_string()) + ); + assert_eq!(pool["currency"], serde_json::json!("USD")); + assert_eq!(pool["balance_minor"], serde_json::json!(500)); +} + +/// A settle whose body `tenant_id` is OUTSIDE the caller's authorized scope (the +/// allow fake echoes only the subject tenant as `owner_tenant_id`) → the handler +/// `(payment, write)` gate's target-membership assertion denies it → 403, and no +/// settlement is recorded for the foreign tenant. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn settle_into_foreign_tenant_is_denied_403() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // Body targets a tenant the caller is NOT authorized for (≠ SUBJECT_TENANT). + let foreign = Uuid::now_v7(); + let body = serde_json::json!({ + "tenant_id": foreign, + "payer_tenant_id": s.payer, + "payment_id": "PAY-FOREIGN", + "gross_minor": 1000, + "fee_minor": 0, + "currency": "USD", + "scale": 2 + }); + let (status, problem) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/payments", + Some(body), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "cross-tenant settle must be 403: {problem}" + ); + + // No settlement row was created for the foreign tenant. + let repo = PaymentRepo::new(provider); + assert!( + repo.read_settlement(&AccessScope::for_tenant(foreign), foreign, "PAY-FOREIGN") + .await + .unwrap() + .is_none(), + "a denied settle must leave no settlement row" + ); +} + +/// `POST /payments/{id}/allocations` for a payment that was NEVER settled → 202 +/// ACCEPTED with the queued handle (§4.7 allocation-before-settlement): the body +/// `status` is the `allocation-queued` token and it carries the queue +/// `flow` (`PAYMENT_ALLOCATE`) + `business_id` (the request's `allocation_id`). +/// A second identical POST (SAME `allocation_id`) is still 202 — the queued +/// replay is idempotent (the dedup short-circuit returns the same handle). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn allocate_unsettled_returns_202_queued() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // An open AR exists, but PAY-Q-REST is NEVER settled — so the allocate queues + // instead of posting. A FIXED allocation_id lets the second POST replay it. + seed_ar_invoice( + &provider, + &s, + "INV-A", + 300, + Utc::now() - chrono::Duration::hours(1), + ) + .await; + let allocation_id = Uuid::now_v7(); + let body = serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "allocation_id": allocation_id, + "lump_minor": 300, + "currency": "USD", + "scale": 2 + }); + + let (status, queued) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx.clone())), + "POST", + "/bss-ledger/v1/payments/PAY-Q-REST/allocations", + Some(body.clone()), + ) + .await; + assert_eq!( + status, + StatusCode::ACCEPTED, + "an unsettled allocate is 202: {queued}" + ); + assert_eq!( + queued["status"], + serde_json::json!("allocation-queued"), + "the 202 body status token: {queued}" + ); + assert_eq!( + queued["flow"], + serde_json::json!("PAYMENT_ALLOCATE"), + "the queued handle carries the PAYMENT_ALLOCATE flow" + ); + assert_eq!( + queued["business_id"], + serde_json::json!(allocation_id.to_string()), + "the queued handle's business_id is the allocation_id" + ); + + // The same allocation_id is still queued (not yet drained) → 202 replay. + let (status, replay) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/payments/PAY-Q-REST/allocations", + Some(body), + ) + .await; + assert_eq!( + status, + StatusCode::ACCEPTED, + "a queued replay is still 202 (idempotent): {replay}" + ); + assert_eq!(replay["status"], serde_json::json!("allocation-queued")); + assert_eq!( + replay["business_id"], + serde_json::json!(allocation_id.to_string()), + "the replay returns the same handle" + ); + + // Still queued, never posted: no allocation rows recorded for the payment. + let repo = PaymentRepo::new(provider); + assert!( + repo.list_payment_allocations(&AccessScope::for_tenant(s.tenant), s.tenant, "PAY-Q-REST") + .await + .unwrap() + .is_empty(), + "a queued allocate records no allocation rows" + ); +} + +/// `POST /payments/{id}/returns` after a settle → 201 fresh; re-posting the SAME +/// `psp_return_id` replays → 200, and the pool drains by exactly the returned +/// amount (1000 settled − 400 returned = 600 left). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn return_payment_returns_201_then_200_on_replay() { + let (_c, raw, provider) = boot().await; + let s = setup_seller(&raw, &provider).await; + let ctx = authed_context(); + + // Settle 1000 into the pool. + let (status, _) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx.clone())), + "POST", + "/bss-ledger/v1/payments", + Some(settle_body(&s, "PAY-RET-REST", 1000, 0)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "settle is 201"); + + // Return 400 → 201 fresh. + let (status, fresh) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx.clone())), + "POST", + "/bss-ledger/v1/payments/PAY-RET-REST/returns", + Some(return_body(&s, "RET-REST-1", 400)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "fresh return is 201: {fresh}"); + assert_eq!(fresh["replayed"], serde_json::json!(false)); + let entry_id = fresh["entry_id"].clone(); + + // Re-post the same psp_return_id ⇒ idempotent replay ⇒ 200 with the prior ref. + let (status, replay) = send( + router_with_db(provider.clone()).layer(axum::Extension(ctx)), + "POST", + "/bss-ledger/v1/payments/PAY-RET-REST/returns", + Some(return_body(&s, "RET-REST-1", 400)), + ) + .await; + assert_eq!(status, StatusCode::OK, "replay is 200: {replay}"); + assert_eq!(replay["replayed"], serde_json::json!(true)); + assert_eq!(replay["entry_id"], entry_id, "replay returns the prior id"); + + // The pool drained by exactly the returned amount (1000 - 400 = 600). + let repo = PaymentRepo::new(provider); + assert_eq!( + repo.read_unallocated(&AccessScope::for_tenant(s.tenant), s.tenant, s.payer, "USD") + .await + .unwrap(), + 600 + ); +} diff --git a/gears/bss/ledger/ledger/tests/rest_provisioning.rs b/gears/bss/ledger/ledger/tests/rest_provisioning.rs new file mode 100644 index 000000000..bd8efb500 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/rest_provisioning.rs @@ -0,0 +1,614 @@ +//! API-level (router) tests for the provisioning endpoint +//! `POST /bss-ledger/v1/provisioning` (target tenant in the body) and +//! `GET /bss-ledger/v1/accounts?tenant_id=…`. +//! +//! Drives the router via `tower::ServiceExt::oneshot` against a stub +//! `LedgerClientV1` (no DB) and an in-test `PolicyEnforcer` fake (no +//! PDP). Covers the happy path (200, allow + auth), the unauthenticated path +//! (401 problem+json — the enforcer is still layered so the 401 comes from +//! `require_authenticated`, not a missing-extension 500), a malformed body +//! (400 problem+json carrying the `json_syntax_error` reason), an invalid +//! granularity (400 problem+json — `into_request` fails before the client is +//! reached), a PDP deny (403 problem+json), and a cross-tenant target (403 — +//! the membership guard denies a target outside the caller's subtree). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use bss_ledger::api::rest::provisioning::{ApiState, router}; +use bss_ledger_sdk::api::LedgerClientV1; +use bss_ledger_sdk::posting::{ODataQuery, Page, PostEntry, PostingRef}; +use bss_ledger_sdk::{ProvisionOutcome, ProvisionRequest}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit_odata::PageInfo; +use tower::ServiceExt; +use uuid::Uuid; + +/// In-test data-access stub: the only method exercised is +/// `provision`, which returns a canned outcome. +struct StubClient; + +#[async_trait::async_trait] +impl LedgerClientV1 for StubClient { + async fn return_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::ReturnPayment, + ) -> Result { + unimplemented!("not exercised by these router tests") + } + + async fn record_dispute_phase( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::RecordDisputePhase, + ) -> Result { + unimplemented!("not exercised by these router tests") + } + + async fn post_credit_application( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::CreditApplication, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn post_balanced_entry( + &self, + _ctx: &toolkit_security::SecurityContext, + _entry: PostEntry, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn read_account_balance( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _account_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn list_accounts( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + Ok(Page { + items: vec![bss_ledger_sdk::AccountInfo { + account_id: uuid::uuid!("99999999-9999-9999-9999-999999999999"), + account_class: bss_ledger_sdk::AccountClass::Ar, + currency: "USD".to_owned(), + revenue_stream: None, + lifecycle_state: "OPEN".to_owned(), + }], + page_info: PageInfo { + next_cursor: None, + prev_cursor: None, + limit: 200, + }, + }) + } + + async fn get_entry( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _entry_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn list_lines( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn list_balances( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn list_ar_invoice_balances( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Option, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn provision( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: ProvisionRequest, + ) -> Result { + Ok(ProvisionOutcome { + accounts: vec![bss_ledger_sdk::AccountInfo { + account_id: uuid::uuid!("99999999-9999-9999-9999-999999999999"), + account_class: bss_ledger_sdk::AccountClass::Ar, + currency: "USD".to_owned(), + revenue_stream: None, + lifecycle_state: "OPEN".to_owned(), + }], + accounts_created: 1, + accounts_existing: 0, + scales_created: 0, + scales_existing: 0, + calendar_created: true, + period_id: "202606".to_owned(), + period_created: true, + }) + } + + async fn close_period( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _period_id: String, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn settle_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::SettlePayment, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn allocate_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::AllocatePayment, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn list_payment_allocations( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payment_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn read_unallocated( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Uuid, + _currency: String, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn trigger_recognition_run( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::TriggerRecognitionRun, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn list_revenue_disaggregation( + &self, + _ctx: &toolkit_security::SecurityContext, + _query: bss_ledger_sdk::RevenueDisaggregationQuery, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn change_recognition_schedule( + &self, + _ctx: &toolkit_security::SecurityContext, + _cmd: bss_ledger_sdk::ChangeRecognitionSchedule, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn get_recognition_schedule( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _schedule_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the provisioning router tests") + } + + async fn list_recognition_schedules( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _invoice_id: Option, + _revenue_stream: Option, + ) -> Result { + unimplemented!("not exercised by the provisioning router tests") + } +} + +/// Subject tenant from the request (`subject.properties["tenant_id"]`), nil when +/// absent. Mirrors the rms fake's helper so the allow constraint echoes the +/// caller's own tenant, as a real PDP would for an own-tenant grant. +fn subject_tenant_id(request: &EvaluationRequest) -> Uuid { + request + .subject + .properties + .get("tenant_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(Uuid::nil) +} + +/// `owner_tenant_id IN [tenant_id]` — the allow fake's standard tenant-scoping +/// constraint. Non-empty so the `require_constraints = true` PEP path compiles a +/// scope rather than fail-closing. +fn tenant_in_constraint(tenant_id: Uuid) -> Constraint { + Constraint { + predicates: vec![Predicate::In(InPredicate::new( + toolkit_security::pep_properties::OWNER_TENANT_ID, + [tenant_id], + ))], + } +} + +/// Always-allow fake that echoes the subject's tenant as an `owner_tenant_id` +/// `In` constraint — the in-process router has no PDP, so this stands in for one +/// granting the caller's own tenant. +struct AllowAuthZ; + +#[async_trait] +impl AuthZResolverClient for AllowAuthZ { + async fn evaluate( + &self, + request: EvaluationRequest, + ) -> Result { + let tenant_id = subject_tenant_id(&request); + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![tenant_in_constraint(tenant_id)], + deny_reason: None, + }, + }) + } +} + +/// Always-deny fake (`decision: false`) — models the PDP refusing the action so +/// the gate maps it to 403. +struct DenyAuthZ; + +#[async_trait] +impl AuthZResolverClient for DenyAuthZ { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext { + constraints: vec![], + deny_reason: None, + }, + }) + } +} + +/// A permissive `PolicyEnforcer` for the router tests (PDP allows + echoes the +/// caller's tenant scope). +fn allow_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(AllowAuthZ)) +} + +/// A `PolicyEnforcer` whose PDP denies every request — proves the gate maps a +/// deny to 403. +fn deny_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(DenyAuthZ)) +} + +/// Build the provisioning router with the stub client and the supplied +/// enforcer. The enforcer is layered as an `Extension` (the `provision` handler +/// extracts `Extension`); without it the handler 500s on a +/// missing extension before the auth/authz checks run. +fn router_with_enforcer(enforcer: PolicyEnforcer) -> Router { + let state = Arc::new(ApiState { + client: Arc::new(StubClient) as Arc, + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + router(state, &openapi).layer(axum::Extension(enforcer)) +} + +/// Build the provisioning router with the stub client and the always-allow +/// enforcer (the default for the auth/body tests). +fn base_router() -> Router { + router_with_enforcer(allow_enforcer()) +} + +/// The authenticated caller's tenant — also the only tenant the allow fake +/// authorizes (its `In` constraint echoes it), so provisioning THIS tenant is +/// in-scope while any other target is a cross-tenant write. +const SUBJECT_TENANT: Uuid = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + +/// An authenticated `SecurityContext` (non-nil subject + tenant + a positive +/// `subject_type`) so `require_authenticated` passes. Mirrors the rbac +/// router-test fixture (`common::test_security_context`). +fn authed_context() -> toolkit_security::SecurityContext { + toolkit_security::SecurityContext::builder() + .subject_id(uuid::uuid!("11111111-2222-3333-4444-555555555555")) + .subject_tenant_id(SUBJECT_TENANT) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// A valid snake_case provisioning request body (target tenant = `SUBJECT_TENANT`). +fn valid_body() -> serde_json::Value { + serde_json::json!({ + "tenant_id": SUBJECT_TENANT, + "accounts": [ + { + "account_class": "AR", + "currency": "USD", + "normal_side": "DR" + } + ], + "currency_scales": [ + { "currency": "XBT", "minor_units": 8, "source": "TENANT" } + ], + "fiscal_calendar": { + "timezone": "UTC", + "granularity": "MONTH", + "fy_start": 1 + } + }) +} + +fn provisioning_uri() -> String { + "/bss-ledger/v1/provisioning".to_owned() +} + +#[tokio::test] +async fn provision_happy_path_returns_200() { + let router = base_router().layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(provisioning_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(valid_body().to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + assert_eq!(value["accounts_created"], serde_json::json!(1)); + assert_eq!( + value["accounts"][0]["account_id"], + serde_json::json!("99999999-9999-9999-9999-999999999999") + ); +} + +#[tokio::test] +async fn list_accounts_happy_path_returns_200() { + let router = base_router().layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/bss-ledger/v1/accounts?tenant_id={SUBJECT_TENANT}" + )) + .body(Body::empty()) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + // Canonical OData `Page` envelope: accounts under `items`, cursor metadata + // under `page_info` (no more bespoke `{ accounts: [...] }`). + assert_eq!( + value["items"][0]["account_id"], + serde_json::json!("99999999-9999-9999-9999-999999999999") + ); + assert_eq!( + value["items"][0]["lifecycle_state"], + serde_json::json!("OPEN") + ); + assert!( + value["page_info"].is_object(), + "the Page envelope must carry page_info, got {value}" + ); +} + +#[tokio::test] +async fn provision_cross_tenant_target_returns_403() { + // The allow fake authorizes only the caller's own tenant (its `In` + // constraint echoes SUBJECT_TENANT). Targeting a DIFFERENT tenant — outside + // the caller's authorized subtree — must be denied at the PEP boundary even + // though the PDP itself allows: the cross-tenant-write guard (BOLA). + let other_tenant = uuid::uuid!("dddddddd-eeee-ffff-0000-111111111111"); + // Target tenant is now the body's `tenant_id` (not the path). + let mut body = valid_body(); + body["tenant_id"] = serde_json::json!(other_tenant); + let router = base_router().layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(provisioning_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn provision_without_auth_returns_401() { + // No Extension(ctx) layer => require_authenticated fails with 401. The + // enforcer IS layered so the 401 comes from require_authenticated, not a + // missing-extension 500. + let router = base_router(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(provisioning_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(valid_body().to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let ct = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned(); + assert!( + ct.contains("application/problem+json"), + "expected problem+json, got '{ct}'" + ); +} + +#[tokio::test] +async fn provision_malformed_body_returns_400() { + let router = base_router().layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(provisioning_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{")) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let ct = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned(); + assert!( + ct.contains("application/problem+json"), + "expected problem+json, got '{ct}'" + ); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("body must be JSON"); + assert!( + value.to_string().contains("json_syntax_error"), + "expected the json_syntax_error reason code in the body; got {value}" + ); +} + +#[tokio::test] +async fn provision_invalid_granularity_returns_400() { + let mut body = valid_body(); + body["fiscal_calendar"]["granularity"] = serde_json::json!("WEEK"); + + let router = base_router().layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(provisioning_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let ct = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned(); + assert!( + ct.contains("application/problem+json"), + "expected problem+json, got '{ct}'" + ); +} + +#[tokio::test] +async fn provision_denied_returns_403() { + // Authenticated caller, valid body, but the PDP denies (admin, provision): + // the billing-setup gate maps the deny to 403 problem+json before the + // client is reached. + let router = router_with_enforcer(deny_enforcer()).layer(axum::Extension(authed_context())); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(provisioning_uri()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(valid_body().to_string())) + .expect("build req"), + ) + .await + .expect("send"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let ct = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned(); + assert!( + ct.contains("application/problem+json"), + "expected problem+json, got '{ct}'" + ); +} diff --git a/gears/bss/ledger/ledger/tests/rest_recognition.rs b/gears/bss/ledger/ledger/tests/rest_recognition.rs new file mode 100644 index 000000000..85b7408d1 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/rest_recognition.rs @@ -0,0 +1,683 @@ +//! API-level (router) tests for the ASC 606 recognition REST surface +//! (`crate::api::rest::recognition`): the run trigger (POST), the +//! revenue-disaggregation report (GET), the by-id schedule read (GET), the +//! schedule list/discovery (GET), and the schedule change/cancel (POST). +//! +//! Drives the router via `tower::ServiceExt::oneshot` against a stub +//! `LedgerClientV1` (no DB) + an in-test `PolicyEnforcer` fake (no PDP), mirroring +//! `rest_journal_entries.rs` / `rest_payments.rs`. Covers: trigger → 200 (Ran) and +//! 202 (`recognition-period-queued`); disaggregation → 200; by-id read → 200 and +//! 404 (scoped-out/absent); list → 200; change/cancel → 200; the unauthenticated +//! path (401); and a PDP deny (403). `approval` is `None` (no governance DB), so +//! the change handler's dual-control gate is skipped — the threshold routing is +//! covered by `postgres_dual_control.rs` + the e2e. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic +)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, AuthZResolverError, PolicyEnforcer}; +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use bss_ledger::api::rest::recognition::{ApiState, RecognitionApprovalGate, router}; +use bss_ledger_sdk::api::LedgerClientV1; +use bss_ledger_sdk::posting::{ + RecognitionRunOutcome, RecognitionRunQueued, RecognitionRunRef, RecognitionScheduleList, + RecognitionScheduleSegmentView, RecognitionScheduleSummaryView, RecognitionScheduleView, + RevenueDisaggregation, RevenueDisaggregationEntry, ScheduleChangeRef, +}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit_security::pep_properties; +use tower::ServiceExt; +use uuid::Uuid; + +/// The authenticated caller's tenant — also the only tenant the allow fake +/// authorizes (its `In` constraint echoes it), so every body/query `tenant_id` +/// uses it (a cross-tenant target is covered by `postgres_dual_control` + e2e). +const SUBJECT_TENANT: Uuid = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); +const SUBJECT_ID: Uuid = uuid::uuid!("11111111-2222-3333-4444-555555555555"); + +// ── Stub client ─────────────────────────────────────────────────────────────── + +/// In-test data-access stub. The recognition methods return canned values; the +/// `run_queued` / `schedule_absent` flags drive the 202 / 404 branches. The other +/// (non-recognition) methods are not reached by the recognition router. +#[derive(Default)] +struct StubClient { + run_queued: bool, + schedule_absent: bool, + /// Override the returned schedule's status (e.g. `REPLACED`); `None` ⇒ `ACTIVE` + /// (the `canned_schedule` default). Drives the gate ACTIVE-filter test. + schedule_status: Option, +} + +fn canned_schedule() -> RecognitionScheduleView { + RecognitionScheduleView { + schedule_id: "SCH-1".to_owned(), + status: "ACTIVE".to_owned(), + version: 0, + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + total_deferred_minor: 1200, + recognized_minor: 0, + source_invoice_id: "INV-1".to_owned(), + source_invoice_item_ref: "INV-1#1".to_owned(), + po_allocation_group: None, + subscription_ref: None, + policy_ref: "straight-line.v1".to_owned(), + segments: vec![RecognitionScheduleSegmentView { + segment_no: 1, + period_id: "202606".to_owned(), + amount_minor: 1200, + status: "PENDING".to_owned(), + }], + } +} + +fn canned_summary() -> RecognitionScheduleSummaryView { + RecognitionScheduleSummaryView { + schedule_id: "SCH-1".to_owned(), + status: "ACTIVE".to_owned(), + version: 0, + revenue_stream: "subscription".to_owned(), + currency: "USD".to_owned(), + total_deferred_minor: 1200, + recognized_minor: 0, + source_invoice_id: "INV-1".to_owned(), + source_invoice_item_ref: "INV-1#1".to_owned(), + po_allocation_group: None, + subscription_ref: None, + policy_ref: "straight-line.v1".to_owned(), + } +} + +#[async_trait::async_trait] +impl LedgerClientV1 for StubClient { + async fn trigger_recognition_run( + &self, + _ctx: &toolkit_security::SecurityContext, + req: bss_ledger_sdk::TriggerRecognitionRun, + ) -> Result { + if self.run_queued { + Ok(RecognitionRunOutcome::Queued(RecognitionRunQueued { + run_id: Uuid::now_v7(), + period_id: req.period_id, + released: 1, + queued: 2, + })) + } else { + Ok(RecognitionRunOutcome::Ran(RecognitionRunRef { + run_id: Uuid::now_v7(), + period_id: req.period_id, + replayed: false, + released: 3, + already_recognized: 0, + })) + } + } + + async fn list_revenue_disaggregation( + &self, + _ctx: &toolkit_security::SecurityContext, + query: bss_ledger_sdk::RevenueDisaggregationQuery, + ) -> Result { + Ok(RevenueDisaggregation { + entries: vec![RevenueDisaggregationEntry { + period_id: query.period_id.unwrap_or_else(|| "202606".to_owned()), + revenue_stream: "subscription".to_owned(), + recognized_minor: 1200, + currency: "USD".to_owned(), + }], + }) + } + + async fn get_recognition_schedule( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _schedule_id: String, + ) -> Result, CanonicalError> { + if self.schedule_absent { + return Ok(None); + } + let mut view = canned_schedule(); + if let Some(status) = &self.schedule_status { + view.status.clone_from(status); + } + Ok(Some(view)) + } + + async fn list_recognition_schedules( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _invoice_id: Option, + _revenue_stream: Option, + ) -> Result { + Ok(RecognitionScheduleList { + schedules: vec![canned_summary()], + truncated: false, + }) + } + + async fn change_recognition_schedule( + &self, + _ctx: &toolkit_security::SecurityContext, + _cmd: bss_ledger_sdk::ChangeRecognitionSchedule, + ) -> Result { + Ok(ScheduleChangeRef { + schedule_id: "SCH-1".to_owned(), + new_schedule_id: None, + status: "CANCELLED".to_owned(), + }) + } + + // ── not reached by the recognition router ── + async fn return_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::ReturnPayment, + ) -> Result { + unimplemented!("not exercised by the recognition router tests") + } + async fn record_dispute_phase( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::RecordDisputePhase, + ) -> Result { + unimplemented!("not exercised by the recognition router tests") + } + async fn post_credit_application( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::CreditApplication, + ) -> Result { + unimplemented!("not exercised by the recognition router tests") + } + async fn post_balanced_entry( + &self, + _ctx: &toolkit_security::SecurityContext, + _entry: bss_ledger_sdk::PostEntry, + ) -> Result { + unimplemented!("not exercised by the recognition router tests") + } + async fn read_account_balance( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _account_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the recognition router tests") + } + async fn list_accounts( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &bss_ledger_sdk::ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the recognition router tests") + } + async fn get_entry( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _entry_id: Uuid, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the recognition router tests") + } + async fn list_lines( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &bss_ledger_sdk::ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the recognition router tests") + } + async fn list_balances( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _query: &bss_ledger_sdk::ODataQuery, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the recognition router tests") + } + async fn list_ar_invoice_balances( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Option, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the recognition router tests") + } + async fn provision( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::ProvisionRequest, + ) -> Result { + unimplemented!("not exercised by the recognition router tests") + } + async fn close_period( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _period_id: String, + ) -> Result { + unimplemented!("not exercised by the recognition router tests") + } + async fn settle_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::SettlePayment, + ) -> Result { + unimplemented!("not exercised by the recognition router tests") + } + async fn allocate_payment( + &self, + _ctx: &toolkit_security::SecurityContext, + _req: bss_ledger_sdk::AllocatePayment, + ) -> Result { + unimplemented!("not exercised by the recognition router tests") + } + async fn list_payment_allocations( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payment_id: String, + ) -> Result, CanonicalError> { + unimplemented!("not exercised by the recognition router tests") + } + async fn read_unallocated( + &self, + _ctx: &toolkit_security::SecurityContext, + _tenant_id: Uuid, + _payer_tenant_id: Uuid, + _currency: String, + ) -> Result { + unimplemented!("not exercised by the recognition router tests") + } +} + +// ── PDP fakes + router harness (mirror rest_journal_entries.rs) ──────────────── + +/// Always-allow fake emitting a flat `In([SUBJECT_TENANT])` over `owner_tenant_id` +/// — the degraded-mode shape this gear's PEP compiles. A target/`tenant_id` equal +/// to `SUBJECT_TENANT` passes the gate's write-membership assert. +struct AllowInResolver; + +#[async_trait] +impl AuthZResolverClient for AllowInResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::In(InPredicate::new( + pep_properties::OWNER_TENANT_ID, + vec![SUBJECT_TENANT], + ))], + }], + deny_reason: None, + }, + }) + } +} + +/// Always-deny fake — the PDP refuses, so the gate maps it to 403. +struct DenyResolver; + +#[async_trait] +impl AuthZResolverClient for DenyResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext { + constraints: vec![], + deny_reason: None, + }, + }) + } +} + +fn allow_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(AllowInResolver)) +} + +fn deny_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(DenyResolver)) +} + +/// Build the recognition router over `client` + `enforcer` (`approval = None`, so +/// the change handler's dual-control gate is skipped). +fn router_with(client: StubClient, enforcer: PolicyEnforcer) -> Router { + let state = Arc::new(ApiState { + client: Arc::new(client) as Arc, + approval: None, + recognition_repo: None, + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + router(state, &openapi).layer(axum::Extension(enforcer)) +} + +fn authed_ctx() -> toolkit_security::SecurityContext { + toolkit_security::SecurityContext::builder() + .subject_id(SUBJECT_ID) + .subject_tenant_id(SUBJECT_TENANT) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// An authenticated router (allow enforcer + default stub). +fn authed_router(client: StubClient) -> Router { + router_with(client, allow_enforcer()).layer(axum::Extension(authed_ctx())) +} + +async fn get(router: Router, uri: &str) -> StatusCode { + router + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty()) + .expect("build req"), + ) + .await + .expect("router call") + .status() +} + +async fn post_json(router: Router, uri: &str, body: serde_json::Value) -> StatusCode { + router + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("build req"), + ) + .await + .expect("router call") + .status() +} + +// ── Dual-control gate stub ────────────────────────────────────────────────── + +/// A recording stub for the recognition dual-control gate. `decision` is what +/// `gate` returns — `Some(id)` (over threshold ⇒ the handler 409s) or `None` +/// (below ⇒ inline) — and `calls` counts invocations, so a test can assert the +/// gate was (or was NOT) reached. +struct StubGate { + decision: Option, + calls: Arc, +} + +#[async_trait] +impl RecognitionApprovalGate for StubGate { + async fn gate( + &self, + _ctx: &toolkit_security::SecurityContext, + _scope: &toolkit_db::secure::AccessScope, + _intent: bss_ledger::domain::approval::intent::ApprovalIntent, + _facts: bss_ledger::domain::approval::policy::OperationFacts, + _reason_code: String, + ) -> Result, bss_ledger::domain::error::DomainError> { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(self.decision) + } +} + +/// An authenticated router wired WITH a dual-control gate (vs `authed_router`'s +/// `approval = None`). Returns the router + the gate's call counter so a test can +/// assert whether the gate was reached. +fn authed_router_with_gate( + client: StubClient, + decision: Option, +) -> (Router, Arc) { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let gate: Arc = Arc::new(StubGate { + decision, + calls: Arc::clone(&calls), + }); + let state = Arc::new(ApiState { + client: Arc::new(client) as Arc, + approval: Some(gate), + recognition_repo: None, + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + let router = router(state, &openapi) + .layer(axum::Extension(allow_enforcer())) + .layer(axum::Extension(authed_ctx())); + (router, calls) +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn trigger_recognition_run_in_order_returns_200() { + let status = post_json( + authed_router(StubClient::default()), + "/bss-ledger/v1/recognition-runs", + serde_json::json!({ "tenant_id": SUBJECT_TENANT, "period_id": "202606", "run_id": null }), + ) + .await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn trigger_recognition_run_out_of_order_returns_202() { + let status = post_json( + authed_router(StubClient { + run_queued: true, + schedule_absent: false, + ..Default::default() + }), + "/bss-ledger/v1/recognition-runs", + serde_json::json!({ "tenant_id": SUBJECT_TENANT, "period_id": "202606", "run_id": null }), + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED); +} + +#[tokio::test] +async fn revenue_disaggregation_returns_200() { + let status = get( + authed_router(StubClient::default()), + &format!("/bss-ledger/v1/revenue/disaggregation?tenant_id={SUBJECT_TENANT}"), + ) + .await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn get_recognition_schedule_present_returns_200() { + let status = get( + authed_router(StubClient::default()), + &format!("/bss-ledger/v1/recognition-schedules/SCH-1?tenant_id={SUBJECT_TENANT}"), + ) + .await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn get_recognition_schedule_absent_returns_404() { + let status = get( + authed_router(StubClient { + run_queued: false, + schedule_absent: true, + ..Default::default() + }), + &format!("/bss-ledger/v1/recognition-schedules/NOPE?tenant_id={SUBJECT_TENANT}"), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn list_recognition_schedules_returns_200() { + let status = get( + authed_router(StubClient::default()), + &format!("/bss-ledger/v1/recognition-schedules?tenant_id={SUBJECT_TENANT}"), + ) + .await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn change_recognition_schedule_cancel_returns_200() { + let status = post_json( + authed_router(StubClient::default()), + "/bss-ledger/v1/recognition-schedules/SCH-1/changes", + serde_json::json!({ + "tenant_id": SUBJECT_TENANT, + "change_id": "CHG-1", + "action": "cancel", + "treatment": "prospective", + "new_segments": null + }), + ) + .await; + assert_eq!(status, StatusCode::OK); +} + +/// A `catch_up` treatment is refused with `MODIFICATION_TREATMENT_REVIEW` +/// (400) BEFORE the dual-control gate runs — so an over-threshold catch-up never +/// durably parks a PENDING approval. The gate stub is never called. +#[tokio::test] +async fn change_with_catch_up_treatment_refuses_before_the_gate() { + let (router, calls) = authed_router_with_gate(StubClient::default(), Some(Uuid::now_v7())); + let status = post_json( + router, + "/bss-ledger/v1/recognition-schedules/SCH-1/changes", + serde_json::json!({ + "tenant_id": SUBJECT_TENANT, + "change_id": "CHG-1", + "action": "cancel", + "treatment": "catch_up", + "new_segments": null + }), + ) + .await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "catch_up ⇒ treatment review (invalid_argument), not a gate 409" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the treatment gate MUST refuse before the dual-control gate (no PENDING parked)" + ); +} + +/// #3 (VHP-1855): a change against a non-ACTIVE (REPLACED) schedule — the shape of an +/// idempotent replay of an already-applied change — skips the gate and falls through +/// to the change-service (200), instead of recomputing the stale remainder off the +/// REPLACED row and raising a spurious 409. +#[tokio::test] +async fn change_against_replaced_schedule_skips_the_gate() { + let client = StubClient { + schedule_status: Some("REPLACED".to_owned()), + ..Default::default() + }; + let (router, calls) = authed_router_with_gate(client, Some(Uuid::now_v7())); + let status = post_json( + router, + "/bss-ledger/v1/recognition-schedules/SCH-1/changes", + serde_json::json!({ + "tenant_id": SUBJECT_TENANT, + "change_id": "CHG-1", + "action": "cancel", + "treatment": "prospective", + "new_segments": null + }), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "a REPLACED schedule must not gate; the change-service replays 200" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the gate must be skipped for a non-ACTIVE schedule" + ); +} + +/// The complement of the two skip paths: an ACTIVE schedule whose gate returns an +/// approval id routes to the dual-control queue — the handler 409s and the gate IS +/// reached (proves the wiring is live, not dead like `approval = None`). +#[tokio::test] +async fn change_active_schedule_gate_pending_returns_409() { + let (router, calls) = authed_router_with_gate(StubClient::default(), Some(Uuid::now_v7())); + let status = post_json( + router, + "/bss-ledger/v1/recognition-schedules/SCH-1/changes", + serde_json::json!({ + "tenant_id": SUBJECT_TENANT, + "change_id": "CHG-1", + "action": "cancel", + "treatment": "prospective", + "new_segments": null + }), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "an ACTIVE change the gate parks ⇒ 409 DUAL_CONTROL_REQUIRED" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the gate is reached on the ACTIVE path" + ); +} + +#[tokio::test] +async fn trigger_recognition_run_unauthenticated_returns_401() { + // No `Extension(SecurityContext)` layer ⇒ require_authenticated fails (401). + let router = router_with(StubClient::default(), allow_enforcer()); + let status = post_json( + router, + "/bss-ledger/v1/recognition-runs", + serde_json::json!({ "tenant_id": SUBJECT_TENANT, "period_id": "202606", "run_id": null }), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn trigger_recognition_run_pdp_deny_returns_403() { + let router = + router_with(StubClient::default(), deny_enforcer()).layer(axum::Extension(authed_ctx())); + let status = post_json( + router, + "/bss-ledger/v1/recognition-runs", + serde_json::json!({ "tenant_id": SUBJECT_TENANT, "period_id": "202606", "run_id": null }), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); +} diff --git a/gears/bss/ledger/ledger/tests/rest_refunds.rs b/gears/bss/ledger/ledger/tests/rest_refunds.rs new file mode 100644 index 000000000..883c983f2 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/rest_refunds.rs @@ -0,0 +1,726 @@ +//! API-level (router) tests for the refund REST surface (Slice 3, Phase 2, Group +//! G): `POST /bss-ledger/v1/refunds`, `POST /bss-ledger/v1/refund-with-credit-note`, +//! and `GET /bss-ledger/v1/refunds/{refund_id}` (tenant in body for the writes, in +//! the query for the read). +//! +//! Like `rest_adjustments.rs`, these drive the router against a REAL testcontainer +//! Postgres so a refund runs end-to-end through the foundation engine +//! (`PostingService` + the in-txn `RefundPostSidecar`) + the per-payment money-out +//! caps + (for the composite) the credit-note's second entry in the SAME txn. The +//! refund handler is a CONCRETE orchestrator (not behind `LedgerClientV1`), so the +//! router's `ApiState` is built directly over a real GATED `RefundHandler` (+ the +//! composite `CreditNoteHandler`) + a real `ApprovalService` (so over-D2 → 409) + +//! the `AdjustmentRepo`. An always-allow `PolicyEnforcer` fake (echoing the subject +//! tenant as an `owner_tenant_id` `In` constraint) is layered as an `Extension`, +//! mirroring `register_rest`; a deny fake covers the 403. +//! +//! Cases: +//! - a Pattern-A single-step refund of a settled payment → 201, an idempotent +//! re-post (same `psp_refund_id:phase`) → 200 (`replayed = true`); +//! - a refund whose cash crosses the default D2 threshold (≥ 100_000 minor) → 409 +//! `DUAL_CONTROL_REQUIRED`; +//! - a refund whose origin payment has no settlement (refund-before-payment) → 202 +//! `refund-quarantined` (a normal-body token, never posted); +//! - `POST /refund-with-credit-note` → 201 with BOTH entry ids (the composite +//! commits the refund + the paired credit note atomically); +//! - `GET …/refunds/{id}` → 200 with the recorded refund + its clearing state; +//! - a refund whose body `tenant_id` is outside the caller's scope → 403; a PDP +//! deny → 403. +//! +//! Ignored by default (needs Docker); run with `-- --ignored`. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::panic, + clippy::too_many_lines, + clippy::needless_pass_by_value +)] + +use std::sync::Arc; + +use async_trait::async_trait; +use authz_resolver_sdk::constraints::{Constraint, InPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode, header}; +use bss_ledger::api::rest::refunds::{ApiState, router}; +use bss_ledger::config::{FxConfig, RecognitionConfig}; +use bss_ledger::domain::approval::intent::ApprovalIntent; +use bss_ledger::domain::error::DomainError; +use bss_ledger::domain::model::{AccountRow, CurrencyScaleRow}; +use bss_ledger::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; +use bss_ledger::domain::payment::settlement::SettlementInput; +use bss_ledger::domain::ports::metrics::{LedgerMetricsPort, NoopLedgerMetrics}; +use bss_ledger::infra::adjustment::credit_note_service::CreditNoteHandler; +use bss_ledger::infra::adjustment::refund_service::RefundHandler; +use bss_ledger::infra::approval::service::{ApprovalExecutor, ApprovalService}; +use bss_ledger::infra::events::publisher::LedgerEventPublisher; +use bss_ledger::infra::invoice_post::InvoicePostService; +use bss_ledger::infra::payment::settle::SettlementService; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::AdjustmentRepo; +use bss_ledger_sdk::{AccountClass, Side}; +use chrono::{Datelike, NaiveDate, Utc}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use sea_orm_migration::MigratorTrait; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +fn pg(sql: impl Into) -> Statement { + Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.into()) +} + +fn naive(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +// ── PDP fakes (mirror rest_adjustments.rs) ─────────────────────────────────── + +const SUBJECT_TENANT: Uuid = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); +const SUBJECT_ID: Uuid = uuid::uuid!("11111111-2222-3333-4444-555555555555"); +const FOREIGN_TENANT: Uuid = uuid::uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff"); + +struct AllowInResolver; + +#[async_trait] +impl AuthZResolverClient for AllowInResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::In(InPredicate::new( + toolkit_security::pep_properties::OWNER_TENANT_ID, + vec![SUBJECT_TENANT], + ))], + }], + deny_reason: None, + }, + }) + } +} + +struct DenyResolver; + +#[async_trait] +impl AuthZResolverClient for DenyResolver { + async fn evaluate( + &self, + _req: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext { + constraints: vec![], + deny_reason: None, + }, + }) + } +} + +fn allow_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(AllowInResolver)) +} + +fn deny_enforcer() -> PolicyEnforcer { + PolicyEnforcer::new(Arc::new(DenyResolver)) +} + +/// An `ApprovalExecutor` stub: the over-D2 gate only CREATES a PENDING approval + +/// returns its id (→ 409); it never executes a held mutation in these tests. +#[derive(Clone, Default)] +struct NoopExecutor; + +#[async_trait] +impl ApprovalExecutor for NoopExecutor { + async fn execute( + &self, + _ctx: &SecurityContext, + _scope: &AccessScope, + _intent: &ApprovalIntent, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── DB + seller setup ───────────────────────────────────────────────────────── + +/// Provisioned seller: the refund chart (UNALLOCATED / AR / REFUND_CLEARING / +/// CASH_CLEARING / PSP_FEE_EXPENSE) plus the credit-note chart (REVENUE(sub) / +/// CONTRACT_LIABILITY(sub) / CONTRA_REVENUE / GOODWILL / REUSABLE_CREDIT / TAX) for +/// the composite. `tenant` is `SUBJECT_TENANT` so the handler authz gate passes; +/// `period_id` is the CURRENT month. +struct Seller { + tenant: Uuid, + payer: Uuid, + cash: Uuid, + unallocated: Uuid, + refund_clearing: Uuid, + ar: Uuid, + psp_fee: Uuid, + revenue: Uuid, + contract_liability: Uuid, + contra_revenue: Uuid, + goodwill: Uuid, + reusable_credit: Uuid, + tax: Uuid, + period_id: String, +} + +fn account( + tenant: Uuid, + id: Uuid, + class: AccountClass, + normal: Side, + stream: Option<&str>, +) -> AccountRow { + AccountRow { + account_id: id, + tenant_id: tenant, + legal_entity_id: tenant, + account_class: class.as_str().to_owned(), + currency: "USD".to_owned(), + revenue_stream: stream.map(str::to_owned), + normal_side: normal.as_str().to_owned(), + may_go_negative: false, + lifecycle_state: "OPEN".to_owned(), + } +} + +async fn boot() -> ( + testcontainers_modules::testcontainers::ContainerAsync, + sea_orm::DatabaseConnection, + DBProvider, + Seller, +) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let raw = Database::connect(&url).await.unwrap(); + Migrator::up(&raw, None).await.unwrap(); + let repo_url = format!("{url}?options=-c%20search_path%3Dbss,public"); + let tdb = connect_db(&repo_url, ConnectOpts::default()).await.unwrap(); + let provider = DBProvider::::new(tdb); + + let now = Utc::now(); + let s = Seller { + tenant: SUBJECT_TENANT, + payer: Uuid::now_v7(), + cash: Uuid::now_v7(), + unallocated: Uuid::now_v7(), + refund_clearing: Uuid::now_v7(), + ar: Uuid::now_v7(), + psp_fee: Uuid::now_v7(), + revenue: Uuid::now_v7(), + contract_liability: Uuid::now_v7(), + contra_revenue: Uuid::now_v7(), + goodwill: Uuid::now_v7(), + reusable_credit: Uuid::now_v7(), + tax: Uuid::now_v7(), + period_id: format!("{:04}{:02}", now.year(), now.month()), + }; + + let reference = bss_ledger::infra::storage::repo::ReferenceRepo::new(provider.clone()); + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: s.tenant, + currency: "USD".to_owned(), + minor_units: 2, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "iso".to_owned(), + }) + .await + .unwrap(); + raw.execute(pg(format!( + "INSERT INTO bss.ledger_fiscal_period (tenant_id, legal_entity_id, period_id, fiscal_tz, status) + VALUES ('{}','{}','{}','UTC','OPEN')", + s.tenant, s.tenant, s.period_id + ))) + .await + .unwrap(); + + for row in [ + account( + s.tenant, + s.cash, + AccountClass::CashClearing, + Side::Debit, + None, + ), + account( + s.tenant, + s.unallocated, + AccountClass::Unallocated, + Side::Credit, + None, + ), + account( + s.tenant, + s.refund_clearing, + AccountClass::RefundClearing, + Side::Credit, + None, + ), + account(s.tenant, s.ar, AccountClass::Ar, Side::Debit, None), + account( + s.tenant, + s.psp_fee, + AccountClass::PspFeeExpense, + Side::Debit, + None, + ), + account( + s.tenant, + s.revenue, + AccountClass::Revenue, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contract_liability, + AccountClass::ContractLiability, + Side::Credit, + Some("subscription"), + ), + account( + s.tenant, + s.contra_revenue, + AccountClass::ContraRevenue, + Side::Debit, + None, + ), + account( + s.tenant, + s.goodwill, + AccountClass::Goodwill, + Side::Debit, + None, + ), + account( + s.tenant, + s.reusable_credit, + AccountClass::ReusableCredit, + Side::Credit, + None, + ), + account( + s.tenant, + s.tax, + AccountClass::TaxPayable, + Side::Credit, + None, + ), + ] { + reference.insert_account(row).await.unwrap(); + } + (container, raw, provider, s) +} + +/// Settle `gross` (fee 0) for `payment_id` — seeds the `payment_settlement` row the +/// refund resolves as its origin (Pattern A draws its `UNALLOCATED` pool). +async fn settle(provider: &DBProvider, s: &Seller, payment_id: &str, gross: i64) { + SettlementService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + ) + .settle( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + SettlementInput { + tenant_id: s.tenant, + payer_tenant_id: s.payer, + payment_id: payment_id.to_owned(), + gross_minor: gross, + fee_minor: 0, + currency: "USD".to_owned(), + effective_at: None, + }, + ) + .await + .expect("settle must succeed"); +} + +/// Post an invoice (deferred over 12) so the composite's credit note has a posted +/// invoice + schedule to act against. +async fn post_invoice(provider: &DBProvider, s: &Seller, invoice_id: &str, amount: i64) { + use bss_ledger::domain::invoice::builder::{InvoiceItem, PostedInvoice, TaxBreakdown}; + use bss_ledger::domain::recognition::input::{RecognitionInput, RecognitionTiming}; + let item = InvoiceItem { + amount_minor_ex_tax: amount, + deferred_minor: 0, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: Some(AccountClass::Revenue), + contract_class: None, + gl_code: Some("4000".to_owned()), + recognition: Some(RecognitionInput { + policy_ref: "policy.sl.v1".to_owned(), + timing: RecognitionTiming::StraightLine { + periods: 12, + first_period_id: None, + }, + po_allocation_group: Some("grp-1".to_owned()), + multi_po: false, + ssp_snapshot_ref: None, + subscription_ref: Some("sub-1".to_owned()), + vc_estimate_ref: None, + vc_method_ref: None, + immaterial_one_shot_sku: false, + }), + invoice_item_ref: Some("item-1".to_owned()), + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }; + let inv = PostedInvoice { + invoice_id: invoice_id.to_owned(), + payer_tenant_id: s.payer, + resource_tenant_id: None, + seller_tenant_id: s.tenant, + effective_at: naive(2026, 6, 1), + due_date: Some(naive(2026, 7, 1)), + period_id: s.period_id.clone(), + items: vec![item], + tax: Vec::::new(), + posted_by_actor_id: s.tenant, + correlation_id: s.tenant, + }; + InvoicePostService::new( + provider.clone(), + Arc::new(LedgerEventPublisher::noop()), + Arc::new(NoopLedgerMetrics), + RecognitionConfig::default(), + FxConfig::default(), + ) + .post_invoice( + &SecurityContext::anonymous(), + &AccessScope::for_tenant(s.tenant), + &inv, + true, + ) + .await + .expect("seed invoice post must succeed"); +} + +// ── Router / context / request helpers ──────────────────────────────────────── + +/// Build the refund router over a REAL gated `RefundHandler` (with the composite +/// credit-note handler + a real `ApprovalService` so over-D2 → 409) + the +/// `AdjustmentRepo`, with the given enforcer layered as an `Extension`. +fn router_with(provider: DBProvider, enforcer: PolicyEnforcer) -> Router { + let publisher = Arc::new(LedgerEventPublisher::noop()); + let metrics: Arc = Arc::new(NoopLedgerMetrics); + let credit_note = Arc::new(CreditNoteHandler::new( + provider.clone(), + Arc::clone(&publisher), + Arc::clone(&metrics), + )); + let approval = Arc::new(ApprovalService::new( + provider.clone(), + Arc::new(NoopExecutor), + Arc::clone(&metrics), + bss_ledger::config::FxConfig::default(), + )); + let refunds = Arc::new( + RefundHandler::new(provider.clone(), Arc::clone(&publisher)) + .with_approval(approval) + .with_credit_note_handler(credit_note) + .with_metrics(metrics), + ); + let state = Arc::new(ApiState { + refunds, + refund_repo: AdjustmentRepo::new(provider), + }); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + router(state, &openapi).layer(axum::Extension(enforcer)) +} + +fn authed_context() -> SecurityContext { + SecurityContext::builder() + .subject_id(SUBJECT_ID) + .subject_tenant_id(SUBJECT_TENANT) + .subject_type("gts.cf.core.security.subject_user.v1~") + .token_scopes(vec!["*".to_owned()]) + .build() + .expect("authed SecurityContext must build") +} + +/// A Pattern-A single-step refund body (no `invoice_id`, `two_stage = false` ⇒ one +/// `initiated` entry straight to cash). +fn refund_body( + s: &Seller, + refund_id: &str, + psp_refund_id: &str, + payment_id: &str, + amount_minor: i64, +) -> serde_json::Value { + serde_json::json!({ + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "refund_id": refund_id, + "psp_refund_id": psp_refund_id, + "phase": "initiated", + "pattern": "A_UNALLOCATED", + "payment_id": payment_id, + "currency": "USD", + "amount_minor": amount_minor, + "scale": 2, + "two_stage": false + }) +} + +async fn send( + router: Router, + method: &str, + uri: &str, + body: Option, +) -> (StatusCode, serde_json::Value) { + let mut builder = Request::builder().method(method).uri(uri); + let req = if let Some(b) = body { + builder = builder.header(header::CONTENT_TYPE, "application/json"); + builder.body(Body::from(b.to_string())).expect("build req") + } else { + builder.body(Body::empty()).expect("build req") + }; + let response = router.oneshot(req).await.expect("send"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let value = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).expect("body must be JSON") + }; + (status, value) +} + +fn assert_problem_code(body: &serde_json::Value, code: &str) { + let rendered = body.to_string(); + assert!( + rendered.contains(code), + "expected {code} in problem body, got {rendered}" + ); +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +/// A Pattern-A single-step refund of a settled payment → 201; an identical re-post +/// (same `psp_refund_id:phase`) → 200 (`replayed = true`), no second entry. +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_posts_201_then_replays_200() { + let (_c, _raw, provider, s) = boot().await; + settle(&provider, &s, "PAY-1", 5_000).await; + + let app = + router_with(provider.clone(), allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, body) = send( + app, + "POST", + "/bss-ledger/v1/refunds", + Some(refund_body(&s, "RF-1", "PSP-1", "PAY-1", 300)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "fresh refund 201: {body}"); + assert_eq!(body["replayed"], serde_json::json!(false)); + + let app2 = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, body) = send( + app2, + "POST", + "/bss-ledger/v1/refunds", + Some(refund_body(&s, "RF-1", "PSP-1", "PAY-1", 300)), + ) + .await; + assert_eq!(status, StatusCode::OK, "replay 200: {body}"); + assert_eq!(body["replayed"], serde_json::json!(true)); +} + +/// A refund whose returned cash crosses the default D2 threshold (≥ 100_000 minor) +/// → 409 `DUAL_CONTROL_REQUIRED` (the gate opens a PENDING approval; nothing posts). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_over_d2_threshold_requires_dual_control_409() { + let (_c, _raw, provider, s) = boot().await; + settle(&provider, &s, "PAY-BIG", 200_000).await; + + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + // 150_000 minor (> the 100_000 default D2) ⇒ dual-control. + let (status, body) = send( + app, + "POST", + "/bss-ledger/v1/refunds", + Some(refund_body(&s, "RF-BIG", "PSP-BIG", "PAY-BIG", 150_000)), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "over-D2 409: {body}"); + assert_problem_code(&body, "DUAL_CONTROL_REQUIRED"); +} + +/// A refund whose origin payment has no settlement (refund-before-payment) → 202 +/// `refund-quarantined` (a normal-body kebab token, NOT an error; nothing posted). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_before_payment_quarantined_202() { + let (_c, _raw, provider, s) = boot().await; + // No settle for PAY-MISSING ⇒ no origin settlement ⇒ quarantine. + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, body) = send( + app, + "POST", + "/bss-ledger/v1/refunds", + Some(refund_body(&s, "RF-Q", "PSP-Q", "PAY-MISSING", 400)), + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED, "quarantine 202: {body}"); + assert_eq!( + body["status"], + serde_json::json!("refund-quarantined"), + "body: {body}" + ); + assert_eq!(body["flow"], serde_json::json!("REFUND_QUARANTINE")); +} + +/// `POST /refund-with-credit-note` → 201 with BOTH entry ids (the refund + the +/// paired credit note, committed atomically in one txn). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_with_credit_note_posts_both_201() { + let (_c, _raw, provider, s) = boot().await; + settle(&provider, &s, "PAY-CN", 5_000).await; + post_invoice(&provider, &s, "INV-CN", 1_200).await; + + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let body = serde_json::json!({ + "refund": refund_body(&s, "RF-CN", "PSP-CN", "PAY-CN", 300), + "credit_note": { + "tenant_id": s.tenant, + "payer_tenant_id": s.payer, + "credit_note_id": "CN-PAIR", + "origin_invoice_id": "INV-CN", + "origin_invoice_item_ref": "item-1", + "po_allocation_group": "grp-1", + "revenue_stream": "subscription", + "currency": "USD", + "scale": 2, + "amount_minor": 300, + "tax_minor": 0, + "tax": [], + "requested_deferred_minor": 300, + "reason_code": "CUSTOMER_GOODWILL" + } + }); + let (status, resp) = send( + app, + "POST", + "/bss-ledger/v1/refund-with-credit-note", + Some(body), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "composite 201: {resp}"); + assert!( + resp["refund_entry_id"].is_string(), + "refund entry id present: {resp}" + ); + assert!( + resp["credit_note_entry_id"].is_string(), + "credit-note entry id present: {resp}" + ); + assert_eq!(resp["replayed"], serde_json::json!(false)); +} + +/// `GET …/refunds/{id}` after a stage-1 two-stage refund → 200 with the recorded +/// refund + `clearing_state = PENDING` (the REFUND_CLEARING balance is open). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn get_refund_returns_record_and_clearing_state() { + let (_c, _raw, provider, s) = boot().await; + settle(&provider, &s, "PAY-G", 5_000).await; + + // A two-stage stage-1 ⇒ clearing_state = PENDING. + let app = + router_with(provider.clone(), allow_enforcer()).layer(axum::Extension(authed_context())); + let mut body = refund_body(&s, "RF-G", "PSP-G", "PAY-G", 250); + body["two_stage"] = serde_json::json!(true); + let (status, _) = send(app, "POST", "/bss-ledger/v1/refunds", Some(body)).await; + assert_eq!(status, StatusCode::CREATED); + + let app2 = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, resp) = send( + app2, + "GET", + &format!("/bss-ledger/v1/refunds/RF-G?tenant_id={}", s.tenant), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "get refund 200: {resp}"); + assert_eq!(resp["refund_id"], serde_json::json!("RF-G")); + assert_eq!(resp["psp_refund_id"], serde_json::json!("PSP-G")); + assert_eq!(resp["pattern"], serde_json::json!("A_UNALLOCATED")); + assert_eq!(resp["clearing_state"], serde_json::json!("PENDING")); + assert_eq!(resp["amount_minor"], serde_json::json!(250)); +} + +/// `GET …/refunds/{id}` for an unknown refund → 404 (no existence leak). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn get_refund_absent_returns_404() { + let (_c, _raw, provider, s) = boot().await; + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let (status, _) = send( + app, + "GET", + &format!("/bss-ledger/v1/refunds/RF-NONE?tenant_id={}", s.tenant), + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +/// A refund whose body `tenant_id` is outside the caller's authorized scope → 403 +/// (the write-gate's cross-tenant membership assert denies it). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_foreign_tenant_denied_403() { + let (_c, _raw, provider, s) = boot().await; + let app = router_with(provider, allow_enforcer()).layer(axum::Extension(authed_context())); + let mut body = refund_body(&s, "RF-FOREIGN", "PSP-F", "PAY-F", 100); + body["tenant_id"] = serde_json::json!(FOREIGN_TENANT); + let (status, _b) = send(app, "POST", "/bss-ledger/v1/refunds", Some(body)).await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +/// A PDP deny → 403 (the gate maps the refusal). +#[tokio::test] +#[ignore = "requires Docker (testcontainers)"] +async fn refund_pdp_deny_403() { + let (_c, _raw, provider, s) = boot().await; + let app = router_with(provider, deny_enforcer()).layer(axum::Extension(authed_context())); + let (status, _b) = send( + app, + "POST", + "/bss-ledger/v1/refunds", + Some(refund_body(&s, "RF-DENY", "PSP-D", "PAY-D", 100)), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); +} diff --git a/gears/bss/ledger/ledger/tests/sqlite_adjustment_repo.rs b/gears/bss/ledger/ledger/tests/sqlite_adjustment_repo.rs new file mode 100644 index 000000000..58d6484ac --- /dev/null +++ b/gears/bss/ledger/ledger/tests/sqlite_adjustment_repo.rs @@ -0,0 +1,494 @@ +//! Fast SQLite integration tests for the Slice-3 credit-note durable guarantees +//! (Group C), exercised at the repo layer (the load-bearing CHECK guards + the +//! first-touch upsert + the reads) — the cheap half of the Phase-1 integration +//! matrix that does not need Docker/testcontainers. The full handler end-to-end +//! (chart provisioning + projector + post engine) is a Postgres-only test +//! (`postgres_credit_note.rs`, `#[ignore]`). +//! +//! Covered: +//! - **headroom CHECK blocks over-cap** (C5 / AC #24): `add_credit_note_total` +//! within the seeded headroom succeeds; a bump past `original_total + +//! debit_note_total` trips `chk_ledger_invoice_exposure_headroom` → +//! `MoneyOutCapExceeded` (the handler refines it to `CreditNoteExceedsHeadroom`). +//! - **first-touch upsert is idempotent + concurrency-shaped** (C2 headroom seed): +//! a second `seed_exposure_first_touch` is a no-op that never resets the running +//! `credit_note_total_minor`. +//! - **schedule deferred reduction + over-reduction CHECK** (C2 schedule reduction): +//! `reduce_deferred` lowers `total_deferred_minor`; a reduction past the +//! releasable remainder (below `recognized_minor`) trips the schedule CHECK. +//! - **posted-AR / open-AR reads** (C2 caps): the headroom seed basis + the +//! AR-vs-wallet cap reads return the journal/cache totals. +//! - **credit_note row persists** (C2): `insert_credit_note` round-trips. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::similar_names, + clippy::needless_pass_by_value +)] + +use bss_ledger::domain::model::{NewEntry, NewLine, RepoError}; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::adjustment_repo::NewCreditNote; +use bss_ledger::infra::storage::repo::recognition_repo::NewSchedule; +use bss_ledger::infra::storage::repo::{AdjustmentRepo, JournalRepo, RecognitionRepo}; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use sea_orm_migration::MigratorTrait; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +/// Connect an in-memory SQLite + run the migrator (the same harness as +/// `sqlite_repo.rs`). +async fn provider() -> DBProvider { + let db = connect_db("sqlite::memory:", ConnectOpts::default()) + .await + .expect("connect in-memory sqlite"); + run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("run migrator"); + DBProvider::::new(db) +} + +/// Seed an ACTIVE recognition schedule in a fresh txn and return its id. +async fn seed_schedule( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + total_deferred: i64, + recognized: i64, +) -> String { + let schedule_id = Uuid::now_v7().to_string(); + let sched = NewSchedule { + tenant_id: tenant, + schedule_id: schedule_id.clone(), + payer_tenant_id: tenant, + source_invoice_id: "inv-1".to_owned(), + source_invoice_item_ref: "item-1".to_owned(), + po_allocation_group: Some("po-1".to_owned()), + subscription_ref: None, + revenue_stream: "SAAS".to_owned(), + currency: "USD".to_owned(), + total_deferred_minor: total_deferred, + policy_ref: "straight_line".to_owned(), + ssp_snapshot_ref: None, + vc_estimate_ref: None, + vc_method_ref: None, + }; + let scope_owned = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + RecognitionRepo::insert_schedule(tx, &scope_owned, &sched) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("seed schedule"); + // The schedule starts at recognized_minor = 0; bump it to `recognized` so the + // reduction tests have an in-flight schedule (the cap CHECK floor). + if recognized > 0 { + let sid = schedule_id.clone(); + let scope_owned = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + RecognitionRepo::add_recognized(tx, &scope_owned, tenant, &sid, recognized) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("bump recognized"); + } + schedule_id +} + +/// Read a schedule's `total_deferred_minor` back. +async fn read_total_deferred( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + schedule_id: &str, +) -> i64 { + RecognitionRepo::new(provider.clone()) + .read_schedule(scope, tenant, schedule_id) + .await + .expect("read schedule") + .expect("schedule present") + .total_deferred_minor +} + +#[tokio::test] +async fn headroom_check_blocks_over_cap_credit_note() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let invoice = "inv-cap"; + + // Seed original_total = 1000 (no debit notes ⇒ headroom = 1000). + let scope_a = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::seed_exposure_first_touch( + tx, &scope_a, tenant, invoice, "USD", 1000, + ) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("seed exposure"); + + // A 600 bump is within headroom — OK. + let scope_b = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::add_credit_note_total(tx, &scope_b, tenant, invoice, 600) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("first credit note within cap"); + + // A further 500 bump (running total 1100 > 1000) trips the headroom CHECK → + // MoneyOutCapExceeded (the handler refines this to CreditNoteExceedsHeadroom). + let scope_c = scope.clone(); + let res = provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::add_credit_note_total(tx, &scope_c, tenant, invoice, 500) + .await + .map_err(repo_to_db) + }) + }) + .await; + assert_cap_exceeded(&res); +} + +#[tokio::test] +async fn first_touch_seed_is_idempotent_and_preserves_running_total() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let invoice = "inv-seed"; + + // First touch seeds original_total = 1000. + let scope_a = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::seed_exposure_first_touch( + tx, &scope_a, tenant, invoice, "USD", 1000, + ) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("first seed"); + // Bump the running credit-note total to 400. + let scope_b = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::add_credit_note_total(tx, &scope_b, tenant, invoice, 400) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("bump 400"); + // A second seed (a later credit note on the same invoice) is a no-op: it must + // NOT reset original_total nor the running credit_note_total. A subsequent + // 600 bump fits exactly to the cap (400 + 600 == 1000); a 601 would not. + let scope_c = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::seed_exposure_first_touch( + tx, &scope_c, tenant, invoice, "USD", 9999, + ) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("second seed is a no-op"); + + let scope_d = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::add_credit_note_total(tx, &scope_d, tenant, invoice, 600) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("bump to exactly the cap (proves original_total stayed 1000)"); + + // One more unit over the cap is rejected — confirms the second seed did not + // bump original_total to 9999. + let scope_e = scope.clone(); + let res = provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::add_credit_note_total(tx, &scope_e, tenant, invoice, 1) + .await + .map_err(repo_to_db) + }) + }) + .await; + assert_cap_exceeded(&res); +} + +#[tokio::test] +async fn reduce_deferred_lowers_total_and_blocks_over_reduction() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // Schedule: total_deferred = 1000, recognized = 300 ⇒ releasable remainder 700. + let schedule_id = seed_schedule(&provider, &scope, tenant, 1000, 300).await; + + // Reduce 400 (within the 700 remainder) ⇒ total_deferred = 600. + let sid = schedule_id.clone(); + let scope_a = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + RecognitionRepo::reduce_deferred(tx, &scope_a, tenant, &sid, 400) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("reduce within remainder"); + assert_eq!( + read_total_deferred(&provider, &scope, tenant, &schedule_id).await, + 600, + "total_deferred reduced over the unreleased remainder" + ); + + // A further reduction of 400 would drop total_deferred to 200 < recognized 300 + // — over-reducing an in-flight schedule. The recognized_minor <= + // total_deferred_minor CHECK rejects it → MoneyOutCapExceeded. + let sid = schedule_id.clone(); + let scope_b = scope.clone(); + let res = provider + .transaction(move |tx| { + Box::pin(async move { + RecognitionRepo::reduce_deferred(tx, &scope_b, tenant, &sid, 400) + .await + .map_err(repo_to_db) + }) + }) + .await; + assert_cap_exceeded(&res); + // The rejected reduction rolled back — total_deferred is still 600. + assert_eq!( + read_total_deferred(&provider, &scope, tenant, &schedule_id).await, + 600 + ); +} + +#[tokio::test] +async fn posted_ar_read_nets_invoice_post_ar_lines() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let invoice = "inv-ar"; + + // Post an INVOICE_POST entry: DR AR 1100 (incl 100 tax) / CR REVENUE 1000 / + // CR TAX_PAYABLE 100. The posted AR incl. tax is 1100. + insert_invoice_post(&provider, tenant, invoice, 1100).await; + + let posted = AdjustmentRepo::new(provider.clone()) + .read_posted_ar_incl_tax_out_of_txn(&scope, tenant, invoice) + .await + .expect("read posted AR"); + assert_eq!(posted, 1100, "posted AR incl. tax = the AR debit"); + + // An invoice with no posted AR line reads 0 (the headroom then floors on debit + // notes only). + let absent = AdjustmentRepo::new(provider.clone()) + .read_posted_ar_incl_tax_out_of_txn(&scope, tenant, "inv-none") + .await + .expect("read absent posted AR"); + assert_eq!(absent, 0); +} + +#[tokio::test] +async fn credit_note_row_round_trips() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + let note = NewCreditNote { + tenant_id: tenant, + credit_note_id: "cn-rt".to_owned(), + origin_invoice_id: "inv-1".to_owned(), + origin_invoice_item_ref: Some("item-1".to_owned()), + revenue_stream: "SAAS".to_owned(), + currency: "USD".to_owned(), + amount_minor: 1000, + recognized_part_minor: 400, + deferred_part_minor: 500, + split_basis_ref: Some("item=item-1;po=po-1".to_owned()), + reason_code: "CUSTOMER_GOODWILL".to_owned(), + created_at_utc: Utc::now(), + }; + let scope_a = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::insert_credit_note(tx, &scope_a, ¬e) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("insert credit_note"); + // A duplicate PK insert collides (the engine's idempotency claim normally + // short-circuits this before the sidecar; here we assert the PK guard holds). + let note2 = NewCreditNote { + tenant_id: tenant, + credit_note_id: "cn-rt".to_owned(), + origin_invoice_id: "inv-1".to_owned(), + origin_invoice_item_ref: None, + revenue_stream: "SAAS".to_owned(), + currency: "USD".to_owned(), + amount_minor: 1, + recognized_part_minor: 1, + deferred_part_minor: 0, + split_basis_ref: None, + reason_code: "X".to_owned(), + created_at_utc: Utc::now(), + }; + let scope_b = scope.clone(); + let dup = provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::insert_credit_note(tx, &scope_b, ¬e2) + .await + .map_err(repo_to_db) + }) + }) + .await; + assert!( + dup.is_err(), + "duplicate credit_note_id must collide on the PK" + ); +} + +// --- helpers --- + +/// Map an in-txn `RepoError` into the `DbError` the `provider.transaction` closure +/// must return (it fixes the closure error to `DbError` and rolls back on `Err`). +/// The `RepoError` `Debug` is stamped into `DbError::Other` so a caller can assert +/// the variant (`RepoError::MoneyOutCapExceeded(_)`'s `Debug` contains the variant +/// name) — the cap tests only confirm the rejection IS the cap guard, so a +/// contains-check on the surfaced string is sufficient + robust. +fn repo_to_db(e: RepoError) -> DbError { + DbError::Other(anyhow::Error::msg(format!("{e:?}"))) +} + +/// Assert a rolled-back `provider.transaction` result IS the per-row cap CHECK +/// (`RepoError::MoneyOutCapExceeded`) — the headroom / schedule guards. +fn assert_cap_exceeded(res: &Result<(), DbError>) { + let err = res + .as_ref() + .expect_err("expected a cap-CHECK rejection") + .to_string(); + assert!( + err.contains("MoneyOutCapExceeded"), + "expected MoneyOutCapExceeded, got: {err}" + ); +} + +/// Insert a minimal INVOICE_POST journal entry (DR AR `ar_incl_tax` / CR +/// CASH_CLEARING) for `invoice` so `read_posted_ar_incl_tax` can net it. (We use +/// CASH_CLEARING as the credit side to avoid the per-stream revenue_stream CHECK; +/// the read only sums AR-class lines, so the credit side's class is irrelevant.) +async fn insert_invoice_post( + provider: &DBProvider, + tenant: Uuid, + invoice: &str, + ar_incl_tax: i64, +) { + let repo = JournalRepo::new(provider.clone()); + let account_id = Uuid::now_v7(); + let entry = NewEntry { + entry_id: Uuid::now_v7(), + tenant_id: tenant, + legal_entity_id: tenant, + period_id: "202606".to_owned(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: invoice.to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 18).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: Uuid::now_v7(), + correlation_id: Uuid::now_v7(), + rounding_evidence: serde_json::json!({}), + rate_snapshot_ref: None, + }; + let mk = |class: AccountClass, side: Side, amount: i64| NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: tenant, + seller_tenant_id: Some(tenant), + resource_tenant_id: None, + account_id, + account_class: class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some(invoice.to_owned()), + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }; + let lines = vec![ + mk(AccountClass::Ar, Side::Debit, ar_incl_tax), + mk(AccountClass::CashClearing, Side::Credit, ar_incl_tax), + ]; + provider + .transaction(move |tx| { + Box::pin(async move { + repo.insert_entry_with_lines(tx, entry, lines) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("insert invoice-post entry"); +} diff --git a/gears/bss/ledger/ledger/tests/sqlite_chain_state.rs b/gears/bss/ledger/ledger/tests/sqlite_chain_state.rs new file mode 100644 index 000000000..d099c766f --- /dev/null +++ b/gears/bss/ledger/ledger/tests/sqlite_chain_state.rs @@ -0,0 +1,92 @@ +//! Fast SQLite round-trip for `ChainStateRepo`. Opens an in-memory database, +//! runs the migrator, then inside a transaction: reads the tip of a fresh +//! tenant (genesis → `None`), `advance`s it (insert), reads it back, `advance`s +//! again with new values (update), and reads the updated tip. SQLite has no +//! triggers, so the upsert exercises the `ON CONFLICT (tenant_id) DO UPDATE` +//! path directly. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::similar_names +)] + +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::{ChainStateRepo, TipRow}; +use sea_orm_migration::MigratorTrait; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +#[tokio::test] +async fn chain_state_tip_round_trips_on_sqlite() { + let db = connect_db("sqlite::memory:", ConnectOpts::default()) + .await + .expect("connect in-memory sqlite"); + run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("run migrator"); + let provider = DBProvider::::new(db); + let repo = ChainStateRepo::new(); + + let tenant_id = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant_id); + + let first = TipRow { + last_row_hash: vec![0xAA, 0xBB, 0xCC], + last_entry_id: Uuid::now_v7(), + last_period_id: "202606".to_owned(), + last_seq: 1, + }; + let second = TipRow { + last_row_hash: vec![0x11, 0x22, 0x33, 0x44], + last_entry_id: Uuid::now_v7(), + last_period_id: "202607".to_owned(), + last_seq: 2, + }; + + provider + .transaction(move |tx| { + Box::pin(async move { + // Genesis: no tip row yet. + let absent = repo + .read_tip(tx, &scope, tenant_id) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string())))?; + assert!(absent.is_none(), "fresh tenant must have no chain tip"); + + // Advance with an absent tip → INSERT. + repo.advance(tx, &scope, tenant_id, &first) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string())))?; + + let after_insert = repo + .read_tip(tx, &scope, tenant_id) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string())))? + .expect("tip must be present after first advance"); + assert_eq!(after_insert, first, "inserted tip must round-trip"); + + // Advance again → ON CONFLICT (tenant_id) DO UPDATE. + repo.advance(tx, &scope, tenant_id, &second) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string())))?; + + let after_update = repo + .read_tip(tx, &scope, tenant_id) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string())))? + .expect("tip must be present after second advance"); + assert_eq!(after_update, second, "updated tip must round-trip"); + + Ok(()) + }) + }) + .await + .expect("chain_state round-trip inside transaction"); +} diff --git a/gears/bss/ledger/ledger/tests/sqlite_debit_note_repo.rs b/gears/bss/ledger/ledger/tests/sqlite_debit_note_repo.rs new file mode 100644 index 000000000..9a39dbfa2 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/sqlite_debit_note_repo.rs @@ -0,0 +1,225 @@ +//! Fast SQLite integration tests for the Slice-3 debit-note repo guarantees +//! (Group D3), exercised at the repo layer — the cheap half of the Phase-1 +//! integration matrix that does not need Docker/testcontainers. The full handler +//! end-to-end (chart provisioning + projector + post engine + schedule build) is a +//! Postgres-only test (`postgres_debit_note.rs`, `#[ignore]`). +//! +//! Covered: +//! - **`add_debit_note_total` raises the headroom** (D3 / AC #24): a debit note +//! bumps `debit_note_total_minor`, which is the RHS of the headroom CHECK +//! (`credit_note_total_minor <= original_total_minor + debit_note_total_minor`), +//! so the cap for *later credit notes* grows — a credit note that would have been +//! over-cap before the debit note now fits, and the one-unit-over is still +//! rejected (proving the raise is exactly the debit-note amount). +//! - **`add_debit_note_total` requires a seeded row** (invariant): a bump before +//! the first-touch seed is a `Db` error. +//! - **`insert_debit_note` round-trips** (D3): a `debit_note` row persists and a +//! duplicate `(tenant, debit_note_id)` collides on the PK. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::similar_names, + clippy::needless_pass_by_value +)] + +use bss_ledger::domain::model::RepoError; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::AdjustmentRepo; +use bss_ledger::infra::storage::repo::adjustment_repo::NewDebitNote; +use chrono::Utc; +use sea_orm_migration::MigratorTrait; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +/// Connect an in-memory SQLite + run the migrator (the same harness as the +/// credit-note repo test). +async fn provider() -> DBProvider { + let db = connect_db("sqlite::memory:", ConnectOpts::default()) + .await + .expect("connect in-memory sqlite"); + run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("run migrator"); + DBProvider::::new(db) +} + +/// Map an in-txn `RepoError` into the `DbError` the `provider.transaction` closure +/// must return (it fixes the closure error to `DbError` and rolls back on `Err`); +/// the `RepoError` `Debug` is stamped into `DbError::Other` so a caller can assert +/// the variant by a contains-check. +fn repo_to_db(e: RepoError) -> DbError { + DbError::Other(anyhow::Error::msg(format!("{e:?}"))) +} + +/// Assert a rolled-back `provider.transaction` result IS the headroom cap CHECK +/// (`RepoError::MoneyOutCapExceeded`). +fn assert_cap_exceeded(res: &Result<(), DbError>) { + let err = res + .as_ref() + .expect_err("expected a cap-CHECK rejection") + .to_string(); + assert!( + err.contains("MoneyOutCapExceeded"), + "expected MoneyOutCapExceeded, got: {err}" + ); +} + +#[tokio::test] +async fn debit_note_total_raises_headroom_for_credit_notes() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + let invoice = "inv-dn-headroom"; + + // Seed original_total = 1000 (no debit notes ⇒ headroom = 1000). + let scope_a = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::seed_exposure_first_touch( + tx, &scope_a, tenant, invoice, "USD", 1000, + ) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("seed exposure"); + + // Raise the headroom by a 500 debit note ⇒ headroom = 1000 + 500 = 1500. + let scope_b = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::add_debit_note_total(tx, &scope_b, tenant, invoice, 500) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("debit note raises headroom"); + + // A 1500 credit note now fits exactly to the raised cap (1000 original + 500 + // debit) — it WOULD have been over-cap (1500 > 1000) before the debit note. + let scope_c = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::add_credit_note_total(tx, &scope_c, tenant, invoice, 1500) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("credit note fits the debit-note-raised headroom"); + + // One unit over the raised cap (running credit 1501 > 1500) is rejected — + // confirms the headroom was raised by exactly the debit-note amount. + let scope_d = scope.clone(); + let res = provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::add_credit_note_total(tx, &scope_d, tenant, invoice, 1) + .await + .map_err(repo_to_db) + }) + }) + .await; + assert_cap_exceeded(&res); +} + +#[tokio::test] +async fn add_debit_note_total_requires_a_seeded_row() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // No seed first ⇒ the bump matches no row and is a Db invariant error. + let scope_a = scope.clone(); + let res = provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::add_debit_note_total(tx, &scope_a, tenant, "inv-unseeded", 100) + .await + .map_err(repo_to_db) + }) + }) + .await; + let err = res + .as_ref() + .expect_err("expected a not-seeded error") + .to_string(); + assert!( + err.contains("not seeded"), + "expected not-seeded Db error, got: {err}" + ); +} + +#[tokio::test] +async fn debit_note_row_round_trips() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + let note = NewDebitNote { + tenant_id: tenant, + debit_note_id: "dn-rt".to_owned(), + origin_invoice_id: "inv-1".to_owned(), + currency: "USD".to_owned(), + amount_minor: 1100, + recognized_part_minor: 600, + deferred_part_minor: 400, + created_at_utc: Utc::now(), + }; + let scope_a = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::insert_debit_note(tx, &scope_a, ¬e) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("insert debit_note"); + + // The insert committed without error — the round-trip success. Field-level + // persistence (incl-tax amount + ex-tax split parts) is asserted by the engine + // end-to-end PG test (`postgres_debit_note`); here the composite-PK guard is + // asserted by the duplicate-insert collision below (mirrors the credit_note + // round-trip in `sqlite_adjustment_repo`). + + // A duplicate PK insert collides (the engine's idempotency claim normally + // short-circuits this before the sidecar; here we assert the PK guard holds). + let note2 = NewDebitNote { + tenant_id: tenant, + debit_note_id: "dn-rt".to_owned(), + origin_invoice_id: "inv-1".to_owned(), + currency: "USD".to_owned(), + amount_minor: 1, + recognized_part_minor: 1, + deferred_part_minor: 0, + created_at_utc: Utc::now(), + }; + let scope_b = scope.clone(); + let dup = provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::insert_debit_note(tx, &scope_b, ¬e2) + .await + .map_err(repo_to_db) + }) + }) + .await; + assert!( + dup.is_err(), + "duplicate debit_note_id must collide on the PK" + ); +} diff --git a/gears/bss/ledger/ledger/tests/sqlite_payment_refund_cap.rs b/gears/bss/ledger/ledger/tests/sqlite_payment_refund_cap.rs new file mode 100644 index 000000000..ad5faf99f --- /dev/null +++ b/gears/bss/ledger/ledger/tests/sqlite_payment_refund_cap.rs @@ -0,0 +1,346 @@ +//! Fast SQLite integration tests for the Slice-3 Phase-2 refund CAP repo +//! guarantees (Group C1), exercised at the repo layer — the load-bearing +//! `payment_settlement` / `payment_allocation_refund` cap CHECKs the refund +//! stage-1 reservation rides. The cheap half of the Phase-2 cap matrix that does +//! not need Docker/testcontainers; the full handler end-to-end (cap reservation + +//! the stage-1 reversal that releases it) is a Postgres-only test +//! (`postgres_refund_cap.rs`, `#[ignore]`). +//! +//! Covered: +//! - **`add_refunded` total money-out cap** (C1): a bump within `settled` succeeds; +//! a bump past `refunded + clawed_back <= settled` trips +//! `chk_payment_settlement_moneyout_le_settled` → `MoneyOutCapExceeded`. +//! - **`add_refunded_unallocated` spendable-headroom cap** (C1 / Pattern A): a bump +//! within `settled − allocated` succeeds; one past `allocated + +//! refunded_unallocated <= settled` trips +//! `chk_payment_settlement_alloc_refu_le_settled` → `MoneyOutCapExceeded`. +//! - **`add_allocation_refund_refunded` per-`(payment, invoice)` cap** (C1 / Pattern +//! B): a bump within the allocated amount succeeds; one past `refunded <= +//! allocated` trips `chk_par_refunded_le_allocated` → `MoneyOutCapExceeded`. +//! - **decrement returns to norm** (C1 reversal): a negative Δ (the stage-1 +//! reversal release) backs the counter out and re-opens the cap for a later +//! refund — proving the Group-C reversal frees what initiation reserved. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::similar_names, + clippy::needless_pass_by_value +)] + +use bss_ledger::domain::model::RepoError; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::PaymentRepo; +use sea_orm_migration::MigratorTrait; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +/// Connect an in-memory SQLite + run the migrator (the same harness as the other +/// repo tests). +async fn provider() -> DBProvider { + let db = connect_db("sqlite::memory:", ConnectOpts::default()) + .await + .expect("connect in-memory sqlite"); + run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("run migrator"); + DBProvider::::new(db) +} + +/// Map an in-txn `RepoError` into the `DbError` the `provider.transaction` closure +/// must return; the `RepoError` is preserved in `DbError::Other` so a test can +/// re-match it after the txn (`Debug`-stamped — matched by substring below). +fn repo_to_db(e: RepoError) -> DbError { + // Stamp the variant name so the post-txn match can distinguish + // `MoneyOutCapExceeded` from an infra `Db` error. + DbError::Other(anyhow::Error::msg(format!("{e:?}"))) +} + +/// `true` iff the txn error surfaced a `MoneyOutCapExceeded` (the cap CHECK fired). +fn is_cap_exceeded(e: &DbError) -> bool { + e.to_string().contains("MoneyOutCapExceeded") +} + +/// Seed a `payment_settlement` row (`settled`, `allocated`) in a fresh txn. Every +/// other counter starts at 0. +async fn seed_settlement( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + settled: i64, + allocated: i64, +) { + let scope = scope.clone(); + let payment_id = payment_id.to_owned(); + provider + .transaction(move |tx| { + Box::pin(async move { + PaymentRepo::seed_settlement(tx, &scope, tenant, &payment_id, "USD", settled, 0) + .await + .map_err(repo_to_db)?; + if allocated > 0 { + PaymentRepo::add_allocated(tx, &scope, tenant, &payment_id, allocated) + .await + .map_err(repo_to_db)?; + } + Ok::<(), DbError>(()) + }) + }) + .await + .expect("seed settlement"); +} + +/// Seed a `payment_allocation_refund` row with `allocated_minor = allocated` (the +/// per-`(payment, invoice)` cap basis) via `bump_allocation_refund` in a fresh txn. +async fn seed_allocation_refund( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + invoice_id: &str, + allocated: i64, +) { + let scope = scope.clone(); + let payment_id = payment_id.to_owned(); + let invoice_id = invoice_id.to_owned(); + provider + .transaction(move |tx| { + Box::pin(async move { + PaymentRepo::bump_allocation_refund( + tx, + &scope, + tenant, + &payment_id, + &invoice_id, + allocated, + ) + .await + .map_err(repo_to_db) + }) + }) + .await + .expect("seed allocation_refund"); +} + +/// Run `PaymentRepo::add_refunded(payment_id, delta)` in its own txn, returning the +/// result. +async fn add_refunded( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + delta: i64, +) -> Result<(), DbError> { + let scope = scope.clone(); + let payment_id = payment_id.to_owned(); + provider + .transaction(move |tx| { + Box::pin(async move { + PaymentRepo::add_refunded(tx, &scope, tenant, &payment_id, delta) + .await + .map_err(repo_to_db) + }) + }) + .await +} + +/// Run `add_refunded_unallocated` in its own txn. +async fn add_refunded_unallocated( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + delta: i64, +) -> Result<(), DbError> { + let scope = scope.clone(); + let payment_id = payment_id.to_owned(); + provider + .transaction(move |tx| { + Box::pin(async move { + PaymentRepo::add_refunded_unallocated(tx, &scope, tenant, &payment_id, delta) + .await + .map_err(repo_to_db) + }) + }) + .await +} + +/// Run `add_allocation_refund_refunded` in its own txn. +async fn add_allocation_refund_refunded( + provider: &DBProvider, + scope: &AccessScope, + tenant: Uuid, + payment_id: &str, + invoice_id: &str, + delta: i64, +) -> Result<(), DbError> { + let scope = scope.clone(); + let payment_id = payment_id.to_owned(); + let invoice_id = invoice_id.to_owned(); + provider + .transaction(move |tx| { + Box::pin(async move { + PaymentRepo::add_allocation_refund_refunded( + tx, + &scope, + tenant, + &payment_id, + &invoice_id, + delta, + ) + .await + .map_err(repo_to_db) + }) + }) + .await +} + +#[tokio::test] +async fn add_refunded_total_moneyout_cap_blocks_over_settled() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + // Settle 1000, nothing allocated. + seed_settlement(&provider, &scope, tenant, "pay-1", 1000, 0).await; + + // Within cap: refund 600 of 1000 settled. + add_refunded(&provider, &scope, tenant, "pay-1", 600) + .await + .expect("refund within settled succeeds"); + + // Over cap: a further 500 ⇒ refunded 1100 > 1000 settled ⇒ CHECK fires. + let over = add_refunded(&provider, &scope, tenant, "pay-1", 500) + .await + .expect_err("over-settled refund must be rejected by the cap CHECK"); + assert!( + is_cap_exceeded(&over), + "expected MoneyOutCapExceeded, got {over}" + ); + + // The remaining headroom (400) still admits a refund up to the cap. + add_refunded(&provider, &scope, tenant, "pay-1", 400) + .await + .expect("refund up to exactly settled succeeds"); + // And one more minor unit is now over. + let over2 = add_refunded(&provider, &scope, tenant, "pay-1", 1) + .await + .expect_err("refunding past exactly-settled must be rejected"); + assert!( + is_cap_exceeded(&over2), + "expected MoneyOutCapExceeded, got {over2}" + ); +} + +#[tokio::test] +async fn add_refunded_decrement_reopens_cap() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + seed_settlement(&provider, &scope, tenant, "pay-1", 1000, 0).await; + + // Reserve the whole settled amount. + add_refunded(&provider, &scope, tenant, "pay-1", 1000) + .await + .expect("reserve full cap"); + // A further refund is over-cap. + assert!( + is_cap_exceeded( + &add_refunded(&provider, &scope, tenant, "pay-1", 1) + .await + .expect_err("over cap") + ), + "cap is exhausted" + ); + + // Release 1000 (the stage-1 reversal decrement, negative Δ) — backs the counter + // to 0; the nonneg CHECK is NOT tripped (we back out exactly what we reserved). + add_refunded(&provider, &scope, tenant, "pay-1", -1000) + .await + .expect("decrement releases the cap (no underflow on the matched amount)"); + + // The cap is fully re-opened: a fresh full refund succeeds again. + add_refunded(&provider, &scope, tenant, "pay-1", 1000) + .await + .expect("cap re-opened after the release"); +} + +#[tokio::test] +async fn add_refunded_unallocated_headroom_cap_blocks_when_allocated() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + // Settle 1000, allocate 700 ⇒ spendable headroom for a Pattern-A refund is 300. + seed_settlement(&provider, &scope, tenant, "pay-1", 1000, 700).await; + + // Within headroom: refund_unallocated 300 ⇒ allocated 700 + 300 = 1000 <= 1000. + add_refunded_unallocated(&provider, &scope, tenant, "pay-1", 300) + .await + .expect("refund within spendable headroom succeeds"); + + // Over headroom: a further 1 ⇒ 700 + 301 = 1001 > 1000 ⇒ CHECK fires (the + // refunded on-account cash can no longer also be allocated). + let over = add_refunded_unallocated(&provider, &scope, tenant, "pay-1", 1) + .await + .expect_err("over-headroom refund_unallocated must be rejected"); + assert!( + is_cap_exceeded(&over), + "expected MoneyOutCapExceeded, got {over}" + ); +} + +#[tokio::test] +async fn add_allocation_refund_per_invoice_cap_blocks_over_allocated() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + seed_settlement(&provider, &scope, tenant, "pay-1", 1000, 800).await; + // The (payment, invoice) pair was allocated 800. + seed_allocation_refund(&provider, &scope, tenant, "pay-1", "inv-9", 800).await; + + // Within the per-invoice cap: refund 800. + add_allocation_refund_refunded(&provider, &scope, tenant, "pay-1", "inv-9", 800) + .await + .expect("refund up to the allocated amount succeeds"); + + // Over the per-invoice cap: a further 1 ⇒ refunded 801 > 800 allocated ⇒ CHECK. + let over = add_allocation_refund_refunded(&provider, &scope, tenant, "pay-1", "inv-9", 1) + .await + .expect_err("per-(payment, invoice) over-refund must be rejected"); + assert!( + is_cap_exceeded(&over), + "expected MoneyOutCapExceeded, got {over}" + ); + + // Release (the stage-1 reversal decrement) re-opens the per-invoice cap. + add_allocation_refund_refunded(&provider, &scope, tenant, "pay-1", "inv-9", -800) + .await + .expect("decrement releases the per-invoice cap"); + add_allocation_refund_refunded(&provider, &scope, tenant, "pay-1", "inv-9", 800) + .await + .expect("per-invoice cap re-opened after the release"); +} + +#[tokio::test] +async fn add_allocation_refund_absent_row_is_db_error() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + seed_settlement(&provider, &scope, tenant, "pay-1", 1000, 0).await; + // No payment_allocation_refund row for (pay-1, inv-none) ⇒ a Pattern-B refund of + // an unallocated receipt is an upstream contract violation (rows_affected == 0). + let err = add_allocation_refund_refunded(&provider, &scope, tenant, "pay-1", "inv-none", 100) + .await + .expect_err("a Pattern-B refund of a never-allocated (payment, invoice) must fail"); + // Not a cap violation — a plain Db error (the row is absent, not over-cap). + assert!( + !is_cap_exceeded(&err), + "absent allocation_refund row is a Db error, not a cap violation: {err}" + ); +} diff --git a/gears/bss/ledger/ledger/tests/sqlite_refund_repo.rs b/gears/bss/ledger/ledger/tests/sqlite_refund_repo.rs new file mode 100644 index 000000000..4c22e66a3 --- /dev/null +++ b/gears/bss/ledger/ledger/tests/sqlite_refund_repo.rs @@ -0,0 +1,211 @@ +//! Fast SQLite integration tests for the Slice-3 Phase-2 refund repo guarantees +//! (Group B3), exercised at the repo layer — the cheap half of the Phase-2 +//! integration matrix that does not need Docker/testcontainers. The full handler +//! end-to-end (chart provisioning + projector + post engine + the two-stage +//! REFUND_CLEARING drain) is a Postgres-only test (`postgres_refund.rs`, +//! `#[ignore]`). +//! +//! Covered: +//! - **`insert_refund` round-trips** (B3): a `refund` row persists with all fields +//! (Pattern A with NULL invoice_id; Pattern B with an invoice_id). +//! - **the surrogate PK collides** (B3): a duplicate `(tenant, refund_id)` is a +//! `Db` error (the engine's idempotency claim normally short-circuits this). +//! - **the natural UNIQUE `(tenant, psp_refund_id, phase)` collides** (B3 / design +//! §7): two DIFFERENT `refund_id`s carrying the SAME `(psp_refund_id, phase)` +//! collide on `uq_ledger_refund_psp_phase` — the idempotency grain is enforced at +//! the row even past the surrogate PK. +//! - **distinct phases of one PSP refund coexist** (B3): `(psp, initiated)` + +//! `(psp, confirmed)` both persist (one PSP refund advances through phase rows). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::similar_names, + clippy::needless_pass_by_value +)] + +use bss_ledger::domain::model::RepoError; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::AdjustmentRepo; +use bss_ledger::infra::storage::repo::adjustment_repo::NewRefund; +use chrono::Utc; +use sea_orm_migration::MigratorTrait; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +/// Connect an in-memory SQLite + run the migrator (the same harness as the +/// note repo tests). +async fn provider() -> DBProvider { + let db = connect_db("sqlite::memory:", ConnectOpts::default()) + .await + .expect("connect in-memory sqlite"); + run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("run migrator"); + DBProvider::::new(db) +} + +/// Map an in-txn `RepoError` into the `DbError` the `provider.transaction` closure +/// must return; the `RepoError` `Debug` is stamped into `DbError::Other`. +fn repo_to_db(e: RepoError) -> DbError { + DbError::Other(anyhow::Error::msg(format!("{e:?}"))) +} + +/// A Pattern-A (`A_UNALLOCATED`) stage-1 refund row: NULL invoice_id, PENDING +/// clearing. +fn refund_a(tenant: Uuid, refund_id: &str, psp_refund_id: &str, phase: &str) -> NewRefund { + NewRefund { + tenant_id: tenant, + refund_id: refund_id.to_owned(), + psp_refund_id: psp_refund_id.to_owned(), + phase: phase.to_owned(), + pattern: "A_UNALLOCATED".to_owned(), + payment_id: "pay-1".to_owned(), + invoice_id: None, + currency: "USD".to_owned(), + amount_minor: 500, + clearing_state: "PENDING".to_owned(), + relates_to_refund_id: None, + reverses_entry_id: None, + created_at_utc: Utc::now(), + } +} + +/// Persist one refund row in a fresh txn, returning the txn result. +async fn insert( + provider: &DBProvider, + scope: &AccessScope, + row: NewRefund, +) -> Result<(), DbError> { + let scope = scope.clone(); + provider + .transaction(move |tx| { + Box::pin(async move { + AdjustmentRepo::insert_refund(tx, &scope, &row) + .await + .map_err(repo_to_db) + }) + }) + .await +} + +#[tokio::test] +async fn refund_row_round_trips_pattern_a_and_b() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // Pattern A: NULL invoice_id. + insert( + &provider, + &scope, + refund_a(tenant, "rf-a", "psp-a", "initiated"), + ) + .await + .expect("insert Pattern A refund"); + + // Pattern B: an invoice_id, SETTLED single-step. + let row_b = NewRefund { + tenant_id: tenant, + refund_id: "rf-b".to_owned(), + psp_refund_id: "psp-b".to_owned(), + phase: "initiated".to_owned(), + pattern: "B_RESTORE_AR".to_owned(), + payment_id: "pay-2".to_owned(), + invoice_id: Some("inv-9".to_owned()), + currency: "USD".to_owned(), + amount_minor: 800, + clearing_state: "SETTLED".to_owned(), + relates_to_refund_id: None, + reverses_entry_id: None, + created_at_utc: Utc::now(), + }; + insert(&provider, &scope, row_b) + .await + .expect("insert Pattern B refund"); +} + +#[tokio::test] +async fn duplicate_surrogate_pk_collides() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + insert( + &provider, + &scope, + refund_a(tenant, "rf-dup", "psp-1", "initiated"), + ) + .await + .expect("first insert"); + // Same (tenant, refund_id) — a different psp/phase cannot rescue the surrogate PK. + let dup = insert( + &provider, + &scope, + refund_a(tenant, "rf-dup", "psp-2", "confirmed"), + ) + .await; + assert!( + dup.is_err(), + "duplicate (tenant, refund_id) must collide on PK" + ); +} + +#[tokio::test] +async fn duplicate_natural_psp_phase_collides() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // First row claims (psp-x, initiated) under refund_id rf-1. + insert( + &provider, + &scope, + refund_a(tenant, "rf-1", "psp-x", "initiated"), + ) + .await + .expect("first insert"); + // A DIFFERENT surrogate refund_id (rf-2) but the SAME (psp_refund_id, phase) must + // collide on the natural UNIQUE index `uq_ledger_refund_psp_phase` (the + // idempotency grain, design §7) — past the surrogate PK. + let dup = insert( + &provider, + &scope, + refund_a(tenant, "rf-2", "psp-x", "initiated"), + ) + .await; + assert!( + dup.is_err(), + "duplicate (tenant, psp_refund_id, phase) must collide on the natural UNIQUE index" + ); +} + +#[tokio::test] +async fn distinct_phases_of_one_psp_refund_coexist() { + let provider = provider().await; + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // One PSP refund advances initiated → confirmed: two rows, same psp_refund_id, + // distinct phase + distinct surrogate refund_id. Both persist. + insert( + &provider, + &scope, + refund_a(tenant, "rf-s1", "psp-multi", "initiated"), + ) + .await + .expect("stage-1 row"); + insert( + &provider, + &scope, + refund_a(tenant, "rf-s2", "psp-multi", "confirmed"), + ) + .await + .expect("stage-2 row coexists (distinct phase)"); +} diff --git a/gears/bss/ledger/ledger/tests/sqlite_repo.rs b/gears/bss/ledger/ledger/tests/sqlite_repo.rs new file mode 100644 index 000000000..aceff23ca --- /dev/null +++ b/gears/bss/ledger/ledger/tests/sqlite_repo.rs @@ -0,0 +1,155 @@ +//! Fast SQLite round-trip for the foundation repos. Opens an in-memory +//! database, runs the migrator, inserts a balanced 2-line entry through +//! `JournalRepo::insert_entry_with_lines` inside a transaction, then reads +//! it back via `find_entry`. SQLite has no triggers, so the entry is +//! already balanced (balance validation lands in P3). + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown, + clippy::similar_names +)] + +use bss_ledger::domain::model::{EntryKey, NewEntry, NewLine}; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::JournalRepo; +use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; +use chrono::{NaiveDate, Utc}; +use sea_orm_migration::MigratorTrait; +use serde_json::json; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +#[tokio::test] +async fn balanced_entry_round_trips_on_sqlite() { + let db = connect_db("sqlite::memory:", ConnectOpts::default()) + .await + .expect("connect in-memory sqlite"); + run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("run migrator"); + let provider = DBProvider::::new(db); + let repo = JournalRepo::new(provider.clone()); + + let tenant_id = Uuid::now_v7(); + let entry_id = Uuid::now_v7(); + let account_id = Uuid::now_v7(); + let period_id = "202606".to_owned(); + + let entry = NewEntry { + entry_id, + tenant_id, + legal_entity_id: Uuid::now_v7(), + period_id: period_id.clone(), + entry_currency: "USD".to_owned(), + source_doc_type: SourceDocType::InvoicePost, + source_business_id: "inv-1".to_owned(), + reverses_entry_id: None, + reverses_period_id: None, + posted_at_utc: Utc::now(), + effective_at: NaiveDate::from_ymd_opt(2026, 6, 18).unwrap(), + origin: "SYSTEM".to_owned(), + posted_by_actor_id: Uuid::now_v7(), + correlation_id: Uuid::now_v7(), + rounding_evidence: json!({}), + rate_snapshot_ref: None, + }; + + let mk_line = |account_class: AccountClass, side: Side, amount: i64| NewLine { + line_id: Uuid::now_v7(), + payer_tenant_id: tenant_id, + seller_tenant_id: None, + resource_tenant_id: None, + account_id, + account_class, + gl_code: None, + side, + amount_minor: amount, + currency: "USD".to_owned(), + currency_scale: 2, + invoice_id: Some("inv-1".to_owned()), + due_date: None, + revenue_stream: None, + mapping_status: MappingStatus::Resolved, + functional_amount_minor: None, + functional_currency: None, + tax_jurisdiction: None, + tax_filing_period: None, + tax_rate_ref: None, + legal_entity_id: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + po_allocation_group: None, + credit_grant_event_type: None, + ar_status: None, + }; + + // Balanced: 1000 DR (AR) against 1000 CR (CASH_CLEARING), same + // currency/scale. Neither class requires revenue_stream/tax dims, so the + // entry exercises the round-trip without tripping the column CHECKs. + let lines = vec![ + mk_line(AccountClass::Ar, Side::Debit, 1000), + mk_line(AccountClass::CashClearing, Side::Credit, 1000), + ]; + + let entry_ref = provider + .transaction(move |tx| { + Box::pin(async move { + repo.insert_entry_with_lines(tx, entry, lines) + .await + .map_err(|e| DbError::Other(anyhow::Error::msg(e.to_string()))) + }) + }) + .await + .expect("insert entry inside transaction"); + + assert_eq!(entry_ref.entry_id, entry_id); + assert!( + entry_ref.created_seq > 0, + "created_seq must be DB-populated, got {}", + entry_ref.created_seq + ); + + let read_repo = JournalRepo::new(provider); + let scope = AccessScope::for_tenant(tenant_id); + let record = read_repo + .find_entry( + &scope, + EntryKey { + tenant_id, + period_id, + entry_id, + }, + ) + .await + .expect("find_entry must succeed") + .expect("entry MUST be present after insert"); + + assert_eq!(record.entry_id, entry_id); + assert_eq!(record.created_seq, entry_ref.created_seq); + assert_eq!(record.entry_currency, "USD"); + assert_eq!(record.lines.len(), 2); + + let total_dr: i64 = record + .lines + .iter() + .filter(|l| l.side == "DR") + .map(|l| l.amount_minor) + .sum(); + let total_cr: i64 = record + .lines + .iter() + .filter(|l| l.side == "CR") + .map(|l| l.amount_minor) + .sum(); + assert_eq!(total_dr, total_cr, "lines must round-trip balanced"); + assert!(record.lines.iter().all(|l| l.currency == "USD")); +} diff --git a/gears/bss/ledger/ledger/tests/sqlite_scale_resolver.rs b/gears/bss/ledger/ledger/tests/sqlite_scale_resolver.rs new file mode 100644 index 000000000..c5b28b28c --- /dev/null +++ b/gears/bss/ledger/ledger/tests/sqlite_scale_resolver.rs @@ -0,0 +1,86 @@ +//! Fast SQLite tests for currency-scale resolution and the registration +//! headroom guard. ISO default vs registry override vs unknown, plus an +//! out-of-headroom scale rejected at upsert. + +#![allow( + clippy::non_ascii_literal, + clippy::let_underscore_must_use, + clippy::needless_collect, + clippy::expect_used, + clippy::unwrap_used, + clippy::doc_markdown +)] + +use bss_ledger::domain::model::{CurrencyScaleRow, RepoError}; +use bss_ledger::domain::money::{DEFAULT_PLAUSIBLE_MAX_MAJOR, ScaleError}; +use bss_ledger::infra::currency_scale::CurrencyScaleResolver; +use bss_ledger::infra::storage::migrations::Migrator; +use bss_ledger::infra::storage::repo::ReferenceRepo; +use sea_orm_migration::MigratorTrait; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::secure::AccessScope; +use toolkit_db::{ConnectOpts, DBProvider, DbError, connect_db}; +use uuid::Uuid; + +#[tokio::test] +async fn resolves_iso_override_and_rejects_unknown_and_overflow() { + let db = connect_db("sqlite::memory:", ConnectOpts::default()) + .await + .expect("connect sqlite"); + run_migrations_for_testing(&db, Migrator::migrations()) + .await + .expect("run migrator"); + let reference = ReferenceRepo::new(DBProvider::::new(db)); + let resolver = CurrencyScaleResolver::new(reference.clone()); + + let tenant = Uuid::now_v7(); + let scope = AccessScope::for_tenant(tenant); + + // ISO default, no registry row. + assert_eq!(resolver.resolve(&scope, tenant, "USD").await.unwrap(), 2); + + // Non-ISO currency, scale within the default headroom, via the registry. + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "USDC".to_owned(), + minor_units: 6, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "tenant".to_owned(), + }) + .await + .unwrap(); + assert_eq!(resolver.resolve(&scope, tenant, "USDC").await.unwrap(), 6); + + // High-precision crypto (BTC@8) fits under a smaller per-currency max + // (21_000_000 major units): 2.1e7 * 10^8 = 2.1e15 <= i64::MAX (VHP-1834). + reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "BTC".to_owned(), + minor_units: 8, + plausible_max_major: 21_000_000, + source: "tenant".to_owned(), + }) + .await + .unwrap(); + assert_eq!(resolver.resolve(&scope, tenant, "BTC").await.unwrap(), 8); + + // Non-ISO, no row -> unknown. + let unknown = resolver.resolve(&scope, tenant, "ZZZ").await.unwrap_err(); + assert!(matches!(unknown, ScaleError::UnknownCurrencyScale(_))); + + // Out-of-headroom scale rejected at registration: scale 8 under the + // default 10^12 max overflows (10^12 * 10^8 = 10^20 > i64::MAX). + let overflow = reference + .upsert_currency_scale(CurrencyScaleRow { + tenant_id: tenant, + currency: "ETH".to_owned(), + minor_units: 8, + plausible_max_major: DEFAULT_PLAUSIBLE_MAX_MAJOR, + source: "tenant".to_owned(), + }) + .await + .unwrap_err(); + assert!(matches!(overflow, RepoError::ScaleOutOfRange(_))); +} diff --git a/gears/bss/libs/coord/Cargo.toml b/gears/bss/libs/coord/Cargo.toml new file mode 100644 index 000000000..fff758109 --- /dev/null +++ b/gears/bss/libs/coord/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "coord" +edition.workspace = true +license.workspace = true +description = "Shared BSS coordination primitives — a DB-backed distributed lease (single-active job/run guard), ported from the account-management lease pattern. Works entirely within the existing SecureORM API (unscoped `coord_leases` table via `AccessScope::allow_all()`); no toolkit changes." + +[lints] +workspace = true + +[dependencies] +# SecureORM + entity over both backends (PG prod, SQLite tests). Backend + +# runtime features unify in through `toolkit-db`, mirroring the ledger gear. +sea-orm = { workspace = true, features = [ + "sqlx-postgres", + "sqlx-sqlite", + "with-chrono", + "with-uuid", +] } +# The `coord_leases` table migration (consuming gears add it to their Migrator). +sea-orm-migration = { workspace = true } +# `Db`, `DbError`, `secure::{DbTx, ScopeError, SecureEntityExt, SecureUpdateExt, +# TxConfig, secure_insert, is_unique_violation}` + `transaction_with_retry`. +toolkit-db = { workspace = true, features = ["pg", "sqlite"] } +# `#[derive(Scopable)]` on the `coord_leases` entity. +toolkit-db-macros = { workspace = true } +# `AccessScope::allow_all()` — the unscoped clamp for the coordination table. +toolkit-security = { workspace = true } +uuid = { workspace = true } +# Entity `locked_until: DateTime` + worker-clock `Utc::now()` on the +# acquire INSERT (`clock` feature); all comparisons are DB-clock SQL exprs. +chrono = { workspace = true, features = ["clock"] } +thiserror = { workspace = true } +# `spawn_renewal` heartbeat (`tokio::time::interval`, `tokio::spawn`, +# `tokio::sync::watch`); `macros`/`rt` for the crate's `#[tokio::test]`s. +tokio = { workspace = true, features = ["time", "macros", "rt", "sync"] } +# `CancellationToken` for the renewal-task lifecycle (the dep AM uses). +tokio-util = { workspace = true } +tracing = { workspace = true } +# `#[async_trait]` on the `MigrationTrait` impl. +async-trait = { workspace = true } + +[dev-dependencies] +# Integration tests drive the real `LeaseManager`/`LeaseGuard` against an +# in-memory SQLite `Db`; raw setup SQL goes through sea-orm `Statement`. +tokio = { workspace = true, features = ["time", "macros", "rt", "sync"] } diff --git a/gears/bss/libs/coord/README.md b/gears/bss/libs/coord/README.md new file mode 100644 index 000000000..22bb035e9 --- /dev/null +++ b/gears/bss/libs/coord/README.md @@ -0,0 +1,180 @@ +# `coord` + +> Shared BSS coordination primitives. Today it hosts **one** thing: a DB-backed +> distributed **lease** — a *single-active* guard for jobs and runs. + +This document explains what the lease does and, more importantly, **when it is and +isn't the right tool**, so you can decide if it fits your case before adopting it. + +--- + +## The problem it solves + +You run several replicas of a service (or several ticks of a scheduled job), and +each one might try to do the same unit of work at the same time: + +- one recognition run per `(tenant, period)`, +- one nightly close, +- a singleton reaper / ticker / outbox-drainer. + +You want **at most one** of them to actually run a given unit at a time — without +standing up Redis, ZooKeeper, or a dedicated lock service. `coord` gives you that +using **only the database you already have** (Postgres in prod, SQLite in tests). + +It was ported from the account-management `am_leases` pattern and generalized for +reuse by any BSS gear. It works **entirely within the existing toolkit SecureORM** +— the `coord_leases` row is unscoped process-coordination state accessed via +`AccessScope::allow_all()` — so adopting it needs **no toolkit / `gears-rust` +changes**. + +--- + +## Mental model + +A single table, `coord_leases`, with one row per coordination key: + +| column | meaning | +| -------------- | ------------------------------------------------------------------ | +| `key` (PK) | the coordination domain, an arbitrary string you choose | +| `locked_by` | the current holder's UUID (`NULL` when free) | +| `locked_until` | TTL deadline — the lease is *live* while `locked_until > NOW()` | +| `attempts` | forensic steal counter (a flapping cluster shows a high-water value) | + +- **Acquire** = INSERT a fresh row (free slot) **or** steal an expired one + (`UPDATE … WHERE locked_until < NOW()`), all inside one `SERIALIZABLE` retry + transaction. Exactly one worker wins; the loser gets `CoordError::LeaseHeld`. +- The lease is **time-bounded**. A holder that crashes without releasing is + automatically reclaimable once `locked_until` passes — **no manual cleanup, no + permanent deadlock.** +- Time anchors on the **DB clock** (steal / renew / fence filters all use the + database's `NOW()`). So clock drift between workers can only ever cause a + *conservative false negative* (you fail to acquire a slot that was actually + free), **never an unsafe double-acquire.** + +--- + +## API at a glance + +```rust,ignore +let mgr = coord::LeaseManager::new(db.clone()); + +match mgr.acquire("recognition-run:{tenant}:{period}", ttl).await { + Ok(guard) => { + // ... do the single-active work ... + guard.release().await?; // success: free the slot + reset `attempts` + } + Err(coord::CoordError::LeaseHeld) => { /* a peer is already running — skip */ } + Err(e) => return Err(e.into()), // DB failure +} +``` + +| Item | Purpose | +| --- | --- | +| `LeaseManager::new(db)` / `.acquire(key, ttl)` | acquire-or-steal; returns a `LeaseGuard` or `LeaseHeld` | +| `LeaseGuard::release()` | success path: free the slot, reset `attempts` | +| `LeaseGuard::release_with_retry()` | recoverable-failure path: free the slot, **keep** the `attempts` streak | +| `LeaseGuard::renew(ttl)` | push `locked_until` forward; zero rows ⇒ `LeaseLost` | +| `LeaseGuard::with_ack_in_tx(extract_db_err, f)` | run `f` **and** a lease-validity fence SELECT in ONE serializable tx — a mid-flight steal ⇒ `AckError::LeaseLost` + rollback (your writes never commit under a lost lease) | +| `LeaseGuard::spawn_renewal(period)` → `RenewalHandle` | background heartbeat; emits `RenewalState::Lost` on a `watch` channel so a long holder can pre-empt itself | +| `CoordError { LeaseHeld, LeaseLost, Db }` | acquire / renew / release outcomes | +| `AckError { LeaseLost, Work(E), Db }` | `with_ack_in_tx` outcome envelope | +| `coord::migration::Migration::in_schema("bss")` / `::unqualified()` | the `coord_leases` table migration (qualified for a named PG schema, or bare for SQLite / `search_path`) | + +--- + +## Guarantees + +- **At most one live holder per key** (subject to the TTL caveat below). +- **Crash safety via TTL**: a dead holder's slot auto-reclaims after `locked_until` + — no orphaned locks, no manual recovery. +- **DB-clock anchored**: worker clock drift cannot cause an unsafe double-steal. +- **Atomic check-and-commit** (`with_ack_in_tx`): your DB work and the "do I still + hold the lease?" check commit atomically. You cannot commit results under a lease + a peer has already stolen — the check either aborts on serialization conflict (and + retries) or sees zero rows and rolls back as `LeaseLost`. This is an in-transaction + re-check, **not** a monotonic fencing token (`locked_by` is a fresh per-acquire + UUID); for effects outside the DB transaction see Non-guarantees below. + +## Non-guarantees — read these before relying on it + +- **It is a lease, not a fencing token for *external* side effects.** Only DB + writes folded into `with_ack_in_tx` are protected by the steal fence. Between + "I hold the lease" and an external effect (an HTTP call, a publish outside the + DB transaction) the lease can expire or be stolen. For non-DB effects you still + need **idempotency**. +- **TTL must exceed the work, or you must renew.** If a job outruns its TTL without + `spawn_renewal` / `renew`, a peer can steal mid-run and you get two concurrent + holders. Choose `ttl > expected work`, or heartbeat with `period ≈ ttl / 3` + (survives one missed tick before expiry). +- **No `Drop`-time release.** There is no async I/O in `Drop`, so always call + `release()` / `release_with_retry()` explicitly; an abandoned guard relies on + the TTL fallback. +- **Single database.** It coordinates workers sharing one DB. It does **not** + coordinate across databases, clusters, or regions. +- **Not a queue.** No fairness, no ordering, no backlog. The loser simply gets + `LeaseHeld` and decides for itself whether to skip or retry later. +- **Postgres or SQLite only.** MySQL is rejected at runtime (returns a `Db` error, + not a panic). + +--- + +## Use `coord` when… + +- You need **single-active** semantics for a job/run across replicas, and you + already have Postgres (or SQLite for tests). +- The protected critical section is **primarily DB work** (so `with_ack_in_tx` + fences it), or any external effects can be made idempotent. +- You want **crash-safe auto-reclaim** (TTL) rather than manual lock cleanup. +- Typical fits: one recognition run per `(tenant, period)`; a singleton + reaper/ticker; a nightly close that must not double-run. + +## Don't use `coord` when… (reach for something else) + +| If you need… | use instead | +| --- | --- | +| Hard mutual exclusion for **non-idempotent external side effects**, zero tolerance for a TTL-expiry overlap | a real fencing-token protocol, or idempotency at the effect itself | +| **Low-latency, high-churn** locking (thousands/sec) | Redis / a dedicated lock service — a DB-row lease is the wrong tool | +| **Fairness, ordering, or a backlog** of work | a real queue | +| Coordination **across databases / regions** | out of scope for this primitive | +| Mutual exclusion **within a single process** | `tokio::sync::Mutex` (no DB needed) | + +--- + +## Adopting it + +1. Add `coord` as a dependency and register the migration in your `Migrator`: + `Box::new(coord::migration::Migration::in_schema("bss"))` (or `::unqualified()` + for SQLite / a single-schema `search_path`). +2. Build `LeaseManager::new(db)` and bracket the job: `acquire` → run → `release`. +3. Map `CoordError::LeaseHeld` onto your gear's "already running" outcome; surface + `Db` / `LeaseLost` as appropriate (the crate is deliberately free of any gear's + domain error taxonomy — the mapping lives at the call site). +4. For **long** jobs, `spawn_renewal(ttl / 3)` and watch for `RenewalState::Lost` + to self-pre-empt. For **DB-critical** sections, prefer `with_ack_in_tx` so the + commit is fenced against a concurrent steal. + +### Fenced-commit sketch + +```rust,ignore +// Work + lease-validity check commit atomically; a peer steal mid-flight +// rolls the work back as AckError::LeaseLost (never committed under a lost lease). +let out = guard + .with_ack_in_tx( + |e: &MyErr| e.as_db_err(), // classify retryable contention in your work error + |tx| Box::pin(async move { do_db_work(tx).await }), + ) + .await?; +``` + +--- + +## Design notes / provenance + +- Ported from account-management `am_leases`, generalized for cross-gear reuse: + no domain-error coupling (the consumer maps `CoordError`), a `Dialect` enum + instead of the AM original's panicking `&str` engine match, and a + schema-qualifiable migration. +- `attempts` is a forensic steal counter — alert on `attempts >= 3` to catch a + flapping cluster. `release` resets it; `release_with_retry` preserves the streak. +- Single-table, single-DB, TTL-reclaimable by design: it is the *smallest* thing + that delivers single-active coordination on the database you already run. diff --git a/gears/bss/libs/coord/src/lease.rs b/gears/bss/libs/coord/src/lease.rs new file mode 100644 index 000000000..a0d4323f3 --- /dev/null +++ b/gears/bss/libs/coord/src/lease.rs @@ -0,0 +1,20 @@ +//! Distributed-lease primitive — a DB-backed single-active job/run guard. +//! +//! [`LeaseManager::acquire`] takes the gate (or returns [`CoordError::LeaseHeld`] +//! when a peer holds it); the returned [`LeaseGuard`] renews, releases, fences a +//! write in-tx, or drives a renewal heartbeat. The `coord_leases` row is +//! unscoped (`#[secure(no_tenant, …)]`), so every access scopes with +//! `AccessScope::allow_all()` — the primitive needs nothing beyond the existing +//! `SecureORM` API. + +mod entity; +pub mod error; +pub mod guard; +pub mod manager; + +#[cfg(test)] +mod sqlite_tests; + +pub use error::{AckError, CoordError}; +pub use guard::{LeaseGuard, RenewalHandle, RenewalState}; +pub use manager::LeaseManager; diff --git a/gears/bss/libs/coord/src/lease/entity.rs b/gears/bss/libs/coord/src/lease/entity.rs new file mode 100644 index 000000000..b0cfe15b2 --- /dev/null +++ b/gears/bss/libs/coord/src/lease/entity.rs @@ -0,0 +1,55 @@ +//! `coord_leases` — the `SeaORM` entity backing the distributed lease. +//! +//! One row per coordination domain (`key`); a worker holds the gate iff +//! `locked_by = ` AND `locked_until > NOW()` on the DB clock. The +//! `key` PK lets unrelated singleton jobs share one table — e.g. a billing gear +//! keys recognition runs `"recognition-run:{tenant}:{period}"` while another +//! domain uses its own namespace. +//! +//! Schema (per dialect; created by the `coord::migration` migration the +//! consuming gear adds to its `Migrator`): +//! +//! * `key` `TEXT` PRIMARY KEY — coordination domain. +//! * `locked_by` `UUID` / `TEXT` NULL — current holder; `NULL` ≡ free. +//! * `locked_until` `TIMESTAMPTZ` / `TEXT` NOT NULL — DB-clock expiry; epoch when free. +//! * `attempts` `INTEGER` NOT NULL DEFAULT `0` — forensic takeover counter. +//! +//! All time comparisons run on the DB clock via dialect SQL exprs (see +//! [`super::manager`]); the `DateTime` field is only the worker's view of +//! `locked_until`, written on the acquire INSERT — the row's truth is whatever +//! the DB committed. +//! +//! `#[secure(no_tenant, no_resource, no_owner, no_type)]` because the row is +//! process-coordination state, not a tenant resource: every access scopes with +//! [`toolkit_security::AccessScope::allow_all`]. It is never surfaced through +//! any SDK — only this crate reads or writes it. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use toolkit_db_macros::Scopable; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] +#[sea_orm(table_name = "coord_leases")] +#[secure(no_tenant, no_resource, no_owner, no_type)] +pub struct Model { + /// Coordination domain. A `TEXT` PK leaves room for many singleton jobs in + /// one table with no schema work; the holder namespaces its own keys. + #[sea_orm(primary_key, auto_increment = false)] + pub key: String, + /// Current holder's worker id; `NULL` when the row is free. + pub locked_by: Option, + /// DB-clock expiry. When `locked_by IS NULL` this holds the epoch sentinel + /// (`1970-01-01T00:00:00Z`) — kept non-nullable to simplify the + /// `WHERE locked_until < NOW()` steal filter. + pub locked_until: DateTime, + /// Monotonically increases on every steal (expired-lease takeover). Reset to + /// `0` only on a clean `release`; `release_with_retry` preserves it as a + /// forensic streak so a flapping holder is visible to operators. + pub attempts: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/gears/bss/libs/coord/src/lease/error.rs b/gears/bss/libs/coord/src/lease/error.rs new file mode 100644 index 000000000..3cb66d64d --- /dev/null +++ b/gears/bss/libs/coord/src/lease/error.rs @@ -0,0 +1,77 @@ +//! Error types for the distributed-lease primitive. +//! +//! These are deliberately free of any gear's domain taxonomy: a consuming gear +//! maps [`CoordError`] / [`AckError`] onto its own error type at the call site +//! (e.g. `LeaseHeld` → "a run is already in progress"). That is the one place +//! the AM original differs — it carried a `From for DomainError`; +//! a shared crate cannot, so the mapping lives with the consumer. + +use sea_orm::DbErr; + +/// Result of an acquire / renew / release call against a lease row. +/// +/// `LeaseHeld` is the "another worker already holds this gate" outcome surfaced +/// by the acquire/steal path; `LeaseLost` is the "you used to hold it but a +/// peer stole it (or your TTL lapsed)" outcome surfaced by `renew` and by +/// [`super::guard::LeaseGuard::with_ack_in_tx`]'s fence SELECT. +#[derive(Debug, thiserror::Error)] +pub enum CoordError { + #[error("lease already held by another worker")] + LeaseHeld, + #[error("lease was lost (taken over) before this call could complete")] + LeaseLost, + #[error(transparent)] + Db(#[from] toolkit_db::DbError), +} + +impl CoordError { + /// Extract the underlying `DbErr` for the contention-retry helper. + /// + /// Used as the `extract_db_err` accessor passed into + /// [`toolkit_db::Db::transaction_with_retry`] (the fenced-write path, and + /// in tests). Returns `None` for non-DB variants so the retry loop + /// short-circuits on `LeaseHeld` / `LeaseLost`. + #[must_use] + pub fn db_err(&self) -> Option<&DbErr> { + match self { + Self::Db(toolkit_db::DbError::Sea(e)) => Some(e), + _ => None, + } + } +} + +/// Outcome envelope for [`super::guard::LeaseGuard::with_ack_in_tx`]. +/// +/// `E` is the caller-defined work-error type; the caller hands the guard a +/// `Fn(&E) -> Option<&DbErr>` extractor so the retry helper can decide whether +/// a `Work(_)` failure is retryable contention or a hard failure. +/// +/// `LeaseLost` is **never** retried — re-running under a stolen lease cannot +/// succeed and would commit work against the new holder's slot. +#[derive(Debug, thiserror::Error)] +pub enum AckError { + #[error("lease was lost before the fenced commit could complete")] + LeaseLost, + #[error(transparent)] + Work(E), + #[error(transparent)] + Db(#[from] toolkit_db::DbError), +} + +impl AckError { + /// Extract the underlying `DbErr` from any variant for the contention-retry + /// helper. The caller-supplied `extract_work_db_err` drills into the + /// `Work(E)` arm; this method composes that accessor with the built-in + /// `Db(_)` arm so [`toolkit_db::Db::transaction_with_retry`] sees a single + /// `Fn(&AckError) -> Option<&DbErr>`. + pub fn db_err<'a, X>(&'a self, extract_work_db_err: &X) -> Option<&'a DbErr> + where + X: Fn(&E) -> Option<&DbErr>, + { + match self { + Self::Db(toolkit_db::DbError::Sea(e)) => Some(e), + Self::Db(_) | Self::LeaseLost => None, + Self::Work(w) => extract_work_db_err(w), + } + } +} diff --git a/gears/bss/libs/coord/src/lease/guard.rs b/gears/bss/libs/coord/src/lease/guard.rs new file mode 100644 index 000000000..0b9f38e2b --- /dev/null +++ b/gears/bss/libs/coord/src/lease/guard.rs @@ -0,0 +1,410 @@ +//! `LeaseGuard` — the holder-side handle for an acquired lease. +//! +//! Carries the `key` + `locked_by` UUID needed to scope every subsequent +//! operation (renew, release, fence-check) to the exact row the holder inserted +//! or stole. Construction is private to [`super::manager::LeaseManager`] — +//! callers obtain a guard only via [`super::manager::LeaseManager::acquire`]. +//! +//! Three properties define the surface (unchanged from the AM original): +//! +//! 1. **Fence-in-tx.** [`LeaseGuard::with_ack_in_tx`] runs the caller's work +//! and a `coord_leases`-row SELECT inside one `SERIALIZABLE` transaction +//! (with retry). A peer steal between the work and the commit either aborts +//! as `40001` and retries, or is caught by the fence SELECT as +//! [`AckError::LeaseLost`] → rollback. The holder's writes and the lease +//! validation cannot drift apart. +//! 2. **Renewal heartbeat with explicit lease-loss signal.** +//! [`LeaseGuard::spawn_renewal`] drives `locked_until` forward every +//! `period`; an UPDATE returning zero rows surfaces as [`RenewalState::Lost`] +//! on a `watch::Receiver`, so the holder can pre-empt itself. +//! 3. **Forensic `attempts` counter.** Each steal increments it; `release` +//! resets it, `release_with_retry` preserves it. + +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; + +use sea_orm::sea_query::Expr; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use toolkit_db::Db; +use toolkit_db::secure::{DbTx, ScopeError, SecureEntityExt, SecureUpdateExt, TxConfig}; +use toolkit_security::AccessScope; +use uuid::Uuid; + +use super::entity as coord_leases; +use super::error::{AckError, CoordError}; +use super::manager::{Dialect, map_scope_err, ttl_secs_i64}; + +/// Holder-side handle for an acquired lease. Always release explicitly — there +/// is no `Drop` impl performing async DB I/O (cannot work cleanly under tokio +/// runtime shutdown), so a guard dropped without a `release` / +/// `release_with_retry` relies on the TTL fallback to free the slot. +pub struct LeaseGuard { + db: Db, + key: String, + locked_by: Uuid, + ttl: Duration, +} + +impl std::fmt::Debug for LeaseGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // `Db` is not `Debug`; render only the identity fields. + f.debug_struct("LeaseGuard") + .field("key", &self.key) + .field("locked_by", &self.locked_by) + .field("ttl", &self.ttl) + .finish_non_exhaustive() + } +} + +impl LeaseGuard { + pub(super) fn new(db: Db, key: String, locked_by: Uuid, ttl: Duration) -> Self { + Self { + db, + key, + locked_by, + ttl, + } + } + + /// Lease key — the coordination domain this guard owns. + #[must_use] + pub fn key(&self) -> &str { + &self.key + } + + /// Holder UUID. Stable for the guard's lifetime; reused across `renew`, + /// `release`, and `with_ack_in_tx` fence SELECTs so all target this row. + #[must_use] + pub fn locked_by(&self) -> Uuid { + self.locked_by + } + + /// TTL the lease was acquired with. The renewal heartbeat uses this; + /// explicit `renew(ttl)` calls can override per invocation. + #[must_use] + pub fn ttl(&self) -> Duration { + self.ttl + } + + /// Push `locked_until` forward by `ttl` against the DB clock. + /// + /// The `key + locked_by` filter scopes the UPDATE to this guard's row; zero + /// rows affected → a peer took over (or the TTL already lapsed) and + /// surfaces as [`CoordError::LeaseLost`]. One atomic statement; no tx. + /// + /// # Errors + /// * [`CoordError::LeaseLost`] — peer stole the lease, or it had expired. + /// * [`CoordError::Db`] — DB transport / serialisation failure. + pub async fn renew(&self, ttl: Duration) -> Result<(), CoordError> { + renew_once(&self.db, &self.key, self.locked_by, ttl).await + } + + /// Release on the success path: free the slot and reset the forensic + /// `attempts` counter to `0`. Consumes the guard. + /// + /// A zero-rows-affected UPDATE (lease already stolen) is logged at WARN and + /// returned `Ok(())` — the contract is "the lease is released or was already + /// released", not "we executed the release ourselves". + /// + /// # Errors + /// * [`CoordError::Db`] — DB transport / serialisation failure. + pub async fn release(self) -> Result<(), CoordError> { + self.release_impl(/* reset_attempts */ true).await + } + + /// Release on a recoverable-failure path: free the slot but **preserve** the + /// `attempts` counter so a flapping holder stays visible as a high-water + /// value. Consumes the guard; otherwise identical to [`Self::release`]. + /// + /// # Errors + /// * [`CoordError::Db`] — DB transport / serialisation failure. + pub async fn release_with_retry(self) -> Result<(), CoordError> { + self.release_impl(/* reset_attempts */ false).await + } + + async fn release_impl(self, reset_attempts: bool) -> Result<(), CoordError> { + let dialect = Dialect::from_engine(self.db.db_engine())?; + let conn = self.db.conn().map_err(CoordError::Db)?; + + let mut update = coord_leases::Entity::update_many() + .col_expr(coord_leases::Column::LockedBy, Expr::value(None::)) + .col_expr(coord_leases::Column::LockedUntil, dialect.epoch_expr()); + if reset_attempts { + update = update.col_expr(coord_leases::Column::Attempts, Expr::value(0_i32)); + } + let result = update + .filter( + coord_leases::Column::Key + .eq(self.key.as_str()) + .and(coord_leases::Column::LockedBy.eq(self.locked_by)), + ) + .secure() + .scope_with(&AccessScope::allow_all()) + .exec(&conn) + .await + .map_err(map_scope_err)?; + + if result.rows_affected == 0 { + tracing::warn!( + target: "coord.lease", + key = %self.key, + locked_by = %self.locked_by, + reset_attempts, + "lease release matched zero rows; row was likely stolen before release", + ); + } + Ok(()) + } + + /// Run `f` inside a `SERIALIZABLE` transaction (with retry on transient + /// contention) and append a fence SELECT against `coord_leases` as the last + /// DB call of the tx. Zero matched rows → [`AckError::LeaseLost`] (rollback, + /// **not** retried). + /// + /// `extract_work_db_err` lets the retry helper classify `Work(E)` failures: + /// `Some(&DbErr)` for retryable contention causes a retry; anything else (or + /// `None`) terminates the loop. + /// + /// Critical contract: `f` MUST be idempotent across retries (each attempt + /// opens a fresh tx; in-memory state mutated by an earlier attempt must be + /// reset by `f` itself before re-running). + /// + /// # Errors + /// * [`AckError::LeaseLost`] — fence SELECT found the row stolen / expired. + /// * [`AckError::Work`] — `f` returned `Err(E)`. + /// * [`AckError::Db`] — DB transport / serialisation / fence-SELECT failure + /// the retry helper did not classify as retryable, or an unsupported + /// backend. + pub async fn with_ack_in_tx( + &self, + extract_work_db_err: X, + mut f: F, + ) -> Result> + where + E: Send + 'static, + T: Send + 'static, + X: Fn(&E) -> Option<&sea_orm::DbErr> + Send + Sync, + F: for<'a> FnMut(&'a DbTx<'a>) -> Pin> + Send + 'a>> + + Send, + { + let key_owned = self.key.clone(); + let locked_by = self.locked_by; + let dialect = match Dialect::from_engine(self.db.db_engine()) { + Ok(d) => d, + // `from_engine` only yields `Db`; lift it into the ack taxonomy. + Err(CoordError::Db(db)) => return Err(AckError::Db(db)), + Err(_) => { + return Err(AckError::Db(toolkit_db::DbError::Sea( + sea_orm::DbErr::Custom( + "coord.lease: unexpected acquire error during fence setup".to_owned(), + ), + ))); + } + }; + + self.db + .transaction_with_retry::, _, _>( + TxConfig::serializable(), + |e: &AckError| e.db_err(&extract_work_db_err), + move |tx| { + // FnMut body — clone `key` per attempt; call `f` OUTSIDE the + // async block so its returned future already holds the `tx` + // borrow (avoids moving `f` into the async block, which would + // consume it across attempts). `dialect` is `Copy`. + let key = key_owned.clone(); + let user_future = f(tx); + Box::pin(async move { + let work_result = user_future.await.map_err(AckError::Work)?; + // Fence SELECT — last DB call inside the tx. A peer steal + // that committed mid-flight normally aborts here as 40001 + // (read-set conflict) and retries; the explicit + // zero-rows check covers a steal that committed BEFORE + // this tx began. The `locked_until > NOW()` clause also + // fences a lapsed-but-unstolen lease. + let still_mine = coord_leases::Entity::find() + .filter( + coord_leases::Column::Key + .eq(key.as_str()) + .and(coord_leases::Column::LockedBy.eq(locked_by)) + .and(dialect.live_filter()), + ) + .secure() + .scope_with(&AccessScope::allow_all()) + .one(tx) + .await + .map_err(map_fence_scope_err)?; + if still_mine.is_none() { + return Err(AckError::LeaseLost); + } + Ok(work_result) + }) + }, + ) + .await + } + + /// Spawn a heartbeat task that renews the lease every `period`. Returns a + /// [`RenewalHandle`] carrying the cancel token, a + /// `watch::Receiver` for the in-band lease-loss signal, and + /// the join handle. + /// + /// Convention: `period` should be `~ttl / 3` so the lease survives one + /// missed tick (transient DB blip) before TTL expiry. The task exits on + /// cancellation ([`RenewalState::ShuttingDown`]) or lease loss + /// ([`RenewalState::Lost`]). Transient renewal failures log at ERROR and + /// continue — TTL has margin and the next tick retries. + #[must_use] + pub fn spawn_renewal(&self, period: Duration) -> RenewalHandle { + let cancel = tokio_util::sync::CancellationToken::new(); + let (state_tx, state_rx) = tokio::sync::watch::channel(RenewalState::Healthy); + let db = self.db.clone(); + let key = self.key.clone(); + let locked_by = self.locked_by; + let ttl = self.ttl; + let cancel_task = cancel.clone(); + + let join = tokio::spawn(async move { + let mut interval = tokio::time::interval(period); + // If a `renew_once` runs long (slow DB), skip the missed ticks rather + // than firing catch-up ticks back-to-back — one renewal per period is + // enough (TTL has margin), and a burst would issue redundant UPDATEs. + // Matches every ticker in `module.rs`. + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // The first tick fires immediately; consume it so the first renewal + // happens one `period` after spawn, leaving room for the acquire + // INSERT/UPDATE to commit before we renew the row we just touched. + interval.tick().await; + loop { + tokio::select! { + biased; + () = cancel_task.cancelled() => { + _ = state_tx.send(RenewalState::ShuttingDown); + return; + } + _ = interval.tick() => { + match renew_once(&db, &key, locked_by, ttl).await { + Ok(()) => {} + Err(CoordError::LeaseLost) => { + _ = state_tx.send(RenewalState::Lost); + return; + } + Err(other) => { + tracing::error!( + target: "coord.lease", + key = %key, + error = ?other, + "lease renewal failed; retrying next tick", + ); + } + } + } + } + } + }); + + RenewalHandle { + cancel, + state: state_rx, + join: Some(join), + } + } +} + +/// Standalone renewal — used by [`LeaseGuard::renew`] and by the heartbeat task +/// (which cannot hold a `&LeaseGuard` across `tokio::spawn`). +/// +/// The UPDATE filter scopes to `(key, locked_by, locked_until > NOW())` so +/// either a peer takeover (changed `locked_by`) OR an already-expired lease +/// gets zero rows affected → [`CoordError::LeaseLost`]. +async fn renew_once(db: &Db, key: &str, locked_by: Uuid, ttl: Duration) -> Result<(), CoordError> { + let dialect = Dialect::from_engine(db.db_engine())?; + let ttl_secs = ttl_secs_i64(ttl); + let conn = db.conn().map_err(CoordError::Db)?; + + let result = coord_leases::Entity::update_many() + .col_expr( + coord_leases::Column::LockedUntil, + dialect.ttl_expr(ttl_secs), + ) + .filter( + coord_leases::Column::Key + .eq(key) + .and(coord_leases::Column::LockedBy.eq(locked_by)) + // Require the lease still live on the DB clock: an expired row + // is logically lost (a peer may steal it), so renewal MUST NOT + // resurrect it. Zero rows → `LeaseLost`. + .and(dialect.live_filter()), + ) + .secure() + .scope_with(&AccessScope::allow_all()) + .exec(&conn) + .await + .map_err(map_scope_err)?; + + if result.rows_affected == 0 { + return Err(CoordError::LeaseLost); + } + Ok(()) +} + +/// Lift the fence-SELECT's `ScopeError` into `AckError::Db`. The `coord_leases` +/// table is unscoped, so the only realistic variant is `Db(_)`; the others +/// surface as a `Custom` `DbErr` so the retry classifier sees `None`. +fn map_fence_scope_err(err: ScopeError) -> AckError { + match err { + ScopeError::Db(db) => AckError::Db(toolkit_db::DbError::Sea(db)), + other => AckError::Db(toolkit_db::DbError::Sea(sea_orm::DbErr::Custom(format!( + "coord.lease: fence SELECT ScopeError: {other:?}" + )))), + } +} + +/// Handle returned by [`LeaseGuard::spawn_renewal`]. +/// +/// Two shutdown paths: [`Self::shutdown`] (cooperative — cancels and awaits the +/// task's exit, so the caller observes [`RenewalState::ShuttingDown`]); or +/// dropping the handle (safety net — [`Drop`] cancels the token and the runtime +/// detaches the held `JoinHandle`; no `await` happens in `Drop`). +pub struct RenewalHandle { + pub cancel: tokio_util::sync::CancellationToken, + pub state: tokio::sync::watch::Receiver, + /// `Option` so [`Self::shutdown`] can `.take()` and move the handle out for + /// awaiting — a plain `JoinHandle` field would let [`Drop`] block the + /// partial move at any call site that awaits the join. + join: Option>, +} + +impl RenewalHandle { + /// Cancel the heartbeat task and await its exit. Preferred when the caller + /// wants the task to observably reach [`RenewalState::ShuttingDown`]. + pub async fn shutdown(mut self) { + self.cancel.cancel(); + if let Some(join) = self.join.take() { + _ = join.await; + } + } +} + +impl Drop for RenewalHandle { + fn drop(&mut self) { + // Safety-net cancel for early-return paths that drop the handle without + // `shutdown`. The task sees cancellation on its next `select!` poll and + // exits. Awaiting is not possible in `Drop`; the runtime detaches the + // held `JoinHandle`. If the caller already ran `shutdown`, `self.join` + // is `None` and the token is already cancelled — this is a no-op. + self.cancel.cancel(); + } +} + +/// State transitions emitted by the renewal heartbeat task. +/// +/// `Healthy` is steady state; `Lost` signals an UPDATE returned zero rows (peer +/// stole the lease or the TTL lapsed) and the holder should pre-empt itself; +/// `ShuttingDown` is the cooperative-cancel exit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RenewalState { + Healthy, + Lost, + ShuttingDown, +} diff --git a/gears/bss/libs/coord/src/lease/manager.rs b/gears/bss/libs/coord/src/lease/manager.rs new file mode 100644 index 000000000..4aa9f3e4f --- /dev/null +++ b/gears/bss/libs/coord/src/lease/manager.rs @@ -0,0 +1,278 @@ +//! `LeaseManager` — the acquire path for the distributed lease. +//! +//! `acquire` runs the acquire-or-steal inside a `SERIALIZABLE` retry +//! transaction so two workers cannot both observe a free slot and both insert; +//! the loser surfaces as [`CoordError::LeaseHeld`] (PK unique-violation on the +//! INSERT path, or zero-rows on the steal-UPDATE path). The retry helper +//! absorbs transient `40001` aborts transparently. +//! +//! Time anchors on the **DB clock**: the steal/renew filters use `WHERE +//! locked_until < NOW()` (DB-side), so a worker that misreads expiry under NTP +//! drift simply gets `rows_affected == 0` and returns `LeaseHeld` — a +//! false-negative on acquire, never a false-positive steal. The only worker +//! clock used is the `locked_until` written on the free-slot INSERT, and drift +//! there can only *shorten* our own lease versus the DB's view, never extend it +//! past it. + +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::{Expr, SimpleExpr}; +use sea_orm::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; +use toolkit_db::Db; +use toolkit_db::secure::{ + ScopeError, SecureEntityExt, SecureUpdateExt, TxConfig, is_unique_violation, secure_insert, +}; +use toolkit_security::AccessScope; +use uuid::Uuid; + +use super::entity as coord_leases; +use super::error::CoordError; +use super::guard::LeaseGuard; + +/// Acquire-side entry point. Cheap to construct (clones an `Arc` inside `Db`) +/// and safe to share behind an `Arc` across job ticks. +#[derive(Clone)] +pub struct LeaseManager { + db: Db, +} + +impl LeaseManager { + #[must_use] + pub fn new(db: Db) -> Self { + Self { db } + } + + /// Try to acquire the lease keyed by `key` with the given `ttl`. + /// + /// On success returns a [`LeaseGuard`] bound to a fresh `locked_by` UUID; + /// when a peer already holds a live slot returns [`CoordError::LeaseHeld`]. + /// Transient `40001` failures are retried internally; persistent DB + /// failures surface via [`CoordError::Db`]. + /// + /// # Errors + /// * [`CoordError::LeaseHeld`] — peer holds a live, non-expired slot. + /// * [`CoordError::Db`] — a DB failure not absorbed by the retry helper, or + /// an unsupported backend (only Postgres / `SQLite` are supported). + pub async fn acquire(&self, key: &str, ttl: Duration) -> Result { + let my_uuid = Uuid::new_v4(); + let ttl_secs = ttl_secs_i64(ttl); + let dialect = Dialect::from_engine(self.db.db_engine())?; + let key_owned = key.to_owned(); + + self.db + .transaction_with_retry::<(), CoordError, _, _>( + TxConfig::serializable(), + CoordError::db_err, + move |tx| { + // FnMut body — clone the captured key per attempt so a + // retried iteration owns a fresh `String`. `my_uuid` / + // `dialect` / `ttl_secs` are `Copy`. + let key = key_owned.clone(); + Box::pin(async move { + let existing = coord_leases::Entity::find() + .filter(coord_leases::Column::Key.eq(key.as_str())) + .secure() + .scope_with(&AccessScope::allow_all()) + .one(tx) + .await + .map_err(map_scope_err)?; + + match existing { + None => { + // Free slot — INSERT. `locked_until` is written + // worker-clock; the steal-path filter is + // DB-clock, so drift only shortens our own + // lease, never extends it past the DB's view. + let row = coord_leases::ActiveModel { + key: ActiveValue::Set(key.clone()), + locked_by: ActiveValue::Set(Some(my_uuid)), + // Checked: `chrono::Duration::seconds` and + // `DateTime + Duration` both panic on overflow, + // and `acquire(key, ttl)` is library-public. A + // pathological `ttl` clamps to the representable + // max instead of panicking; the DB-clock steal + // filter stays authoritative regardless. + locked_until: ActiveValue::Set( + chrono::TimeDelta::try_seconds(ttl_secs) + .and_then(|d| Utc::now().checked_add_signed(d)) + .unwrap_or(DateTime::::MAX_UTC), + ), + attempts: ActiveValue::Set(1), + }; + match secure_insert::( + row, + &AccessScope::allow_all(), + tx, + ) + .await + { + Ok(_) => Ok(()), + Err(ScopeError::Db(db)) if is_unique_violation(&db) => { + // A peer raced us between SELECT and + // INSERT and committed first; their row + // is live → `LeaseHeld`. + Err(CoordError::LeaseHeld) + } + Err(err) => Err(map_scope_err(err)), + } + } + Some(row) if row.locked_until <= Utc::now() => { + // Read side says expired; the UPDATE re-checks + // DB-side via `locked_until < NOW()`. If the row + // is in fact still live (drift), zero rows → + // `LeaseHeld`. + let result = coord_leases::Entity::update_many() + .col_expr( + coord_leases::Column::LockedBy, + Expr::value(my_uuid), + ) + .col_expr( + coord_leases::Column::LockedUntil, + dialect.ttl_expr(ttl_secs), + ) + .col_expr( + coord_leases::Column::Attempts, + Expr::col(coord_leases::Column::Attempts).add(1), + ) + .filter( + coord_leases::Column::Key + .eq(key.as_str()) + .and(dialect.expired_filter()), + ) + .secure() + .scope_with(&AccessScope::allow_all()) + .exec(tx) + .await + .map_err(map_scope_err)?; + if result.rows_affected == 0 { + // Belt: under PG SERIALIZABLE a concurrent + // steal normally aborts as 40001 (and + // retries); this arm is reached only when + // `locked_until` advanced between SELECT and + // UPDATE for unrelated reasons. Surface as + // `LeaseHeld` so the caller backs off. + tracing::warn!( + target: "coord.lease", + key = %key, + "lease steal-UPDATE matched zero rows after read-side classified expired; treating as held" + ); + return Err(CoordError::LeaseHeld); + } + Ok(()) + } + Some(_) => Err(CoordError::LeaseHeld), + } + }) + }, + ) + .await?; + + Ok(LeaseGuard::new( + self.db.clone(), + key.to_owned(), + my_uuid, + ttl, + )) + } +} + +/// Supported SQL dialects for the DB-clock lease arithmetic. +/// +/// `MySQL` is filtered out once in [`Dialect::from_engine`] (the only fallible +/// step), so the per-expr matches below stay exhaustive over two variants +/// **without a panic** — this is the one structural change from the AM +/// original, whose `engine: &str` expr helpers `panic!`-ed on an unknown +/// dialect (disallowed by this workspace's lints). +#[derive(Clone, Copy)] +pub(super) enum Dialect { + Postgres, + Sqlite, +} + +impl Dialect { + /// Classify the `toolkit_db` engine string. Unsupported backends surface as + /// [`CoordError::Db`] (a `Custom` `DbErr` the retry classifier treats as + /// non-retryable) instead of panicking. + pub(super) fn from_engine(engine: &str) -> Result { + match engine { + "postgres" => Ok(Self::Postgres), + "sqlite" => Ok(Self::Sqlite), + other => Err(CoordError::Db(toolkit_db::DbError::Sea( + sea_orm::DbErr::Custom(format!( + "coord.lease: unsupported db_engine {other:?} (only postgres/sqlite)" + )), + ))), + } + } + + /// `WHERE`-clause "DB-side now" — the steal / renew / fence filter anchor. + pub(super) fn now_expr(self) -> SimpleExpr { + match self { + Self::Postgres => Expr::cust("NOW()"), + Self::Sqlite => Expr::cust("datetime('now')"), + } + } + + /// The "lease expired" predicate (`locked_until < now`) for the steal filter, + /// DB-clock-anchored. On `SQLite` the column is wrapped in `datetime()` so a value + /// stored by the free-slot INSERT (a Rust `DateTime` → RFC-3339, `T`-separated) + /// and one stored by the steal/renew `ttl_expr` (DB-native, space-separated) both + /// parse to one canonical form before the compare — a raw TEXT `<` would + /// mis-order the `T`-form lexicographically and leave a same-day-expired lease + /// un-stolen. Postgres compares the native `TIMESTAMPTZ`. + pub(super) fn expired_filter(self) -> SimpleExpr { + match self { + Self::Postgres => Expr::col(coord_leases::Column::LockedUntil).lt(self.now_expr()), + Self::Sqlite => Expr::cust("datetime(locked_until) < datetime('now')"), + } + } + + /// The "lease still live" predicate (`locked_until > now`) for the fence + renew + /// filters — the same `datetime()` normalization on `SQLite` as + /// [`Self::expired_filter`]. + pub(super) fn live_filter(self) -> SimpleExpr { + match self { + Self::Postgres => Expr::col(coord_leases::Column::LockedUntil).gt(self.now_expr()), + Self::Sqlite => Expr::cust("datetime(locked_until) > datetime('now')"), + } + } + + /// "DB-side now + `ttl_secs`" — bumps `locked_until` on acquire-steal/renew. + pub(super) fn ttl_expr(self, ttl_secs: i64) -> SimpleExpr { + match self { + Self::Postgres => Expr::cust(format!("NOW() + INTERVAL '{ttl_secs} seconds'")), + Self::Sqlite => Expr::cust(format!("datetime('now', '+{ttl_secs} seconds')")), + } + } + + /// Epoch sentinel — `release` marks a row free without deleting it, so the + /// `attempts` forensic streak survives the row's lifetime. + pub(super) fn epoch_expr(self) -> SimpleExpr { + match self { + Self::Postgres => Expr::cust("TIMESTAMP 'epoch'"), + Self::Sqlite => Expr::cust("'1970-01-01 00:00:00+00:00'"), + } + } +} + +/// Lift a [`ScopeError`] from a secure-extension call into [`CoordError::Db`]. +/// `ScopeError::Db` carries the raw `DbErr` through; the scope-shape variants +/// are unexpected on the unscoped `coord_leases` table and surface as a +/// synthetic `Custom` `DbErr` so the retry classifier sees `None` and +/// propagates. +pub(super) fn map_scope_err(err: ScopeError) -> CoordError { + match err { + ScopeError::Db(db) => CoordError::Db(toolkit_db::DbError::Sea(db)), + other => CoordError::Db(toolkit_db::DbError::Sea(sea_orm::DbErr::Custom(format!( + "coord.lease: unexpected ScopeError on unscoped coord_leases table: {other:?}" + )))), + } +} + +/// Saturating `Duration` → seconds. Lease TTLs are minutes-scale, well within +/// `i64::MAX`; `try_from` + `unwrap_or` saturates pathological inputs without a +/// lossy `as` cast (and thus no cast-lint `#[allow]`). +pub(super) fn ttl_secs_i64(ttl: Duration) -> i64 { + i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX) +} diff --git a/gears/bss/libs/coord/src/lease/sqlite_tests.rs b/gears/bss/libs/coord/src/lease/sqlite_tests.rs new file mode 100644 index 000000000..9b2973696 --- /dev/null +++ b/gears/bss/libs/coord/src/lease/sqlite_tests.rs @@ -0,0 +1,244 @@ +//! In-crate `SQLite` tests for the lease primitive. They live in-crate (not in +//! `tests/`) so they can read the `pub(crate)` `coord_leases` row directly to +//! assert `locked_by` / `attempts`, which the public API does not surface. +//! +//! Each test opens its own in-memory `SQLite` `Db` (a fresh, isolated namespace), +//! runs the `coord_leases` migration, then drives the real `LeaseManager` / +//! `LeaseGuard`. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::secure::{SecureEntityExt, SecureUpdateExt}; +use toolkit_db::{ConnectOpts, Db, connect_db}; +use toolkit_security::AccessScope; + +use super::entity as coord_leases; +use super::error::CoordError; +use super::manager::LeaseManager; + +/// Fresh in-memory `SQLite` `Db` with the `coord_leases` table migrated in. +async fn setup() -> Db { + let db = connect_db("sqlite::memory:", ConnectOpts::default()) + .await + .expect("connect in-memory sqlite"); + run_migrations_for_testing( + &db, + vec![Box::new(crate::migration::Migration::unqualified())], + ) + .await + .expect("run coord_leases migration"); + db +} + +/// Read the lease row by key (unscoped — `allow_all`, as the primitive does). +async fn read_row(db: &Db, key: &str) -> Option { + let conn = db.conn().expect("conn"); + coord_leases::Entity::find() + .filter(coord_leases::Column::Key.eq(key)) + .secure() + .scope_with(&AccessScope::allow_all()) + .one(&conn) + .await + .expect("read lease row") +} + +/// Force a row's `locked_until` into the past (epoch) so the next acquire steals +/// it — deterministic, unlike sleeping past a second-precision TTL. +async fn force_expire(db: &Db, key: &str) { + let conn = db.conn().expect("conn"); + let epoch: DateTime = DateTime::from_timestamp(0, 0).expect("epoch"); + coord_leases::Entity::update_many() + .col_expr(coord_leases::Column::LockedUntil, Expr::value(epoch)) + .filter(coord_leases::Column::Key.eq(key)) + .secure() + .scope_with(&AccessScope::allow_all()) + .exec(&conn) + .await + .expect("force-expire"); +} + +/// Force a row's `locked_until` to a recent past time on the SAME UTC day, written +/// the way the free-slot INSERT writes it (a Rust `DateTime` → RFC-3339, `T`-form). +/// Unlike [`force_expire`]'s epoch — whose `1970-…` date sorts below `datetime('now')` +/// even under a buggy lexicographic TEXT compare — a same-day past time exercises the +/// `T`-vs-space format normalization. +async fn force_expire_same_day(db: &Db, key: &str) { + let conn = db.conn().expect("conn"); + let past = Utc::now() - chrono::TimeDelta::seconds(30); + coord_leases::Entity::update_many() + .col_expr(coord_leases::Column::LockedUntil, Expr::value(past)) + .filter(coord_leases::Column::Key.eq(key)) + .secure() + .scope_with(&AccessScope::allow_all()) + .exec(&conn) + .await + .expect("force-expire same-day"); +} + +/// Regression: a lease whose `locked_until` is stored in +/// RFC-3339 (`T`-separated, as the free-slot INSERT writes it) and is expired but +/// still on the CURRENT UTC day must be reclaimable. A raw lexicographic TEXT compare +/// against `datetime('now')` (space-separated) would mis-order the `T`-form and leave +/// the dead lease un-stolen until the UTC date rolls. +#[tokio::test] +async fn same_day_expired_lease_is_stolen() { + let db = setup().await; + let mgr = LeaseManager::new(db.clone()); + + let first = mgr + .acquire("job", Duration::from_mins(1)) + .await + .expect("acquire"); + let first_holder = first.locked_by(); + force_expire_same_day(&db, "job").await; + + let second = mgr + .acquire("job", Duration::from_mins(1)) + .await + .expect("a same-day-expired lease must be stealable"); + assert_ne!( + second.locked_by(), + first_holder, + "the steal installs a fresh holder" + ); + assert_eq!( + read_row(&db, "job").await.expect("row").attempts, + 2, + "the steal bumps the forensic attempts streak" + ); +} + +#[tokio::test] +async fn acquire_blocks_a_peer_then_release_frees_and_resets_attempts() { + let db = setup().await; + let mgr = LeaseManager::new(db.clone()); + + let guard = mgr + .acquire("job", Duration::from_mins(1)) + .await + .expect("acquire free slot"); + assert_eq!( + read_row(&db, "job").await.expect("row").locked_by, + Some(guard.locked_by()), + "row records the holder" + ); + + // A peer cannot acquire while the slot is live. + let peer = mgr.acquire("job", Duration::from_mins(1)).await; + assert!( + matches!(peer, Err(CoordError::LeaseHeld)), + "live slot ⇒ LeaseHeld, got {peer:?}" + ); + + // Clean release frees the slot and resets the forensic counter. + guard.release().await.expect("release"); + let row = read_row(&db, "job").await.expect("row"); + assert_eq!(row.locked_by, None, "freed"); + assert_eq!(row.attempts, 0, "release resets attempts"); + + // Freed slot is re-acquirable. + let again = mgr + .acquire("job", Duration::from_mins(1)) + .await + .expect("re-acquire after release"); + assert_eq!( + read_row(&db, "job").await.expect("row").locked_by, + Some(again.locked_by()) + ); +} + +#[tokio::test] +async fn expired_lease_is_stolen_and_bumps_attempts() { + let db = setup().await; + let mgr = LeaseManager::new(db.clone()); + + let first = mgr + .acquire("job", Duration::from_mins(1)) + .await + .expect("acquire"); + assert_eq!( + read_row(&db, "job").await.unwrap().attempts, + 1, + "INSERT seeds attempts=1" + ); + let first_holder = first.locked_by(); + + // Expire it, then a fresh acquire (any worker) steals the slot. + force_expire(&db, "job").await; + let stealer = mgr + .acquire("job", Duration::from_mins(1)) + .await + .expect("expired slot is stealable"); + + let row = read_row(&db, "job").await.expect("row"); + assert_eq!( + row.locked_by, + Some(stealer.locked_by()), + "new holder owns the row" + ); + assert_ne!(stealer.locked_by(), first_holder, "a different worker id"); + assert_eq!(row.attempts, 2, "steal bumps attempts 1 -> 2"); +} + +#[tokio::test] +async fn release_with_retry_preserves_the_attempts_streak() { + let db = setup().await; + let mgr = LeaseManager::new(db.clone()); + + // Drive attempts to 2 via a steal. + let _first = mgr + .acquire("job", Duration::from_mins(1)) + .await + .expect("acquire"); + force_expire(&db, "job").await; + let stealer = mgr + .acquire("job", Duration::from_mins(1)) + .await + .expect("steal"); + assert_eq!(read_row(&db, "job").await.unwrap().attempts, 2); + + // The recoverable-failure release frees the slot but keeps the streak. + stealer + .release_with_retry() + .await + .expect("release_with_retry"); + let row = read_row(&db, "job").await.expect("row"); + assert_eq!(row.locked_by, None, "freed"); + assert_eq!(row.attempts, 2, "release_with_retry preserves attempts"); +} + +#[tokio::test] +async fn renew_holds_then_reports_lost_after_a_peer_steal() { + let db = setup().await; + let mgr = LeaseManager::new(db.clone()); + + let guard = mgr + .acquire("job", Duration::from_mins(1)) + .await + .expect("acquire"); + // A live lease renews fine. + guard + .renew(Duration::from_mins(2)) + .await + .expect("renew a held lease"); + + // A peer steals it out from under us (expire + re-acquire). + force_expire(&db, "job").await; + let _peer = LeaseManager::new(db.clone()) + .acquire("job", Duration::from_mins(1)) + .await + .expect("peer steals expired lease"); + + // The original holder's renew now matches zero rows ⇒ LeaseLost. + let lost = guard.renew(Duration::from_mins(1)).await; + assert!( + matches!(lost, Err(CoordError::LeaseLost)), + "stolen lease ⇒ LeaseLost, got {lost:?}" + ); +} diff --git a/gears/bss/libs/coord/src/lib.rs b/gears/bss/libs/coord/src/lib.rs new file mode 100644 index 000000000..18a68e821 --- /dev/null +++ b/gears/bss/libs/coord/src/lib.rs @@ -0,0 +1,32 @@ +//! `coord` — shared BSS coordination primitives. +//! +//! Hosts a DB-backed distributed **lease** (a single-active job/run guard), +//! ported from the account-management lease pattern and generalized for reuse +//! by any BSS gear. +//! +//! It works **entirely within the existing `SecureORM` API**: the `coord_leases` +//! row is unscoped process-coordination state (`#[secure(no_tenant, …)]`) +//! accessed via [`toolkit_security::AccessScope::allow_all`], exactly as AM's +//! `am_leases` does — so adopting it needs **no toolkit changes**. +//! +//! # Usage +//! +//! A gear adds [`migration::Migration`] to its `Migrator`, then builds a +//! [`LeaseManager`] over its `Db` and brackets a job: +//! +//! ```ignore +//! let mgr = coord::LeaseManager::new(db.clone()); +//! match mgr.acquire("recognition-run:{tenant}:{period}", ttl).await { +//! Ok(guard) => { /* run the job */ guard.release().await?; } +//! Err(coord::CoordError::LeaseHeld) => { /* a peer is already running */ } +//! Err(e) => return Err(e.into()), +//! } +//! ``` +//! +//! `LeaseHeld` means a peer already holds the slot (single-active); the consumer +//! maps it onto its own "already running" outcome. + +pub mod lease; +pub mod migration; + +pub use lease::{AckError, CoordError, LeaseGuard, LeaseManager, RenewalHandle, RenewalState}; diff --git a/gears/bss/libs/coord/src/migration.rs b/gears/bss/libs/coord/src/migration.rs new file mode 100644 index 000000000..6b5f35f33 --- /dev/null +++ b/gears/bss/libs/coord/src/migration.rs @@ -0,0 +1,10 @@ +//! The `coord_leases` table migration. +//! +//! A consuming gear adds [`Migration`] to its own `MigratorTrait::migrations()` +//! vec (migrations run in vec order, so place it after the gear's own). The +//! migration name is `m0001_create_coord_leases` (derived from the defining +//! module), stable regardless of where it sits in the host chain. + +pub mod m0001_create_coord_leases; + +pub use m0001_create_coord_leases::Migration; diff --git a/gears/bss/libs/coord/src/migration/m0001_create_coord_leases.rs b/gears/bss/libs/coord/src/migration/m0001_create_coord_leases.rs new file mode 100644 index 000000000..c8dafb09c --- /dev/null +++ b/gears/bss/libs/coord/src/migration/m0001_create_coord_leases.rs @@ -0,0 +1,138 @@ +//! Migration — the `coord_leases` distributed-lease table (see [`crate::lease`]). +//! +//! Schema rationale: +//! * `key` is the PRIMARY KEY (no synthetic id) — saves an index and makes +//! adding a second coordination domain a zero-schema change. +//! * `locked_until` defaults to the Unix epoch so the steal filter +//! `WHERE locked_until < NOW()` works uniformly whether or not the row was +//! ever held. +//! * `attempts` defaults to `0`; the acquire path bumps it on every steal, so a +//! flapping cluster surfaces as a high-water value (alert on `attempts >= 3`). +//! +//! **PG schema qualification.** A consuming gear that keeps its domain DDL in a +//! named schema (e.g. the BSS gears' `bss`) constructs this with +//! [`Migration::in_schema`] so the PG `CREATE TABLE` is **qualified** +//! (`bss.coord_leases`) and lands in that schema regardless of the connection's +//! `search_path` order — matching the gear's other qualified domain tables (an +//! unqualified `CREATE TABLE` would otherwise land in whichever schema the +//! `search_path` lists first, e.g. `public`). [`Migration::unqualified`] creates +//! a bare `coord_leases` (`SQLite` — single namespace — or a single-schema PG +//! setup that resolves it via `search_path`). +//! +//! **Self-contained schema.** The toolkit migration runner applies migrations in +//! NAME order, and coord's `m0001_…` name sorts BEFORE a consumer gear's own +//! "create schema" migration — so coord runs first and cannot assume the schema +//! already exists. [`Migration::in_schema`]'s `up` therefore issues +//! `CREATE SCHEMA IF NOT EXISTS {schema}` before the `CREATE TABLE`, making it +//! safe wherever it lands in the run order (idempotent with the consumer's own +//! `IF NOT EXISTS` schema migration that runs afterwards). + +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::ConnectionTrait; + +const MYSQL_NOT_SUPPORTED: &str = + "coord migrations: MySQL is not supported (PostgreSQL/SQLite only)"; + +/// The `coord_leases` table migration. Carries an optional PG schema to qualify +/// the table into (see the module doc); `SQLite` is always single-namespace. +#[derive(DeriveMigrationName)] +pub struct Migration { + /// `Some("bss")` ⇒ PG `CREATE TABLE bss.coord_leases`; `None` ⇒ unqualified + /// (`search_path`-resolved). `SQLite` ignores it (single namespace). + schema: Option<&'static str>, +} + +impl Migration { + /// Qualify the PG table into `schema` (e.g. `"bss"`): `CREATE TABLE + /// {schema}.coord_leases` lands there regardless of `search_path` order. + #[must_use] + pub fn in_schema(schema: &'static str) -> Self { + Self { + schema: Some(schema), + } + } + + /// Unqualified `coord_leases` — resolves via the connection's `search_path` + /// (`SQLite`, or a single-schema PG setup). + #[must_use] + pub fn unqualified() -> Self { + Self { schema: None } + } + + /// The (optionally schema-qualified) PG table reference. + fn pg_table(&self) -> String { + match self.schema { + Some(s) => format!("{s}.coord_leases"), + None => "coord_leases".to_owned(), + } + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let conn = manager.get_connection(); + + // For a schema-qualified PG table, ensure the schema exists FIRST. The + // toolkit migration runner applies migrations in NAME order, and coord's + // `m0001_…` name sorts BEFORE a consumer gear's own "create schema" + // migration — so coord runs first and cannot assume the schema already + // exists. `CREATE SCHEMA IF NOT EXISTS` is idempotent with the consumer's + // own (also-`IF NOT EXISTS`) schema migration that runs afterwards. + if backend == sea_orm::DatabaseBackend::Postgres + && let Some(schema) = self.schema + { + conn.execute_unprepared(&format!("CREATE SCHEMA IF NOT EXISTS {schema};")) + .await?; + } + + let sql = match backend { + // Qualified into the gear's schema (e.g. `bss.coord_leases`) so the + // table lands there regardless of `search_path` order or which + // migration created the schema. + sea_orm::DatabaseBackend::Postgres => format!( + "CREATE TABLE {} ( \ + key TEXT PRIMARY KEY, \ + locked_by UUID NULL, \ + locked_until TIMESTAMPTZ NOT NULL DEFAULT 'epoch', \ + attempts INTEGER NOT NULL DEFAULT 0 \ + );", + self.pg_table() + ), + // SQLite has no native UUID / TIMESTAMPTZ and a single namespace: + // sea_orm serialises `Uuid` to canonical TEXT and `DateTime` to + // ISO-8601 TEXT, so both columns are `TEXT`; the epoch default uses + // the literal form the `DateTime` mapper accepts on read. + sea_orm::DatabaseBackend::Sqlite => "CREATE TABLE coord_leases ( \ + key TEXT PRIMARY KEY, \ + locked_by TEXT NULL, \ + locked_until TEXT NOT NULL DEFAULT '1970-01-01 00:00:00+00:00', \ + attempts INTEGER NOT NULL DEFAULT 0 \ + );" + .to_owned(), + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Custom(MYSQL_NOT_SUPPORTED.to_owned())); + } + }; + + conn.execute_unprepared(&sql).await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let backend = manager.get_database_backend(); + let table = match backend { + sea_orm::DatabaseBackend::Postgres => self.pg_table(), + sea_orm::DatabaseBackend::Sqlite => "coord_leases".to_owned(), + sea_orm::DatabaseBackend::MySql => { + return Err(DbErr::Custom(MYSQL_NOT_SUPPORTED.to_owned())); + } + }; + manager + .get_connection() + .execute_unprepared(&format!("DROP TABLE IF EXISTS {table};")) + .await?; + Ok(()) + } +} diff --git a/testing/e2e/gears/bss/__init__.py b/testing/e2e/gears/bss/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/testing/e2e/gears/bss/ledger/__init__.py b/testing/e2e/gears/bss/ledger/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/testing/e2e/gears/bss/ledger/conftest.py b/testing/e2e/gears/bss/ledger/conftest.py new file mode 100644 index 000000000..09fd4bbb5 --- /dev/null +++ b/testing/e2e/gears/bss/ledger/conftest.py @@ -0,0 +1,54 @@ +"""E2E fixtures for the bss-ledger gear. + +The ledger REST surface (``/bss-ledger/v1``) is an opt-in gear that is NOT yet +wired into ``cf-gears-example-server`` (no dependency + no ``registered_gears`` +entry). Until those routes are mounted, the whole module skips gracefully — we +do NOT want red failures for endpoints that intentionally are not served here. +Once the gear is wired, the probe below starts returning non-404 and every seam +test runs for real (mirrors the file-storage module's staging pattern). +""" + +import os + +import httpx +import pytest + +REQUEST_TIMEOUT = 5.0 + +# The gear's REST base path (design §3.3 / the axum routers). Every ledger route +# is a literal full path under this prefix. +API_BASE = "/bss-ledger/v1" + + +@pytest.fixture(scope="session", autouse=True) +def require_ledger_mounted(): + """Skip the whole module unless the ledger control surface is reachable. + + Keys on ``GET /bss-ledger/v1/accounts`` (the read-only chart-of-accounts + surface — the simplest authenticated GET). A 404 means the gear's routes are + not mounted (it is an opt-in gear that may not be built into this server); a + connection error means the server is down. Either way: skip, don't fail. + + The probe is **authenticated**: the API gateway returns 401 (not 404) for an + unauthenticated request to any unknown path, so an auth-less probe could not + tell "gear absent" (skip) from "auth required" (present). With a valid token + an unknown route yields a clean 404. + """ + base_url = os.getenv("E2E_BASE_URL", "http://localhost:8086") + token = os.getenv("E2E_AUTH_TOKEN", "e2e-token-tenant-a") + url = f"{base_url}{API_BASE}/accounts" + try: + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.get(url, headers={"Authorization": f"Bearer {token}"}) + except httpx.HTTPError as exc: + pytest.skip(f"cf-gears-server not reachable at {base_url}: {exc}") + if r.status_code == 404: + pytest.skip( + "bss-ledger REST endpoints are not mounted — the gear is an opt-in " + "gear not built into this server (no registered_gears entry)." + ) + + +@pytest.fixture +def api_base(): + return API_BASE diff --git a/testing/e2e/gears/bss/ledger/test_ledger_seams.py b/testing/e2e/gears/bss/ledger/test_ledger_seams.py new file mode 100644 index 000000000..009ac1794 --- /dev/null +++ b/testing/e2e/gears/bss/ledger/test_ledger_seams.py @@ -0,0 +1,183 @@ +"""Black-box E2E seam tests for the bss-ledger gear. + +Drives the running ``cf-gears-server`` over HTTP (no in-process access). The +whole module is skipped by ``conftest.require_ledger_mounted`` until the gear's +routes are mounted, so these assert reachability + the coarse REST contract +(auth gate, scoped not-found, route presence) rather than deep payloads — the +full posting/allocation behaviour is covered by the crate's Postgres +integration tests. +""" + +import uuid + +import httpx +import pytest + +REQUEST_TIMEOUT = 10.0 + +# The tenant the standard E2E token (`e2e-token-tenant-a`) authenticates as — +# its `subject_tenant_id` in config/e2e-local.yaml's static-authn-plugin. Reads +# are tenant-scoped, and several take `tenant_id` as a required query param, so +# the seam tests pass this (in-scope) id: an absent resource then reads as a +# clean 404 (not a scope miss), and list/read surfaces resolve to the caller's +# own — possibly empty — data. +TENANT_A = "00000000-df51-5b42-9538-d2b56b7ee953" + +# Write surfaces (POST) whose route presence + auth gate we probe. Full paths +# under the gear's `/bss-ledger/v1` base (design §3.3 / the axum routers). +WRITE_PATHS = [ + "/journal-entries", + "/payments", + "/credit-notes", + "/debit-notes", + "/manual-adjustments", + "/refunds", + "/recognition-runs", +] + +# Read-by-id surfaces (GET) — an absent/foreign id must read as a scoped 404. +GET_BY_ID_PATHS = [ + "/journal-entries/{id}", + "/recognition-schedules/{id}", + "/refunds/{id}", + "/credit-notes/{id}", +] + + +def test_accounts_read_is_reachable(base_url, auth_headers, api_base): + """The chart-of-accounts read surface is mounted and returns JSON. + + (The session probe already proved non-404; here we assert the authenticated + read succeeds and yields a JSON body — the paginated chart envelope.) + """ + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.get(f"{base_url}{api_base}/accounts?tenant_id={TENANT_A}", headers=auth_headers) + assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}" + # Either the canonical page envelope or a bare list — both are valid JSON. + assert isinstance(r.json(), (dict, list)) + + +def test_unknown_journal_entry_is_404(base_url, auth_headers, api_base): + """An absent (or foreign-scoped) journal entry reads as 404, not 500/leak. + + A random entry id the caller's tenant never posted must be indistinguishable + from one outside its authorized subtree — the same 404 either way (no + existence leak / BOLA). + """ + entry_id = uuid.uuid4() + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.get( + f"{base_url}{api_base}/journal-entries/{entry_id}?tenant_id={TENANT_A}", + headers=auth_headers, + ) + assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}" + + +def test_post_journal_entry_requires_auth(base_url, api_base): + """Posting without a bearer token is rejected by the gateway auth gate (401). + + No ``auth_headers`` here on purpose: the write surface must never accept an + unauthenticated post. + """ + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.post(f"{base_url}{api_base}/journal-entries", json={}) + assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}" + + +def test_provisioning_route_is_present(base_url, auth_headers, api_base): + """The seller-provisioning route exists (a bad body is a 4xx, never a 404). + + An empty body fails request validation / the seller predicate, so we expect + some client error — the point is only that the route is MOUNTED (not 404), + the one Foundation endpoint the design marks externally exposed. + """ + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.post( + f"{base_url}{api_base}/provisioning", + headers=auth_headers, + json={}, + ) + assert r.status_code != 404, f"provisioning route must be mounted: {r.text}" + assert 400 <= r.status_code < 500, ( + f"an empty provisioning body should be a 4xx, got {r.status_code}: {r.text}" + ) + + +def test_balances_read_is_reachable(base_url, auth_headers, api_base): + """The current-balances read surface is mounted (no server error). + + A bare read may 200 (empty) or 400 (a required filter is missing), but never + 404 (route absent) or 5xx (server fault). + """ + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.get(f"{base_url}{api_base}/balances?tenant_id={TENANT_A}", headers=auth_headers) + assert r.status_code != 404, f"balances route must be mounted: {r.text}" + assert r.status_code < 500, f"balances read must not 5xx: {r.status_code} {r.text}" + + +def test_journal_entries_list_is_reachable(base_url, auth_headers, api_base): + """The journal-entries list surface returns a JSON page envelope.""" + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.get(f"{base_url}{api_base}/journal-entries?tenant_id={TENANT_A}", headers=auth_headers) + assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}" + assert isinstance(r.json(), (dict, list)) + + +@pytest.mark.parametrize("path", WRITE_PATHS) +def test_write_routes_require_auth(base_url, api_base, path): + """Every write surface is behind the gateway auth gate — no token ⇒ 401. + + No ``auth_headers`` on purpose: an unauthenticated POST to a write route + must never be accepted. + """ + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.post(f"{base_url}{api_base}{path}", json={}) + assert r.status_code == 401, ( + f"{path}: expected 401 without a token, got {r.status_code}: {r.text}" + ) + + +@pytest.mark.parametrize("path", WRITE_PATHS) +def test_write_routes_are_mounted(base_url, auth_headers, api_base, path): + """Every write surface is mounted: an empty body is a 4xx, never a 404/5xx. + + Proves the route exists (request validation / a domain guard rejects the + empty body) without asserting the exact rejection code. + """ + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.post(f"{base_url}{api_base}{path}", headers=auth_headers, json={}) + assert r.status_code != 404, f"{path}: route must be mounted: {r.text}" + assert 400 <= r.status_code < 500, ( + f"{path}: an empty body should be a 4xx, got {r.status_code}: {r.text}" + ) + + +@pytest.mark.parametrize("path", GET_BY_ID_PATHS) +def test_get_by_id_absent_is_404(base_url, auth_headers, api_base, path): + """A random id on each read-by-id surface reads as a scoped 404. + + Absent and foreign-scoped are indistinguishable — the same 404 either way + (no existence leak / BOLA). + """ + url = f"{base_url}{api_base}{path.format(id=uuid.uuid4())}?tenant_id={TENANT_A}" + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.get(url, headers=auth_headers) + assert r.status_code == 404, f"{path}: expected 404, got {r.status_code}: {r.text}" + + +def test_not_found_is_problem_json(base_url, auth_headers, api_base): + """The gear renders a not-found as RFC 9457 ``application/problem+json``. + + Checks the error envelope the gear's canonical-error middleware produces + (content type + a ``status`` of 404 in the body), the machine-readable shape + consumers match on. + """ + url = f"{base_url}{api_base}/journal-entries/{uuid.uuid4()}?tenant_id={TENANT_A}" + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + r = client.get(url, headers=auth_headers) + assert r.status_code == 404 + assert "problem+json" in r.headers.get("content-type", ""), ( + f"expected application/problem+json, got {r.headers.get('content-type')!r}" + ) + body = r.json() + assert body.get("status") == 404, f"problem body should carry status 404: {body}" From befa68f7197cad39544a636a93f092d5951ca7ae Mon Sep 17 00:00:00 2001 From: Diffora Date: Tue, 7 Jul 2026 10:47:47 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(bss-ledger):=20close=20review=20finding?= =?UTF-8?q?s=20=E2=80=94=20lease=20fence/clock,=20fx=20rate=20sign,=20pani?= =?UTF-8?q?c,=20actor=20spoof,=20boundary=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address findings from the bss/ledger branch review (codex CLI + review agents), verified with Postgres tests. coord lease fence (HIGH, reproduced): - `with_ack_in_tx` fenced with a snapshot SELECT under SERIALIZABLE: a peer steal committed after the ack tx's snapshot stayed invisible and the commit was not guaranteed to abort (a lone rw-antidependency does not trip SSI), so a stolen lease could ack successfully. - Fence is now a no-op self-touch UPDATE (ww-conflict ⇒ first-updater-wins 40001 on a peer steal) with a wall-clock `live_filter_clock` for a lapsed-but-unstolen lease. New `tests/postgres_fence.rs` reproduces the scenario: red before the fix, green after. coord lease acquire clock (MEDIUM): - The free-slot INSERT wrote `locked_until` from the worker clock while steal/renew bump it on the DB clock; a worker whose clock ran ahead stamped an expiry into the future, so peers (comparing against DB `NOW()`) saw the lease live long past its TTL and were blocked. Acquire now INSERTs the epoch sentinel (no worker clock) then claims the row it created with the shared DB-clock steal-UPDATE (`claim_expired_slot`), so no worker-clock value ever lands in `locked_until`. ledger FX rate sign (HIGH): - A non-positive FX rate could reach the unrealized-revaluation post path: `translate_amount` (single-amount) lacked the `rate_micro > 0` guard `translate_entry` already has, the provider-sync upsert and the raw store had no gate (only the REST ingest DTO validated), and there was no DB CHECK. A zero/negative provider quote would zero out or flip the sign of the translated position and post a wrong FX entry. Guarded at four layers: `translate_amount`, `RateSource::resolve` (skip poisoned rows so a valid fallback wins), provider-sync upsert (drop + warn, never poison the store), and a `CHECK (rate_micro > 0)` on `ledger_fx_rate` / `ledger_fx_rate_snapshot` (pg + sqlite). ledger (HIGH): - Reject negative invoice item / tax amounts at the DTO boundary — a negative `amount_minor_ex_tax` drove `deferred.clamp(0, amount)` into `min > max` (panic); `.max(0)` belt in the builder as defense-in-depth. - Stamp `posted_by_actor_id` from `ctx.subject_id()` in the local client so an in-process ClientHub caller cannot spoof the audit actor. ledger (MEDIUM): - Functional-FX columns must be a consistent pair on the direct post path (`Some`/`None` mismatch ⇒ 400) — no RateLocker there to derive one. - Route every request DTO `currency` field through `check_currency_code`. - Reject a wholly-empty invoice (no items and no tax). - Cap unbounded free-text at the boundary — `reason` (4096) / `reason_code` (64) — for both the DTOs that lower via `into_domain` and the four handler-lowered DTOs (reversal, annotation, reidentify, audit-pack) via a `pub(crate) validate()` the handler calls. e2e: - Rewrite the BOLA/no-existence-leak tests to genuinely cross tenants (seed a real entry as one tenant, assert another gets the same 404), with an owner-200 baseline. Refresh stale conftest docs (the gear is already wired into cf-gears-example-server). Verified: coord sqlite (5) + fence pg test green; ledger fx unit tests pass; 15 Postgres tests green over Docker (fence, migration-idempotency, and the revaluation / allocate / invoice-post / refund / chargeback / settlement-return FX suites); clippy clean on both crates. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + .../bss/ledger/ledger/src/api/local_client.rs | 22 +- gears/bss/ledger/ledger/src/api/rest/audit.rs | 4 + gears/bss/ledger/ledger/src/api/rest/dto.rs | 126 +++++++++++ .../ledger/ledger/src/api/rest/dto_tests.rs | 126 +++++++++++ .../ledger/src/api/rest/journal_entries.rs | 4 + .../ledger/ledger/src/domain/fx/translate.rs | 14 +- .../ledger/src/domain/fx/translate_tests.rs | 17 ++ .../ledger/src/domain/invoice/builder.rs | 9 +- .../ledger/ledger/src/infra/fx/rate_source.rs | 8 + .../ledger/ledger/src/infra/jobs/rate_sync.rs | 17 ++ .../ledger/ledger/src/infra/jobs/tieout.rs | 51 ----- .../ledger/src/infra/jobs/tieout_tests.rs | 54 ++++- .../m20260627_000026_create_fx_rate_tables.rs | 8 +- gears/bss/libs/coord/Cargo.toml | 16 +- gears/bss/libs/coord/src/lease/guard.rs | 45 ++-- gears/bss/libs/coord/src/lease/manager.rs | 147 ++++++++----- gears/bss/libs/coord/tests/postgres_fence.rs | 199 ++++++++++++++++++ testing/e2e/gears/bss/ledger/conftest.py | 170 ++++++++++++++- .../e2e/gears/bss/ledger/test_ledger_seams.py | 84 ++++++-- 20 files changed, 972 insertions(+), 150 deletions(-) create mode 100644 gears/bss/libs/coord/tests/postgres_fence.rs diff --git a/Cargo.lock b/Cargo.lock index 35345ac23..26a9b6c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3192,6 +3192,7 @@ dependencies = [ "chrono", "sea-orm", "sea-orm-migration", + "testcontainers-modules", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/gears/bss/ledger/ledger/src/api/local_client.rs b/gears/bss/ledger/ledger/src/api/local_client.rs index a135973f3..24d2c9a10 100644 --- a/gears/bss/ledger/ledger/src/api/local_client.rs +++ b/gears/bss/ledger/ledger/src/api/local_client.rs @@ -27,6 +27,7 @@ use toolkit_security::SecurityContext; use uuid::Uuid; use crate::api::rest::error::authz_error_to_canonical; +use crate::domain::error::DomainError; use crate::domain::model::{EntryRecord, LineRecord, NewEntry, NewLine}; use crate::infra::currency_scale::CurrencyScaleResolver; use crate::infra::period_close::PeriodCloseService; @@ -286,7 +287,12 @@ impl LedgerClientV1 for LedgerLocalClient { posted_at_utc, effective_at: entry.effective_at, origin: ORIGIN_SYSTEM.to_owned(), - posted_by_actor_id: entry.posted_by_actor_id, + // Audit actor is the authenticated subject, stamped server-side — + // NEVER the caller-supplied `entry.posted_by_actor_id`. The REST DTO + // already lowers with `ctx.subject_id()`, but an in-process ClientHub + // caller could otherwise attribute a post to an arbitrary subject; + // clamp it to the security context here so no path can spoof it. + posted_by_actor_id: ctx.subject_id(), correlation_id: entry.correlation_id, rounding_evidence: serde_json::Value::Null, // Slice 5: this direct-post path is same-currency in v1 (no FX lock). @@ -295,6 +301,20 @@ impl LedgerClientV1 for LedgerLocalClient { let mut new_lines: Vec = Vec::with_capacity(entry.lines.len()); for line in entry.lines { + // Functional FX columns must be a consistent pair on this direct + // in-process post path: there is no RateLocker here to derive one + // from the other (`rate_snapshot_ref` is `None`), so a `Some` amount + // with a `None` currency (or vice versa) would persist a + // half-populated, unauditable dual column the projector then reads + // inconsistently. Reject the mismatch as a 400. + if line.functional_amount_minor.is_some() != line.functional_currency.is_some() { + return Err(DomainError::InvalidRequest( + "functional_amount_minor and functional_currency must both be set or both be \ + null on a direct post" + .to_owned(), + ) + .into()); + } let scale = self .resolver .resolve(&scope, entry.tenant_id, &line.currency) diff --git a/gears/bss/ledger/ledger/src/api/rest/audit.rs b/gears/bss/ledger/ledger/src/api/rest/audit.rs index dba07f444..6cca02fba 100644 --- a/gears/bss/ledger/ledger/src/api/rest/audit.rs +++ b/gears/bss/ledger/ledger/src/api/rest/audit.rs @@ -642,6 +642,8 @@ async fn audit_reidentify( CanonicalJson(body): CanonicalJson, ) -> Result, CanonicalError> { let ctx = require_authenticated(extension_ctx)?; + // Cap the machine `reason_code` at the boundary (400) before any work. + body.validate().map_err(CanonicalError::from)?; let caller_tenant = ctx.subject_tenant_id(); // The free-text reason is the `X-Investigation-Reason` header (§5), the same // source as the other three cross-tenant endpoints; `reason_code` is in the @@ -728,6 +730,8 @@ async fn audit_pack( CanonicalJson(body): CanonicalJson, ) -> Result { let ctx = require_authenticated(extension_ctx)?; + // Cap the machine `reason_code` at the boundary (400) before any work. + body.validate().map_err(CanonicalError::from)?; // PEP `(entry, audit_read)` gate against the caller's HOME tenant (a deny is a // 403 here, before any read). let _home_scope = audit_read_scope(&enforcer, &ctx).await?; diff --git a/gears/bss/ledger/ledger/src/api/rest/dto.rs b/gears/bss/ledger/ledger/src/api/rest/dto.rs index a920d939b..68195587f 100644 --- a/gears/bss/ledger/ledger/src/api/rest/dto.rs +++ b/gears/bss/ledger/ledger/src/api/rest/dto.rs @@ -249,6 +249,20 @@ impl InvoiceItemDto { /// when the `recognition` block carries an invalid timing (see /// [`RecognitionInputDto::into_domain`]). fn into_domain(self) -> Result { + // Money invariant: an ex-tax line amount is non-negative. A negative + // value is not just wrong accounting — it drives the recognition split's + // `deferred.clamp(0, amount)` into `min > max` (a panic) downstream, so + // reject it here at the wire boundary as a 400, never deeper. + if self.amount_minor_ex_tax < 0 { + return Err(DomainError::InvalidRequest(format!( + "invoice item amount_minor_ex_tax must be non-negative, got {}", + self.amount_minor_ex_tax + ))); + } + // The line currency is stamped verbatim on the journal line and never + // re-parsed downstream; screen a malformed code here (a 400) rather than let + // it silently match zero currency-scale rows / land in a persisted column. + check_currency_code("currency", &self.currency)?; let catalog_class = parse_opt_account_class("catalog_class", self.catalog_class)?; let contract_class = parse_opt_account_class("contract_class", self.contract_class)?; let recognition = self @@ -436,7 +450,29 @@ impl PostInvoiceRequestDto { .into_iter() .map(InvoiceItemDto::into_domain) .collect::, DomainError>>()?; + // Tax amounts are non-negative for the same reason (the AR gross folds + // `Σ items + Σ tax`; a negative tax line understates the receivable). + if let Some(bad) = self.tax.iter().find(|t| t.amount_minor < 0) { + return Err(DomainError::InvalidRequest(format!( + "tax amount_minor must be non-negative, got {}", + bad.amount_minor + ))); + } + // Each tax component's currency is stamped on its own line but lowers via a + // plain `From` (no per-field guard), so this is the one place its code is + // screened before it reaches a persisted line. + for t in &self.tax { + check_currency_code("tax.currency", &t.currency)?; + } let tax: Vec = self.tax.into_iter().map(TaxBreakdown::from).collect(); + // A zero-line invoice (no items AND no tax) has nothing to post — the builder + // would emit an empty entry. Reject it at the boundary as a 400 (an + // items-empty-but-tax-present invoice is a legitimate tax-only posting). + if items.is_empty() && tax.is_empty() { + return Err(DomainError::InvalidRequest( + "invoice must carry at least one item or tax line".to_owned(), + )); + } // Single-currency invariant: the builder stamps the reference currency on // every line, so a differing item/tax currency would be silently // misattributed. Reject it at the boundary (the reference = first item's @@ -484,6 +520,17 @@ pub struct ReversalRequestDto { pub effective_at: Option, } +impl ReversalRequestDto { + /// Cap the persisted audit `reason` free text at the boundary (this DTO has + /// no lowering method — the handler calls this before the write). + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `reason` exceeds [`MAX_FREE_TEXT_LEN`]. + pub(crate) fn validate(&self) -> Result<(), DomainError> { + check_free_text("reason", &self.reason, MAX_FREE_TEXT_LEN) + } +} + /// The `POST /journal-entries/{entryId}/mapping-corrections` request body: a /// strict reversal of the mis-mapped original immediately followed by a /// corrected re-post (`corrected_items` re-mapped to the right accounts). The @@ -509,6 +556,10 @@ impl MappingCorrectionRequestDto { /// [`DomainError::InvalidRequest`] when an item's class literal does not /// parse. pub fn corrected_items_into_domain(&self) -> Result, DomainError> { + // The audit `reason` is persisted on the secured-audit record; cap the + // unbounded free text at the boundary (this is the DTO's only lowering + // method, so it is where the field is screened). + check_free_text("reason", &self.reason, MAX_FREE_TEXT_LEN)?; self.corrected_items .iter() .cloned() @@ -534,6 +585,21 @@ pub struct EntryAnnotationRequestDto { pub reason: String, } +impl EntryAnnotationRequestDto { + /// Cap the persisted `description` note and audit `reason` free text at the + /// boundary (this DTO has no lowering method — the handler calls this before + /// the write; PII screening of `description` runs separately downstream). + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when either exceeds [`MAX_FREE_TEXT_LEN`]. + pub(crate) fn validate(&self) -> Result<(), DomainError> { + if let Some(description) = &self.description { + check_free_text("description", description, MAX_FREE_TEXT_LEN)?; + } + check_free_text("reason", &self.reason, MAX_FREE_TEXT_LEN) + } +} + // ── Audit-retrieval response DTOs (Group 2C, AC #8) ────────────────────────── /// The who/when/source/correlation dims of one posted entry, the @@ -673,6 +739,21 @@ pub struct AuditPackRequestDto { pub reason_code: Option, } +impl AuditPackRequestDto { + /// Cap the machine `reason_code` at the boundary (this DTO has no lowering + /// method — the handler calls this before the export). + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `reason_code` exceeds + /// [`MAX_REASON_CODE_LEN`]. + pub(crate) fn validate(&self) -> Result<(), DomainError> { + if let Some(reason_code) = &self.reason_code { + check_free_text("reason_code", reason_code, MAX_REASON_CODE_LEN)?; + } + Ok(()) + } +} + /// Audit-pack export resource (Slice 6 §5/§10). `POST …/audit/packs` returns /// this at `202 Accepted` (without `csv` — a job summary) with a `Location` to /// `GET …/audit/packs/{exportId}`, which returns it again with `csv` populated @@ -1055,6 +1136,7 @@ impl SettlePaymentRequest { /// [`MAX_BUSINESS_ID_LEN`]. pub fn into_sdk(self) -> Result { validate_business_id("payment_id", &self.payment_id)?; + check_currency_code("currency", &self.currency)?; Ok(bss_ledger_sdk::SettlePayment { tenant_id: self.tenant_id, payer_tenant_id: self.payer_tenant_id, @@ -1127,6 +1209,7 @@ impl ReturnPaymentRequest { ) -> Result { validate_business_id("payment_id", &payment_id)?; validate_business_id("psp_return_id", &self.psp_return_id)?; + check_currency_code("currency", &self.currency)?; Ok(bss_ledger_sdk::ReturnPayment { tenant_id: self.tenant_id, payer_tenant_id: self.payer_tenant_id, @@ -1223,6 +1306,7 @@ impl RecordDisputePhaseRequest { if let Some(invoice_id) = &self.invoice_id { validate_business_id("invoice_id", invoice_id)?; } + check_currency_code("currency", &self.currency)?; // The DB CHECK (cycle >= 1) is authoritative; reject a non-positive cycle // at the boundary with a clear `InvalidRequest` rather than letting it hit // the constraint and surface as a generic 500. @@ -1383,6 +1467,7 @@ impl AllocatePaymentRequest { validate_business_id("splits.invoice_id", &split.invoice_id)?; } } + check_currency_code("currency", &self.currency)?; Ok(bss_ledger_sdk::AllocatePayment { tenant_id: self.tenant_id, payer_tenant_id: self.payer_tenant_id, @@ -1585,6 +1670,10 @@ impl CreditApplicationRequest { format!("must be 1..={MAX_BUSINESS_ID_LEN} bytes"), )); } + // Screen the currency at the boundary; the `DomainError` 400 flows through + // the existing `From for CanonicalError` ladder so it renders + // the same InvalidArgument shape as the kind-specific violations below. + check_currency_code("currency", &self.currency).map_err(CanonicalError::from)?; match self.kind.as_str() { "grant" => { let amount_minor = self.amount_minor.ok_or_else(|| { @@ -2202,6 +2291,18 @@ pub struct ReidentifyRequestDto { pub target_scope: Option, } +impl ReidentifyRequestDto { + /// Cap the machine `reason_code` at the boundary (this DTO has no lowering + /// method — the handler calls this before the re-identify). + /// + /// # Errors + /// [`DomainError::InvalidRequest`] when `reason_code` exceeds + /// [`MAX_REASON_CODE_LEN`]. + pub(crate) fn validate(&self) -> Result<(), DomainError> { + check_free_text("reason_code", &self.reason_code, MAX_REASON_CODE_LEN) + } +} + /// `POST …/audit/reidentify` response: the recovered opaque `pii_ref` (the /// pointer into the external PII store; never the PII itself). #[derive(Debug, Clone)] @@ -2314,6 +2415,8 @@ impl CreditNoteRequest { if let Some(item) = &self.origin_invoice_item_ref { validate_business_id("origin_invoice_item_ref", item)?; } + check_currency_code("currency", &self.currency)?; + check_free_text("reason_code", &self.reason_code, MAX_REASON_CODE_LEN)?; Ok(crate::domain::adjustment::credit_note::CreditNoteRequest { tenant_id: self.tenant_id, payer_tenant_id: self.payer_tenant_id, @@ -2439,6 +2542,8 @@ impl ManualAdjustmentRequest { preparer_actor_id: Uuid, ) -> Result { validate_business_id("adjustment_id", &self.adjustment_id)?; + check_currency_code("currency", &self.currency)?; + check_free_text("reason_code", &self.reason_code, MAX_REASON_CODE_LEN)?; let action = crate::domain::adjustment::manual::ManualAdjustmentAction::parse(&self.action) .ok_or_else(|| { DomainError::InvalidRequest(format!( @@ -2591,6 +2696,8 @@ impl DebitNoteRequest { if let Some(item) = &self.origin_invoice_item_ref { validate_business_id("origin_invoice_item_ref", item)?; } + check_currency_code("currency", &self.currency)?; + check_free_text("reason_code", &self.reason_code, MAX_REASON_CODE_LEN)?; let recognition = self .recognition .map(RecognitionInputDto::into_domain) @@ -2754,6 +2861,7 @@ impl RefundRequest { if let Some(rel) = &self.relates_to_refund_id { validate_business_id("relates_to_refund_id", rel)?; } + check_currency_code("currency", &self.currency)?; let phase = RefundPhase::parse(&self.phase).ok_or_else(|| { DomainError::InvalidRequest(format!( "unknown refund phase {:?} (expected initiated|confirmed|rejected|voided|\ @@ -3319,6 +3427,24 @@ fn check_currency_code(field: &str, code: &str) -> Result<(), DomainError> { Ok(()) } +/// Max bytes of a machine `reason_code` (a short enumerated-style token). +const MAX_REASON_CODE_LEN: usize = 64; +/// Max bytes of a human free-text `reason` / `description` note. +const MAX_FREE_TEXT_LEN: usize = 4096; + +/// Light boundary cap for a persisted free-text field: reject a value whose byte +/// length exceeds `max` (mirrors [`check_currency_code`]). An unbounded note would +/// otherwise blow past its storage column as a 500 instead of a clean 400 here. +fn check_free_text(field: &str, value: &str, max: usize) -> Result<(), DomainError> { + if value.len() > max { + return Err(DomainError::InvalidRequest(format!( + "{field} must be at most {max} bytes, got {}", + value.len() + ))); + } + Ok(()) +} + /// Secondary manual / seed ingest of one FX rate into the local `ledger_fx_rate` /// store (the primary path is the `RateProviderV1` plugin pull, design §4.6 / /// decision 2). Upsert-keyed on `(tenant_id, base_currency, quote_currency, diff --git a/gears/bss/ledger/ledger/src/api/rest/dto_tests.rs b/gears/bss/ledger/ledger/src/api/rest/dto_tests.rs index c8c2648f7..83b813b76 100644 --- a/gears/bss/ledger/ledger/src/api/rest/dto_tests.rs +++ b/gears/bss/ledger/ledger/src/api/rest/dto_tests.rs @@ -143,6 +143,33 @@ fn invoice_item_bad_account_class_is_invalid_request() { ); } +/// Regression: a negative ex-tax amount is rejected at the boundary as +/// `InvalidRequest` (a 400) rather than reaching the recognition split's +/// `deferred.clamp(0, amount)` downstream, where `min > max` would panic. +#[test] +fn invoice_item_negative_amount_is_invalid_request() { + let dto = InvoiceItemDto { + amount_minor_ex_tax: -1, + currency: "USD".to_owned(), + revenue_stream: "subscription".to_owned(), + catalog_class: None, + contract_class: None, + gl_code: None, + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }; + let err = dto + .into_domain() + .expect_err("a negative amount must reject"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest, got {err:?}" + ); +} + /// The mapping-correction body lowers its `corrected_items` the same way (bad /// literal ⇒ `InvalidRequest`). #[test] @@ -318,6 +345,105 @@ fn mixed_currency_invoice_is_invalid_request() { ); } +/// A malformed currency code is rejected at the boundary as `InvalidRequest` (a +/// 400) rather than reaching a persisted line where it would match zero +/// currency-scale rows. +#[test] +fn invoice_item_invalid_currency_is_invalid_request() { + let dto = InvoiceItemDto { + amount_minor_ex_tax: 100, + currency: "NOT-A-CURRENCY".to_owned(), // > 10 chars ⇒ over the code cap + revenue_stream: "subscription".to_owned(), + catalog_class: None, + contract_class: None, + gl_code: None, + recognition: None, + invoice_item_ref: None, + sku_or_plan_ref: None, + price_id: None, + pricing_snapshot_ref: None, + }; + let err = dto + .into_domain() + .expect_err("a malformed currency must reject"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest, got {err:?}" + ); +} + +/// A wholly-empty invoice (no items AND no tax) is rejected at the boundary as +/// `InvalidRequest` — a zero-line invoice has nothing to post. +#[test] +fn empty_invoice_is_invalid_request() { + let tenant = uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let actor = uuid::uuid!("99999999-8888-7777-6666-555555555555"); + let body = serde_json::json!({ + "tenant_id": tenant, + "invoice_id": "INV-EMPTY", + "payer_tenant_id": tenant, + "effective_at": "2026-06-01", + "period_id": "202606", + "items": [], + "tax": [], + "correlation_id": actor + }); + let dto: PostInvoiceRequestDto = + serde_json::from_value(body).expect("snake_case body must deserialize"); + let err = dto + .into_domain(actor) + .expect_err("a zero-line invoice must reject"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest, got {err:?}" + ); +} + +/// An over-long persisted free-text field is capped at the boundary as +/// `InvalidRequest` (a 400) rather than blowing past its storage column as a 500. +#[test] +fn over_long_free_text_is_rejected_at_the_boundary() { + let dto = MappingCorrectionRequestDto { + reason: "X".repeat(MAX_FREE_TEXT_LEN + 1), // one over the free-text cap + period_id: None, + effective_at: None, + corrected_items: vec![], + }; + let err = dto + .corrected_items_into_domain() + .expect_err("an over-long reason must reject"); + assert!( + matches!(err, DomainError::InvalidRequest(_)), + "expected InvalidRequest, got {err:?}" + ); +} + +/// The handler-lowered DTOs (no `into_domain`) cap their free text via a +/// `validate()` the handler calls: an over-long reversal `reason` and an +/// over-long re-identify `reason_code` both reject as `InvalidRequest`. +#[test] +fn handler_lowered_dtos_cap_free_text_via_validate() { + let reversal = ReversalRequestDto { + reason: "X".repeat(MAX_FREE_TEXT_LEN + 1), + period_id: None, + effective_at: None, + }; + assert!( + matches!(reversal.validate(), Err(DomainError::InvalidRequest(_))), + "an over-long reversal reason must reject" + ); + + let reidentify = ReidentifyRequestDto { + payer_tenant_id: uuid::Uuid::now_v7(), + reason_code: "X".repeat(MAX_REASON_CODE_LEN + 1), + target_scope: None, + }; + assert!( + matches!(reidentify.validate(), Err(DomainError::InvalidRequest(_))), + "an over-long reason_code must reject" + ); +} + /// An allocate body WITHOUT `splits` lowers to the SDK type with `splits: None` /// (Mode A/B precedence path — unchanged), and `payment_id` is bound from the /// PATH (not the body). diff --git a/gears/bss/ledger/ledger/src/api/rest/journal_entries.rs b/gears/bss/ledger/ledger/src/api/rest/journal_entries.rs index ba528f370..4bfcc29f8 100644 --- a/gears/bss/ledger/ledger/src/api/rest/journal_entries.rs +++ b/gears/bss/ledger/ledger/src/api/rest/journal_entries.rs @@ -591,6 +591,8 @@ async fn reverse_entry( CanonicalJson(body): CanonicalJson, ) -> Result { let ctx = require_authenticated(extension_ctx)?; + // Cap the unbounded audit `reason` free text at the boundary (400) before any work. + body.validate().map_err(CanonicalError::from)?; // Tenant from the auth context; the scoped `get_entry` read enforces BOLA // (a foreign entry resolves to None ⇒ 404, no existence leak). let tenant_id = ctx.subject_tenant_id(); @@ -857,6 +859,8 @@ async fn set_entry_annotation( CanonicalJson(body): CanonicalJson, ) -> Result { let ctx = require_authenticated(extension_ctx)?; + // Cap the unbounded `description` / `reason` free text at the boundary (400). + body.validate().map_err(CanonicalError::from)?; let caller_tenant = ctx.subject_tenant_id(); let entry = state diff --git a/gears/bss/ledger/ledger/src/domain/fx/translate.rs b/gears/bss/ledger/ledger/src/domain/fx/translate.rs index 997356fdf..5a470a324 100644 --- a/gears/bss/ledger/ledger/src/domain/fx/translate.rs +++ b/gears/bss/ledger/ledger/src/domain/fx/translate.rs @@ -44,9 +44,21 @@ pub enum FxTranslateError { /// balance at the period-end rate without going through [`translate_entry`] /// (which closes a multi-line residual it does not need). /// +/// Rejects a non-positive `rate_micro` up front: a rate `<= 0` is never a valid +/// FX quote, and letting it through would flip the sign of (or zero out) the +/// translated amount and post a wrong revaluation/allocation entry. The +/// [`translate_entry`] path already guards this; guarding here closes the same +/// hole for the direct single-amount callers (e.g. the revaluation run), which +/// resolve a rate straight from the local store that the provider-sync path +/// upserts. +/// /// # Errors -/// [`FxTranslateError::Overflow`] if the translated amount exceeds `i64`. +/// - [`FxTranslateError::RateNonPositive`] if `rate_micro <= 0`. +/// - [`FxTranslateError::Overflow`] if the translated amount exceeds `i64`. pub fn translate_amount(amount_minor: i64, rate_micro: i64) -> Result { + if rate_micro <= 0 { + return Err(FxTranslateError::RateNonPositive); + } let raw = round_half_even(i128::from(amount_minor) * i128::from(rate_micro), MICRO); checked_minor(raw).map_err(|_| FxTranslateError::Overflow(raw)) } diff --git a/gears/bss/ledger/ledger/src/domain/fx/translate_tests.rs b/gears/bss/ledger/ledger/src/domain/fx/translate_tests.rs index 91ebf2ba1..5dac6b778 100644 --- a/gears/bss/ledger/ledger/src/domain/fx/translate_tests.rs +++ b/gears/bss/ledger/ledger/src/domain/fx/translate_tests.rs @@ -90,6 +90,23 @@ fn non_positive_rate_is_rejected() { ); } +#[test] +fn translate_amount_rejects_non_positive_rate() { + // The single-amount path (used by the unrealized-revaluation run) must reject + // a `<= 0` rate too — a zero rate would zero out the position and a negative + // rate would flip its sign, posting a wrong FX entry instead of erroring. + assert_eq!( + translate_amount(10_000, 0), + Err(FxTranslateError::RateNonPositive) + ); + assert_eq!( + translate_amount(10_000, -1_100_000), + Err(FxTranslateError::RateNonPositive) + ); + // A valid positive rate still translates ($100.00 × 1.1 = $110.00). + assert_eq!(translate_amount(10_000, 1_100_000), Ok(11_000)); +} + #[test] fn anchor_out_of_bounds_is_rejected() { let lines = [dr(1000), cr(1000)]; diff --git a/gears/bss/ledger/ledger/src/domain/invoice/builder.rs b/gears/bss/ledger/ledger/src/domain/invoice/builder.rs index ea5f7444c..d68591818 100644 --- a/gears/bss/ledger/ledger/src/domain/invoice/builder.rs +++ b/gears/bss/ledger/ledger/src/domain/invoice/builder.rs @@ -253,7 +253,14 @@ pub fn build_invoice_entry(inv: &PostedInvoice, mapped: &[MappedLine]) -> PostEn // negative recognized-now credit (which would unbalance the entry) — the // orchestrator validates the invariant before calling, and the // foundation unbalanced guard is the backstop. - let deferred_minor = item.deferred_minor.clamp(0, item.amount_minor_ex_tax); + // + // `.max(0)` on the upper bound so a (rejected-at-the-boundary but + // domain-constructible) negative `amount_minor_ex_tax` cannot make this + // `clamp(0, negative)` panic with `min > max`; a negative amount then + // clamps deferred to 0 and the unbalanced guard rejects the entry. + let deferred_minor = item + .deferred_minor + .clamp(0, item.amount_minor_ex_tax.max(0)); let recognized_now = item.amount_minor_ex_tax - deferred_minor; // Key on the stored string forms (the SDK enums are not `Ord`, and a diff --git a/gears/bss/ledger/ledger/src/infra/fx/rate_source.rs b/gears/bss/ledger/ledger/src/infra/fx/rate_source.rs index aef5b62d9..bbd81f786 100644 --- a/gears/bss/ledger/ledger/src/infra/fx/rate_source.rs +++ b/gears/bss/ledger/ledger/src/infra/fx/rate_source.rs @@ -174,6 +174,14 @@ impl RateSource { // First non-stale candidate wins; its precedence rank is the result's // `fallback_order` (0 = primary). for row in &candidates { + // Defence in depth: a rate `<= 0` is never a valid quote. The REST + // ingest DTO rejects it, but the provider-sync upsert and the raw + // store have no such gate, so a corrupt/zero feed row could otherwise + // be picked here and flip the sign of (or zero out) every downstream + // translation. Skip it like a stale row so a valid fallback can win. + if row.rate_micro <= 0 { + continue; + } let age = now - row.as_of; if !is_stale(base, quote, age, &self.cfg) { let rank = order_index(&row.provider, &self.cfg.provider_order); diff --git a/gears/bss/ledger/ledger/src/infra/jobs/rate_sync.rs b/gears/bss/ledger/ledger/src/infra/jobs/rate_sync.rs index e2157586f..570bf0a20 100644 --- a/gears/bss/ledger/ledger/src/infra/jobs/rate_sync.rs +++ b/gears/bss/ledger/ledger/src/infra/jobs/rate_sync.rs @@ -198,6 +198,23 @@ impl RateSyncJob { provider_id: &str, ) -> anyhow::Result<()> { for rate in rates { + // Never poison the local store with a non-positive quote: a rate + // `<= 0` is never valid and would flip the sign of (or zero out) + // every downstream translation. The REST ingest DTO rejects it; the + // provider feed has no such gate, so drop the pair here (the tenant's + // other pairs still sync) rather than upserting corrupt data. + if rate.rate_micro <= 0 { + tracing::warn!( + target: "bss-ledger.rate-sync", + tenant = %tenant, + provider = provider_id, + base = %rate.base, + quote = %rate.quote, + rate_micro = rate.rate_micro, + "bss-ledger: dropping non-positive FX quote from provider feed" + ); + continue; + } self.repo .upsert_rate(&NewFxRate { tenant_id: tenant, diff --git a/gears/bss/ledger/ledger/src/infra/jobs/tieout.rs b/gears/bss/ledger/ledger/src/infra/jobs/tieout.rs index 9df634d7c..8a757dd95 100644 --- a/gears/bss/ledger/ledger/src/infra/jobs/tieout.rs +++ b/gears/bss/ledger/ledger/src/infra/jobs/tieout.rs @@ -1036,30 +1036,6 @@ impl SubGrainAcc { } } -/// Whole-slice reference wrapper over [`SubGrainAcc`] — retained for the -/// pure-function unit tests that lock the fold/finalize semantics the paginated -/// production path relies on. -#[cfg(test)] -fn recompute_sub_grain_variances( - lines: &[journal_line::Model], - normal_side_map: &HashMap, - ar_payer_cache: &[ar_payer_balance::Model], - ar_invoice_cache: &[ar_invoice_balance::Model], - tax_cache: &[tax_subbalance::Model], - unallocated_cache: &[unallocated_balance::Model], - reusable_credit_cache: &[reusable_credit_subbalance::Model], -) -> Vec { - let mut acc = SubGrainAcc::default(); - acc.fold(lines, normal_side_map); - acc.finalize( - ar_payer_cache, - ar_invoice_cache, - tax_cache, - unallocated_cache, - reusable_credit_cache, - ) -} - /// Build the `entry_id -> payment_id` index for PAYMENT_SETTLE entries plus the /// tenant-wide SETTLEMENT_RETURN flag (its presence makes journal-recomputed /// `settled_minor` AND `fee_minor` un-reconcilable — see @@ -1295,22 +1271,6 @@ impl PaymentCounterAcc { } } -/// Whole-slice reference wrapper over [`PaymentCounterAcc`] — retained for the -/// pure-function unit tests that lock the fold/finalize semantics the paginated -/// production path relies on. -#[cfg(test)] -fn recompute_payment_counter_variances( - entries: &[journal_entry::Model], - lines: &[journal_line::Model], - allocations: &[payment_allocation::Model], - settlement_cache: &[payment_settlement::Model], -) -> Vec { - let (settle_payment_by_entry, has_settlement_return) = settle_index(entries); - let mut acc = PaymentCounterAcc::default(); - acc.fold(lines, &settle_payment_by_entry); - acc.finalize(allocations, settlement_cache, has_settlement_return) -} - /// Per-entry running tally for the entry-balance backstop. #[derive(Default)] struct EntryAgg { @@ -1368,17 +1328,6 @@ impl EntryBackstopAcc { } } -/// Group lines by `(entry_id, currency, currency_scale)` and flag any group -/// whose net (`sum(DR) - sum(CR)`) is non-zero, that has no lines, or that -/// spans more than one payer. Independent of the commit trigger — catches a -/// malformed entry committed with a missing/buggy trigger. -#[cfg(test)] -fn entry_backstop(lines: &[journal_line::Model]) -> Vec { - let mut acc = EntryBackstopAcc::default(); - acc.fold(lines); - acc.finalize() -} - /// Re-check the no-negative invariant: an `account_balance` row whose balance is /// negative is a defect when its class is guarded ([`AccountClass::GUARDED`]) OR /// unknown/unparseable (fail loud on a corrupt class). Classes outside the diff --git a/gears/bss/ledger/ledger/src/infra/jobs/tieout_tests.rs b/gears/bss/ledger/ledger/src/infra/jobs/tieout_tests.rs index 1edfd4d87..2132c5bee 100644 --- a/gears/bss/ledger/ledger/src/infra/jobs/tieout_tests.rs +++ b/gears/bss/ledger/ledger/src/infra/jobs/tieout_tests.rs @@ -14,6 +14,7 @@ clippy::inconsistent_struct_constructor )] +use std::collections::HashMap; use std::sync::Arc; use bss_ledger_sdk::{AccountClass, MappingStatus, Side, SourceDocType}; @@ -28,9 +29,9 @@ use toolkit_security::SecurityContext; use uuid::Uuid; use super::{ - NegativeGrain, TieOutReport, cache_baseline_rows, cache_grains, entry_backstop, fold_grains, - key_account, negative_grains, recompute_payment_counter_variances, - recompute_sub_grain_variances, verify_incremental, + EntryBackstopAcc, ImbalancedEntry, NegativeGrain, PaymentCounterAcc, PaymentCounterVariance, + SubGrainAcc, SubGrainVariance, TieOutReport, cache_baseline_rows, cache_grains, fold_grains, + key_account, negative_grains, settle_index, verify_incremental, }; use crate::domain::model::{AccountRow, CurrencyScaleRow, NewEntry, NewLine}; use crate::domain::money::DEFAULT_PLAUSIBLE_MAX_MAJOR; @@ -1079,3 +1080,50 @@ async fn incremental_tie_out_equals_full_across_periods() { let full = job.tie_out_on(&conn, f.tenant).await.unwrap(); assert!(full.is_clean(), "the full fold is clean too"); } + +// ──────────────────────────────────────────────────────────────────────────── +// Whole-slice reference wrappers over the streaming accumulators. Test-only +// (DE1101: test code lives in the companion, not the production file); they lock +// the fold/finalize semantics the paginated production path relies on. +// ──────────────────────────────────────────────────────────────────────────── + +/// Whole-slice reference wrapper over [`SubGrainAcc`]. +fn recompute_sub_grain_variances( + lines: &[journal_line::Model], + normal_side_map: &HashMap, + ar_payer_cache: &[ar_payer_balance::Model], + ar_invoice_cache: &[ar_invoice_balance::Model], + tax_cache: &[tax_subbalance::Model], + unallocated_cache: &[unallocated_balance::Model], + reusable_credit_cache: &[reusable_credit_subbalance::Model], +) -> Vec { + let mut acc = SubGrainAcc::default(); + acc.fold(lines, normal_side_map); + acc.finalize( + ar_payer_cache, + ar_invoice_cache, + tax_cache, + unallocated_cache, + reusable_credit_cache, + ) +} + +/// Whole-slice reference wrapper over [`PaymentCounterAcc`]. +fn recompute_payment_counter_variances( + entries: &[journal_entry::Model], + lines: &[journal_line::Model], + allocations: &[payment_allocation::Model], + settlement_cache: &[payment_settlement::Model], +) -> Vec { + let (settle_payment_by_entry, has_settlement_return) = settle_index(entries); + let mut acc = PaymentCounterAcc::default(); + acc.fold(lines, &settle_payment_by_entry); + acc.finalize(allocations, settlement_cache, has_settlement_return) +} + +/// Whole-slice reference wrapper over [`EntryBackstopAcc`]. +fn entry_backstop(lines: &[journal_line::Model]) -> Vec { + let mut acc = EntryBackstopAcc::default(); + acc.fold(lines); + acc.finalize() +} diff --git a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000026_create_fx_rate_tables.rs b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000026_create_fx_rate_tables.rs index 12f8c6df9..b974f96f4 100644 --- a/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000026_create_fx_rate_tables.rs +++ b/gears/bss/ledger/ledger/src/infra/storage/migrations/m20260627_000026_create_fx_rate_tables.rs @@ -31,6 +31,7 @@ const PG_UP_STATEMENTS: &[&str] = &[ fallback_order integer NOT NULL DEFAULT 0, triangulated_via varchar(128), PRIMARY KEY (tenant_id, rate_id), + CONSTRAINT ck_fx_rate_snapshot_rate_positive CHECK (rate_micro > 0), CONSTRAINT uq_fx_rate_snapshot_lock UNIQUE (tenant_id, base_currency, quote_currency, provider, as_of, fallback_order) )", @@ -52,7 +53,8 @@ const PG_UP_STATEMENTS: &[&str] = &[ as_of timestamptz NOT NULL, fallback_order integer NOT NULL DEFAULT 0, updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (tenant_id, base_currency, quote_currency, provider) + PRIMARY KEY (tenant_id, base_currency, quote_currency, provider), + CONSTRAINT ck_fx_rate_rate_positive CHECK (rate_micro > 0) )", ]; @@ -80,6 +82,7 @@ const SQLITE_UP_STATEMENTS: &[&str] = &[ fallback_order integer NOT NULL DEFAULT 0, triangulated_via varchar(128), PRIMARY KEY (tenant_id, rate_id), + CONSTRAINT ck_fx_rate_snapshot_rate_positive CHECK (rate_micro > 0), CONSTRAINT uq_fx_rate_snapshot_lock UNIQUE (tenant_id, base_currency, quote_currency, provider, as_of, fallback_order) )", @@ -94,7 +97,8 @@ const SQLITE_UP_STATEMENTS: &[&str] = &[ as_of text NOT NULL, fallback_order integer NOT NULL DEFAULT 0, updated_at text NOT NULL DEFAULT (CURRENT_TIMESTAMP), - PRIMARY KEY (tenant_id, base_currency, quote_currency, provider) + PRIMARY KEY (tenant_id, base_currency, quote_currency, provider), + CONSTRAINT ck_fx_rate_rate_positive CHECK (rate_micro > 0) )", ]; diff --git a/gears/bss/libs/coord/Cargo.toml b/gears/bss/libs/coord/Cargo.toml index fff758109..a913b14af 100644 --- a/gears/bss/libs/coord/Cargo.toml +++ b/gears/bss/libs/coord/Cargo.toml @@ -40,6 +40,16 @@ tracing = { workspace = true } async-trait = { workspace = true } [dev-dependencies] -# Integration tests drive the real `LeaseManager`/`LeaseGuard` against an -# in-memory SQLite `Db`; raw setup SQL goes through sea-orm `Statement`. -tokio = { workspace = true, features = ["time", "macros", "rt", "sync"] } +# Integration tests drive the real `LeaseManager`/`LeaseGuard`. The SQLite +# in-crate tests run on an in-memory `Db`; the Postgres fence test +# (`tests/postgres_fence.rs`) needs a real backend (SSI/snapshot semantics +# SQLite cannot model) via testcontainers, plus the multi-thread runtime to +# drive the held ack tx and the peer steal concurrently. +tokio = { workspace = true, features = [ + "time", + "macros", + "rt", + "rt-multi-thread", + "sync", +] } +testcontainers-modules = { workspace = true } diff --git a/gears/bss/libs/coord/src/lease/guard.rs b/gears/bss/libs/coord/src/lease/guard.rs index 0b9f38e2b..e824bf3c4 100644 --- a/gears/bss/libs/coord/src/lease/guard.rs +++ b/gears/bss/libs/coord/src/lease/guard.rs @@ -27,7 +27,7 @@ use std::time::Duration; use sea_orm::sea_query::Expr; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use toolkit_db::Db; -use toolkit_db::secure::{DbTx, ScopeError, SecureEntityExt, SecureUpdateExt, TxConfig}; +use toolkit_db::secure::{DbTx, ScopeError, SecureUpdateExt, TxConfig}; use toolkit_security::AccessScope; use uuid::Uuid; @@ -158,10 +158,21 @@ impl LeaseGuard { } /// Run `f` inside a `SERIALIZABLE` transaction (with retry on transient - /// contention) and append a fence SELECT against `coord_leases` as the last + /// contention) and append a fence UPDATE against `coord_leases` as the last /// DB call of the tx. Zero matched rows → [`AckError::LeaseLost`] (rollback, /// **not** retried). /// + /// The fence is an UPDATE (a no-op self-touch of the holder's own row), not a + /// SELECT, on purpose. A plain SELECT reads the transaction's frozen + /// `SERIALIZABLE` snapshot: a peer steal that committed *after* the ack tx + /// took its snapshot stays invisible, the pre-steal row still tests + /// "mine + live", and the commit is NOT guaranteed to abort (a lone + /// read-only rw-antidependency does not trip Postgres SSI). Writing the row + /// instead makes the fence a ww-conflict: Postgres first-updater-wins raises + /// `40001` at the fence when a peer already committed a steal, and the + /// [`Dialect::live_filter_clock`] wall-clock predicate catches a + /// lapsed-but-unstolen lease — closing the window a SELECT fence left open. + /// /// `extract_work_db_err` lets the retry helper classify `Work(E)` failures: /// `Some(&DbErr)` for retryable contention causes a retry; anything else (or /// `None`) terminates the loop. @@ -171,7 +182,7 @@ impl LeaseGuard { /// reset by `f` itself before re-running). /// /// # Errors - /// * [`AckError::LeaseLost`] — fence SELECT found the row stolen / expired. + /// * [`AckError::LeaseLost`] — fence UPDATE found the row stolen / expired. /// * [`AckError::Work`] — `f` returned `Err(E)`. /// * [`AckError::Db`] — DB transport / serialisation / fence-SELECT failure /// the retry helper did not classify as retryable, or an unsupported @@ -216,25 +227,33 @@ impl LeaseGuard { let user_future = f(tx); Box::pin(async move { let work_result = user_future.await.map_err(AckError::Work)?; - // Fence SELECT — last DB call inside the tx. A peer steal - // that committed mid-flight normally aborts here as 40001 - // (read-set conflict) and retries; the explicit - // zero-rows check covers a steal that committed BEFORE - // this tx began. The `locked_until > NOW()` clause also - // fences a lapsed-but-unstolen lease. - let still_mine = coord_leases::Entity::find() + // Fence UPDATE — last DB call inside the tx. A no-op + // self-touch of the holder's row (`locked_until = + // locked_until`) so it is a WRITE, not a snapshot read: + // a peer steal committed mid-flight then forces a `40001` + // here via Postgres first-updater-wins (the retry helper + // re-runs and observes the steal → zero rows → lost), + // while a lone SELECT could commit against a stale + // snapshot. The `locked_by` clause catches a takeover and + // the wall-clock `live_filter_clock` catches a + // lapsed-but-unstolen lease. Zero rows → `LeaseLost`. + let fenced = coord_leases::Entity::update_many() + .col_expr( + coord_leases::Column::LockedUntil, + Expr::col(coord_leases::Column::LockedUntil).into(), + ) .filter( coord_leases::Column::Key .eq(key.as_str()) .and(coord_leases::Column::LockedBy.eq(locked_by)) - .and(dialect.live_filter()), + .and(dialect.live_filter_clock()), ) .secure() .scope_with(&AccessScope::allow_all()) - .one(tx) + .exec(tx) .await .map_err(map_fence_scope_err)?; - if still_mine.is_none() { + if fenced.rows_affected == 0 { return Err(AckError::LeaseLost); } Ok(work_result) diff --git a/gears/bss/libs/coord/src/lease/manager.rs b/gears/bss/libs/coord/src/lease/manager.rs index 4aa9f3e4f..3704ae3f1 100644 --- a/gears/bss/libs/coord/src/lease/manager.rs +++ b/gears/bss/libs/coord/src/lease/manager.rs @@ -9,20 +9,24 @@ //! Time anchors on the **DB clock**: the steal/renew filters use `WHERE //! locked_until < NOW()` (DB-side), so a worker that misreads expiry under NTP //! drift simply gets `rows_affected == 0` and returns `LeaseHeld` — a -//! false-negative on acquire, never a false-positive steal. The only worker -//! clock used is the `locked_until` written on the free-slot INSERT, and drift -//! there can only *shorten* our own lease versus the DB's view, never extend it -//! past it. +//! false-negative on acquire, never a false-positive steal. `locked_until` is +//! *written* on the DB clock too: the steal / renew paths bump it via +//! `dialect.ttl_expr` (`NOW() + INTERVAL`), and the free-slot path INSERTs the +//! epoch sentinel (a fixed `1970-01-01T00:00:00Z`, no worker clock) then claims +//! the row it just created with the same DB-clock steal-UPDATE. So no +//! worker-clock value ever lands in `locked_until`; a worker whose clock runs +//! *ahead* can no longer stamp an expiry into the future and block peers past +//! the intended TTL. use std::time::Duration; use chrono::{DateTime, Utc}; use sea_orm::sea_query::{Expr, SimpleExpr}; use sea_orm::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; -use toolkit_db::Db; use toolkit_db::secure::{ ScopeError, SecureEntityExt, SecureUpdateExt, TxConfig, is_unique_violation, secure_insert, }; +use toolkit_db::{Db, DbTx}; use toolkit_security::AccessScope; use uuid::Uuid; @@ -80,25 +84,18 @@ impl LeaseManager { match existing { None => { - // Free slot — INSERT. `locked_until` is written - // worker-clock; the steal-path filter is - // DB-clock, so drift only shortens our own - // lease, never extends it past the DB's view. + // Free slot — INSERT the epoch sentinel + // (`locked_until` in 1970, `locked_by = NULL`), + // which carries NO worker-clock value, then claim + // the row we just created on the DB clock via the + // same steal-UPDATE the expired arm uses. A peer + // that races the INSERT loses on the PK + // unique-violation → `LeaseHeld`. let row = coord_leases::ActiveModel { key: ActiveValue::Set(key.clone()), - locked_by: ActiveValue::Set(Some(my_uuid)), - // Checked: `chrono::Duration::seconds` and - // `DateTime + Duration` both panic on overflow, - // and `acquire(key, ttl)` is library-public. A - // pathological `ttl` clamps to the representable - // max instead of panicking; the DB-clock steal - // filter stays authoritative regardless. - locked_until: ActiveValue::Set( - chrono::TimeDelta::try_seconds(ttl_secs) - .and_then(|d| Utc::now().checked_add_signed(d)) - .unwrap_or(DateTime::::MAX_UTC), - ), - attempts: ActiveValue::Set(1), + locked_by: ActiveValue::Set(None), + locked_until: ActiveValue::Set(DateTime::::UNIX_EPOCH), + attempts: ActiveValue::Set(0), }; match secure_insert::( row, @@ -107,45 +104,34 @@ impl LeaseManager { ) .await { - Ok(_) => Ok(()), + Ok(_) => {} Err(ScopeError::Db(db)) if is_unique_violation(&db) => { // A peer raced us between SELECT and // INSERT and committed first; their row // is live → `LeaseHeld`. - Err(CoordError::LeaseHeld) + return Err(CoordError::LeaseHeld); } - Err(err) => Err(map_scope_err(err)), + Err(err) => return Err(map_scope_err(err)), } + // Claim the epoch row on the DB clock. Within our + // own SERIALIZABLE txn this always matches the row + // we just inserted (epoch < NOW()); the zero-rows + // guard stays as a belt. + if claim_expired_slot(tx, dialect, &key, my_uuid, ttl_secs).await? + == 0 + { + return Err(CoordError::LeaseHeld); + } + Ok(()) } Some(row) if row.locked_until <= Utc::now() => { // Read side says expired; the UPDATE re-checks // DB-side via `locked_until < NOW()`. If the row // is in fact still live (drift), zero rows → // `LeaseHeld`. - let result = coord_leases::Entity::update_many() - .col_expr( - coord_leases::Column::LockedBy, - Expr::value(my_uuid), - ) - .col_expr( - coord_leases::Column::LockedUntil, - dialect.ttl_expr(ttl_secs), - ) - .col_expr( - coord_leases::Column::Attempts, - Expr::col(coord_leases::Column::Attempts).add(1), - ) - .filter( - coord_leases::Column::Key - .eq(key.as_str()) - .and(dialect.expired_filter()), - ) - .secure() - .scope_with(&AccessScope::allow_all()) - .exec(tx) - .await - .map_err(map_scope_err)?; - if result.rows_affected == 0 { + if claim_expired_slot(tx, dialect, &key, my_uuid, ttl_secs).await? + == 0 + { // Belt: under PG SERIALIZABLE a concurrent // steal normally aborts as 40001 (and // retries); this arm is reached only when @@ -177,6 +163,46 @@ impl LeaseManager { } } +/// Claim a slot whose `locked_until` is DB-side expired: set the holder and push +/// `locked_until` to `NOW() + ttl` on the **DB clock** (`dialect.ttl_expr`), +/// bumping the forensic `attempts` streak. Returns the affected row count — `0` +/// means the row was re-taken (or renewed) between the caller's read and this +/// write, which the caller surfaces as [`CoordError::LeaseHeld`]. +/// +/// Shared by both acquire paths: the expired-lease steal and the free-slot claim +/// of the epoch sentinel the caller just inserted. Anchoring the write on the DB +/// clock (never the worker clock) keeps a skewed worker from stamping an expiry +/// into the future. +async fn claim_expired_slot( + tx: &DbTx<'_>, + dialect: Dialect, + key: &str, + my_uuid: Uuid, + ttl_secs: i64, +) -> Result { + let result = coord_leases::Entity::update_many() + .col_expr(coord_leases::Column::LockedBy, Expr::value(my_uuid)) + .col_expr( + coord_leases::Column::LockedUntil, + dialect.ttl_expr(ttl_secs), + ) + .col_expr( + coord_leases::Column::Attempts, + Expr::col(coord_leases::Column::Attempts).add(1), + ) + .filter( + coord_leases::Column::Key + .eq(key) + .and(dialect.expired_filter()), + ) + .secure() + .scope_with(&AccessScope::allow_all()) + .exec(tx) + .await + .map_err(map_scope_err)?; + Ok(result.rows_affected) +} + /// Supported SQL dialects for the DB-clock lease arithmetic. /// /// `MySQL` is filtered out once in [`Dialect::from_engine`] (the only fallible @@ -228,8 +254,8 @@ impl Dialect { } } - /// The "lease still live" predicate (`locked_until > now`) for the fence + renew - /// filters — the same `datetime()` normalization on `SQLite` as + /// The "lease still live" predicate (`locked_until > now`) for the renew + /// filter — the same `datetime()` normalization on `SQLite` as /// [`Self::expired_filter`]. pub(super) fn live_filter(self) -> SimpleExpr { match self { @@ -238,6 +264,25 @@ impl Dialect { } } + /// The "lease still live" predicate anchored on the **wall clock** + /// (`clock_timestamp()`), for the `with_ack_in_tx` fence. + /// + /// The fence runs inside a long-lived `SERIALIZABLE` transaction, so `NOW()` + /// / `transaction_timestamp()` (used by [`Self::live_filter`]) reads the + /// transaction's *start* time — a lease that lapsed while the ack work ran + /// would still test live against it, defeating the fence. `clock_timestamp()` + /// re-evaluates at statement execution, so an expiry that elapsed since the + /// snapshot is caught. On `SQLite` `datetime('now')` is already wall-clock + /// (no transaction snapshot to skew it), so the two filters coincide. + pub(super) fn live_filter_clock(self) -> SimpleExpr { + match self { + Self::Postgres => { + Expr::col(coord_leases::Column::LockedUntil).gt(Expr::cust("clock_timestamp()")) + } + Self::Sqlite => Expr::cust("datetime(locked_until) > datetime('now')"), + } + } + /// "DB-side now + `ttl_secs`" — bumps `locked_until` on acquire-steal/renew. pub(super) fn ttl_expr(self, ttl_secs: i64) -> SimpleExpr { match self { diff --git a/gears/bss/libs/coord/tests/postgres_fence.rs b/gears/bss/libs/coord/tests/postgres_fence.rs new file mode 100644 index 000000000..2a5c10e42 --- /dev/null +++ b/gears/bss/libs/coord/tests/postgres_fence.rs @@ -0,0 +1,199 @@ +//! Postgres-only: does [`LeaseGuard::with_ack_in_tx`]'s fence actually reject a +//! lease that a peer stole **after** the ack transaction took its snapshot? +//! +//! This is the one scenario SQLite cannot model. SQLite serializes write +//! transactions (a single writer), so a peer steal cannot interleave "inside" +//! an open ack transaction the way it can under Postgres MVCC. The concern is +//! specific to Postgres `SERIALIZABLE`: +//! +//! * The ack transaction's snapshot is taken at its **first statement**, and +//! the fence predicate `locked_until > NOW()` reads `NOW()` as +//! `transaction_timestamp()` — the transaction's *start* time. +//! * If the ack transaction reads the lease row (fixing its snapshot) while the +//! lease is still live, then outlives the TTL, a peer can steal the lease and +//! commit. Under the ack transaction's frozen snapshot the row still looks +//! "mine" and still looks live, so the fence SELECT can return a row. +//! * The safety of the fence then rests entirely on `SERIALIZABLE` aborting the +//! ack transaction at COMMIT (a `40001`, which the retry helper turns into a +//! fresh attempt that observes the steal → `LeaseLost`). Whether Postgres SSI +//! actually raises that abort for a single read-then-commit against a +//! concurrently-updated row is exactly what this test settles empirically. +//! +//! The test asserts the **safety property**: after a peer steals the lease +//! mid-ack, the acknowledged commit must NOT succeed — it must surface +//! [`AckError::LeaseLost`]. An `Ok(_)` outcome means the fence accepted a stolen +//! lease (the reviewed concern is real). Ignored by default; run with +//! `cargo test -p coord --test postgres_fence -- --ignored`. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::doc_markdown, + clippy::let_underscore_must_use +)] + +use std::sync::Arc; +use std::time::Duration; + +use chrono::{TimeDelta, Utc}; +use sea_orm::sea_query::Expr; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use tokio::sync::{Notify, watch}; +use toolkit_db::migration_runner::run_migrations_for_testing; +use toolkit_db::secure::{SecureEntityExt, SecureUpdateExt}; +use toolkit_db::{ConnectOpts, Db, connect_db}; +use toolkit_security::AccessScope; + +use coord::{AckError, LeaseManager}; + +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::runners::AsyncRunner; + +const KEY: &str = "fence-job"; + +/// Test-local mirror of the crate-private `coord_leases` entity. The primitive +/// does not surface the row, and integration tests can only reach a `DbTx` +/// through `SecureEntityExt`, so the ack closure needs its own entity to issue +/// the snapshot-fixing read inside the ack transaction. +mod coord_leases { + use chrono::{DateTime, Utc}; + use sea_orm::entity::prelude::*; + use toolkit_db_macros::Scopable; + use uuid::Uuid; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] + #[sea_orm(table_name = "coord_leases")] + #[secure(no_tenant, no_resource, no_owner, no_type)] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub key: String, + pub locked_by: Option, + pub locked_until: DateTime, + pub attempts: i32, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} + +/// Fresh Postgres `Db` with the `coord_leases` table migrated in (unqualified — +/// resolved via the default `public` search_path). +async fn setup(url: &str) -> Db { + let db = connect_db(url, ConnectOpts::default()) + .await + .expect("connect postgres"); + run_migrations_for_testing( + &db, + vec![Box::new(coord::migration::Migration::unqualified())], + ) + .await + .expect("run coord_leases migration"); + db +} + +/// Push the holder's `locked_until` into the past so the peer's `acquire` treats +/// the slot as expired and steals it — deterministic, unlike sleeping past a +/// second-precision TTL. Runs on its own connection (a committed UPDATE), so it +/// lands *after* the ack transaction's snapshot was fixed. +async fn force_expire(db: &Db) { + let conn = db.conn().expect("conn"); + let past = Utc::now() - TimeDelta::seconds(30); + coord_leases::Entity::update_many() + .col_expr(coord_leases::Column::LockedUntil, Expr::value(past)) + .filter(coord_leases::Column::Key.eq(KEY)) + .secure() + .scope_with(&AccessScope::allow_all()) + .exec(&conn) + .await + .expect("force-expire"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires Docker (testcontainers)"] +async fn fence_rejects_a_lease_stolen_after_the_ack_snapshot() { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + let db = setup(&url).await; + + let mgr = LeaseManager::new(db.clone()); + let guard_a = mgr + .acquire(KEY, Duration::from_mins(5)) + .await + .expect("worker A acquires the free slot"); + let holder_a = guard_a.locked_by(); + + // Rendezvous: `snapshot_ready` fires once the ack tx has read the lease row + // (fixing its SERIALIZABLE snapshot); `steal_done` gates the ack tx from + // reaching its fence until the peer steal has committed. + let snapshot_ready = Arc::new(Notify::new()); + let (steal_tx, steal_rx) = watch::channel(false); + + // Worker A's fenced ack, running concurrently. Its work closure fixes the + // snapshot, signals the test, then blocks until the peer has stolen. + let ack_task = { + let snapshot_ready = snapshot_ready.clone(); + tokio::spawn(async move { + guard_a + .with_ack_in_tx::<_, (), sea_orm::DbErr, _>( + // A work-side read error is a plain DbErr; expose it to the + // retry classifier as-is. + |e: &sea_orm::DbErr| Some(e), + move |tx| { + let snapshot_ready = snapshot_ready.clone(); + let mut steal_rx = steal_rx.clone(); + Box::pin(async move { + // (1) First statement in the ack tx — fixes the + // SERIALIZABLE snapshot while the lease is still live + // and still ours, BEFORE the peer steal commits. + coord_leases::Entity::find() + .filter(coord_leases::Column::Key.eq(KEY)) + .secure() + .scope_with(&AccessScope::allow_all()) + .one(tx) + .await + .map_err(|e| sea_orm::DbErr::Custom(format!("ack read: {e:?}")))?; + // (2) Snapshot is fixed — let the test steal the lease. + snapshot_ready.notify_one(); + // (3) Do not reach the fence until the steal committed. + // Re-entrant across a 40001 retry: already-true + // resolves immediately. + let _ = steal_rx.wait_for(|stolen| *stolen).await; + Ok(()) + }) + }, + ) + .await + }) + }; + + // Wait until A's ack tx has fixed its snapshot, then steal the lease from + // under it: expire the row and let a peer LeaseManager acquire it. Both are + // committed transactions that land after A's snapshot. + snapshot_ready.notified().await; + force_expire(&db).await; + let peer_guard = LeaseManager::new(db.clone()) + .acquire(KEY, Duration::from_mins(5)) + .await + .expect("peer steals the expired lease"); + assert_ne!( + peer_guard.locked_by(), + holder_a, + "the peer must install a fresh holder" + ); + + // Release A's fence. + steal_tx.send(true).expect("signal steal done"); + + let ack = ack_task.await.expect("ack task join"); + + assert!( + matches!(ack, Err(AckError::LeaseLost)), + "SAFETY: a lease stolen after the ack snapshot must fail the fenced \ + commit with LeaseLost; an Ok(_) means the fence accepted a stolen \ + lease (the fence `NOW()`/snapshot concern is real). Got: {ack:?}" + ); +} diff --git a/testing/e2e/gears/bss/ledger/conftest.py b/testing/e2e/gears/bss/ledger/conftest.py index 09fd4bbb5..a2a62c8ce 100644 --- a/testing/e2e/gears/bss/ledger/conftest.py +++ b/testing/e2e/gears/bss/ledger/conftest.py @@ -1,14 +1,22 @@ """E2E fixtures for the bss-ledger gear. -The ledger REST surface (``/bss-ledger/v1``) is an opt-in gear that is NOT yet -wired into ``cf-gears-example-server`` (no dependency + no ``registered_gears`` -entry). Until those routes are mounted, the whole module skips gracefully — we -do NOT want red failures for endpoints that intentionally are not served here. -Once the gear is wired, the probe below starts returning non-404 and every seam -test runs for real (mirrors the file-storage module's staging pattern). +The ledger REST surface (``/bss-ledger/v1``) is an opt-in gear. It IS wired into +the E2E build now — the ``bss-ledger`` cargo feature is in +``config/e2e-features.txt``, ``registered_gears.rs`` links it under that feature, +and ``config/e2e-local.yaml`` carries both a ``bss-ledger`` gear block and a +tenant-B token (``e2e-token-tenant-b``). So against the standard +``make e2e-local`` binary the module runs for real. + +The reachability probe below is retained as a **graceful guard**: a server built +WITHOUT the ``bss-ledger`` feature (or a plain ``cf-gears-example-server`` on +``PATH``) does not mount these routes, so the probe would 404 and the whole +module skips rather than emitting red failures for endpoints that are simply not +served by that binary. This mirrors the file-storage module's staging pattern. """ +import datetime import os +import uuid import httpx import pytest @@ -19,6 +27,23 @@ # is a literal full path under this prefix. API_BASE = "/bss-ledger/v1" +# ── Tenant identities (must match config/e2e-local.yaml static-authn-plugin) ─ +# +# TENANT_A: the caller token ``e2e-token-tenant-a`` authenticates as this id. +# It is the AM platform-root (``account-management.config.bootstrap.root_id``), +# a valid ledger "seller" — so its ledger can be provisioned and posted to. +# +# TENANT_B: the token ``e2e-token-tenant-b`` authenticates as a DIFFERENT root +# that is NOT inside tenant A's subtree. It is the foreign caller the +# cross-tenant / no-existence-leak (BOLA) tests read as. +TENANT_A_ID = "00000000-df51-5b42-9538-d2b56b7ee953" +TENANT_B_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + +# A neutral third-party payer for seeded invoices — deliberately neither tenant +# A nor B, so the cross-tenant read stays purely about a FOREIGN SELLER (tenant +# B) not seeing tenant A's ledger (not muddied by any payer-facing visibility). +SEED_PAYER_ID = "22222222-2222-2222-2222-222222222222" + @pytest.fixture(scope="session", autouse=True) def require_ledger_mounted(): @@ -26,8 +51,8 @@ def require_ledger_mounted(): Keys on ``GET /bss-ledger/v1/accounts`` (the read-only chart-of-accounts surface — the simplest authenticated GET). A 404 means the gear's routes are - not mounted (it is an opt-in gear that may not be built into this server); a - connection error means the server is down. Either way: skip, don't fail. + not mounted (a binary built WITHOUT the ``bss-ledger`` feature); a connection + error means the server is down. Either way: skip, don't fail. The probe is **authenticated**: the API gateway returns 401 (not 404) for an unauthenticated request to any unknown path, so an auth-less probe could not @@ -44,11 +69,136 @@ def require_ledger_mounted(): pytest.skip(f"cf-gears-server not reachable at {base_url}: {exc}") if r.status_code == 404: pytest.skip( - "bss-ledger REST endpoints are not mounted — the gear is an opt-in " - "gear not built into this server (no registered_gears entry)." + "bss-ledger REST endpoints are not mounted — this server was built " + "without the `bss-ledger` cargo feature." ) @pytest.fixture def api_base(): return API_BASE + + +@pytest.fixture +def auth_headers_tenant_b(): + """Headers with the tenant-B bearer token. + + ``e2e-token-tenant-b`` authenticates as ``TENANT_B_ID`` (config/e2e-local.yaml + static-authn-plugin), a root outside tenant A's subtree. Used by the + cross-tenant no-existence-leak tests to read as a foreign seller. + """ + return {"Authorization": "Bearer e2e-token-tenant-b"} + + +# ── Seeding helpers (genuine cross-tenant BOLA setup) ──────────────────────── + + +def _quarter(month: int) -> int: + """1-based fiscal quarter for a 1-based month (Jan-Mar → Q1, …).""" + return (month - 1) // 3 + 1 + + +def _provision_seller(base_url: str, headers: dict, tenant_id: str) -> httpx.Response: + """Idempotently provision ``tenant_id``'s ledger (chart of accounts + calendar). + + Seeds the account classes an invoice-with-tax needs (AR / REVENUE / + TAX_PAYABLE, plus SUSPENSE as the unmapped fallback) in USD, a monthly fiscal + calendar, and the current period. Provisioning is idempotent — re-provisioning + an already-seeded tenant returns 200 with ``accounts_existing`` — so this is + safe to call on every run. + """ + body = { + "tenant_id": tenant_id, + "accounts": [ + {"account_class": "AR", "currency": "USD", "normal_side": "DR"}, + {"account_class": "REVENUE", "currency": "USD", "normal_side": "CR"}, + {"account_class": "TAX_PAYABLE", "currency": "USD", "normal_side": "CR"}, + {"account_class": "SUSPENSE", "currency": "USD", "normal_side": "DR"}, + ], + "currency_scales": [], + "fiscal_calendar": {"timezone": "UTC", "granularity": "MONTH", "fy_start": 1}, + } + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + return client.post( + f"{base_url}{API_BASE}/provisioning", headers=headers, json=body + ) + + +def _post_invoice( + base_url: str, headers: dict, tenant_id: str, payer_tenant_id: str +) -> httpx.Response: + """Post one balanced invoice into ``tenant_id``'s ledger; return the response. + + Dates and ``period_id`` are derived from the current UTC date so the entry + lands in the period provisioning just opened. A fresh ``invoice_id`` + + ``correlation_id`` per call sidesteps idempotent-replay ambiguity (a re-post + of the same invoice would return 200-replay, not a fresh 201). + """ + today = datetime.datetime.now(datetime.timezone.utc).date() + period_id = f"{today.year:04d}{today.month:02d}" + body = { + "tenant_id": tenant_id, + "invoice_id": f"E2E-BOLA-{uuid.uuid4()}", + "payer_tenant_id": payer_tenant_id, + "effective_at": today.isoformat(), + "due_date": (today + datetime.timedelta(days=30)).isoformat(), + "period_id": period_id, + "items": [ + { + "amount_minor_ex_tax": 1000, + "currency": "USD", + "revenue_stream": "subscription", + "catalog_class": "REVENUE", + "gl_code": "4000", + } + ], + "tax": [ + { + "amount_minor": 200, + "currency": "USD", + "tax_jurisdiction": "US-CA", + "tax_filing_period": f"{today.year}Q{_quarter(today.month)}", + } + ], + "correlation_id": str(uuid.uuid4()), + } + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + return client.post( + f"{base_url}{API_BASE}/journal-entries", headers=headers, json=body + ) + + +@pytest.fixture(scope="module") +def seeded_entry(): + """The id of a journal entry that genuinely exists in TENANT_A's ledger. + + Provisions TENANT_A (idempotent) then posts one invoice as TENANT_A, returning + the created ``entry_id``. Reads base URL / token from the environment (like the + session-scoped probe) so it can stay module-scoped. + + If the stack cannot seed — provisioning or the post is not accepted (e.g. AM is + not wired to mark TENANT_A a seller, or the current period is closed) — the + dependent cross-tenant test SKIPS rather than fails: a seeding gap is an infra + concern, not a BOLA regression. + """ + base_url = os.getenv("E2E_BASE_URL", "http://localhost:8086") + headers = { + "Authorization": f"Bearer {os.getenv('E2E_AUTH_TOKEN', 'e2e-token-tenant-a')}" + } + + prov = _provision_seller(base_url, headers, TENANT_A_ID) + if prov.status_code not in (200, 201): + pytest.skip( + f"cannot seed: provisioning TENANT_A returned {prov.status_code}: {prov.text}" + ) + + posted = _post_invoice(base_url, headers, TENANT_A_ID, SEED_PAYER_ID) + if posted.status_code not in (200, 201): + pytest.skip( + f"cannot seed: posting a journal entry returned {posted.status_code}: {posted.text}" + ) + + entry_id = posted.json().get("entry_id") + if not entry_id: + pytest.skip(f"cannot seed: post response carried no entry_id: {posted.text}") + return entry_id diff --git a/testing/e2e/gears/bss/ledger/test_ledger_seams.py b/testing/e2e/gears/bss/ledger/test_ledger_seams.py index 009ac1794..11c336274 100644 --- a/testing/e2e/gears/bss/ledger/test_ledger_seams.py +++ b/testing/e2e/gears/bss/ledger/test_ledger_seams.py @@ -13,6 +13,8 @@ import httpx import pytest +from .conftest import TENANT_A_ID, TENANT_B_ID + REQUEST_TIMEOUT = 10.0 # The tenant the standard E2E token (`e2e-token-tenant-a`) authenticates as — @@ -21,7 +23,10 @@ # the seam tests pass this (in-scope) id: an absent resource then reads as a # clean 404 (not a scope miss), and list/read surfaces resolve to the caller's # own — possibly empty — data. -TENANT_A = "00000000-df51-5b42-9538-d2b56b7ee953" +TENANT_A = TENANT_A_ID +# The foreign tenant used by the cross-tenant no-existence-leak tests — a +# different root the token `e2e-token-tenant-b` authenticates as (see conftest). +TENANT_B = TENANT_B_ID # Write surfaces (POST) whose route presence + auth gate we probe. Full paths # under the gear's `/bss-ledger/v1` base (design §3.3 / the axum routers). @@ -57,20 +62,67 @@ def test_accounts_read_is_reachable(base_url, auth_headers, api_base): assert isinstance(r.json(), (dict, list)) -def test_unknown_journal_entry_is_404(base_url, auth_headers, api_base): - """An absent (or foreign-scoped) journal entry reads as 404, not 500/leak. +def test_journal_entry_not_leaked_across_tenants( + base_url, auth_headers, auth_headers_tenant_b, api_base, seeded_entry +): + """A journal entry seeded in TENANT_A's ledger is invisible to TENANT_B. + + Genuine cross-tenant BOLA / no-existence-leak. The `seeded_entry` fixture + posts a REAL entry as TENANT_A; TENANT_B then reads that SAME id and MUST get + the same 404 it gets for a random id — existence is not leaked across tenants. - A random entry id the caller's tenant never posted must be indistinguishable - from one outside its authorized subtree — the same 404 either way (no - existence leak / BOLA). + This replaces the earlier check that issued a random uuid under a SINGLE + tenant and asserted 404: that proved the absent→404 mapping but nothing about + cross-tenant isolation (a random id is trivially absent for everyone). Here + the id demonstrably EXISTS (the owner baseline below is 200), so TENANT_B's + 404 is a true isolation signal. """ - entry_id = uuid.uuid4() with httpx.Client(timeout=REQUEST_TIMEOUT) as client: - r = client.get( - f"{base_url}{api_base}/journal-entries/{entry_id}?tenant_id={TENANT_A}", + # Baseline: the owner (TENANT_A) DOES see the entry — it genuinely exists, + # so the cross-tenant 404 below cannot be an accident of an absent id. + owner = client.get( + f"{base_url}{api_base}/journal-entries/{seeded_entry}?tenant_id={TENANT_A}", headers=auth_headers, ) - assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}" + assert owner.status_code == 200, ( + f"owner (TENANT_A) must see the seeded entry, " + f"got {owner.status_code}: {owner.text}" + ) + + # Cross-tenant: TENANT_B reads the SAME real id within its own scope — the + # foreign entry resolves to None ⇒ 404 (no existence leak). + cross = client.get( + f"{base_url}{api_base}/journal-entries/{seeded_entry}?tenant_id={TENANT_B}", + headers=auth_headers_tenant_b, + ) + assert cross.status_code == 404, ( + f"TENANT_B must NOT see TENANT_A's entry (expected 404), " + f"got {cross.status_code}: {cross.text}" + ) + + # Impersonation: TENANT_B explicitly targets TENANT_A's scope by passing + # tenant_id=TENANT_A. The PEP subtree clamp still hides the row — either a + # 404 (no-existence-leak) or a 403 (hard scope deny) is acceptable; a 200 + # would be the BOLA break. + impersonate = client.get( + f"{base_url}{api_base}/journal-entries/{seeded_entry}?tenant_id={TENANT_A}", + headers=auth_headers_tenant_b, + ) + assert impersonate.status_code in (403, 404), ( + f"TENANT_B targeting TENANT_A's scope must be denied (403/404), " + f"got {impersonate.status_code}: {impersonate.text}" + ) + + # Equivalence: a random id under TENANT_B is ALSO a 404 — proving the + # foreign REAL id is indistinguishable from a never-existed one. + random_404 = client.get( + f"{base_url}{api_base}/journal-entries/{uuid.uuid4()}?tenant_id={TENANT_B}", + headers=auth_headers_tenant_b, + ) + assert random_404.status_code == cross.status_code == 404, ( + "a foreign real id and a random id must both read as 404 " + f"(random={random_404.status_code}, cross={cross.status_code})" + ) def test_post_journal_entry_requires_auth(base_url, api_base): @@ -154,10 +206,14 @@ def test_write_routes_are_mounted(base_url, auth_headers, api_base, path): @pytest.mark.parametrize("path", GET_BY_ID_PATHS) def test_get_by_id_absent_is_404(base_url, auth_headers, api_base, path): - """A random id on each read-by-id surface reads as a scoped 404. - - Absent and foreign-scoped are indistinguishable — the same 404 either way - (no existence leak / BOLA). + """An id the caller's own tenant never created reads as a scoped 404. + + A route-contract check: each read-by-id surface is mounted and maps an absent + id to a clean 404 (not a 500 or a router-miss). This does NOT by itself prove + cross-tenant isolation — a random id is trivially absent for everyone. The + genuine no-existence-leak (BOLA) property is covered by + ``test_journal_entry_not_leaked_across_tenants``, which seeds a REAL entry as + one tenant and asserts another tenant gets the same 404. """ url = f"{base_url}{api_base}{path.format(id=uuid.uuid4())}?tenant_id={TENANT_A}" with httpx.Client(timeout=REQUEST_TIMEOUT) as client: