refinement phase 6

This commit is contained in:
hamid
2026-07-13 17:03:45 +03:30
parent d4147342da
commit 70268ecc06
33 changed files with 7262 additions and 53 deletions
+28 -4
View File
@@ -65,10 +65,29 @@ ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created). Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created).
- **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin · - **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin ·
`404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused. `404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused.
- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; posts the balanced ledger reversal via - **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; the refund row is persisted (`approved`)
b10's helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is **before** the external channel executes (crash-window fix), then the balanced ledger reversal posts via b10's
deferred to reconciliation for BNPL/manual. `ticketId` is required only when `refund_ticket_required` config is helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is
on (off until b15). Notifies the customer. **deferred to reconciliation for BNPL/manual** (settled later via `confirm_settlement`, below). `ticketId` is
optional — one is auto-opened when omitted (b15), so a refund is always ticket-anchored. Notifies the customer.
### `POST api/v1/admin_refunds/{id}/confirm_settlement`
- **Purpose:** reconciliation confirmed the customer cash-back for a `processing` BNPL/manual refund — transitions
it `processing → succeeded`, stamps the settled instant, and posts the deferred `refund_payable ↔ escrow_held`
clearing in the same commit. (Also reached automatically by the BNPL provider cash-back callback.)
- **Auth:** admin · **Rate-limited:** yes (sensitive) · **Idempotent:** a replay against an already-`succeeded`
refund is a no-op success (the clearing never posts twice).
- **Request body:** none (id in the route).
- **Success `200` (`data`):** `RefundSettlement` — `{ "refundId": 7, "bookingId": 42, "status": "succeeded",
"completedAt": "2026-08-12T10:00:00Z" }`.
- **Failure:** `404` refund not found · `409` refund not in `processing` (e.g. still `approved`, already `failed`).
### `POST api/v1/admin_refunds/{id}/mark_failed`
- **Purpose:** reconciliation reported the BNPL/manual customer cash-back did **not** land — transitions the
`processing` refund to `failed`. No ledger moves (the clearing was never posted for a processing refund).
- **Auth:** admin · **Rate-limited:** yes · **Idempotent:** a replay against an already-`failed` refund is a no-op.
- **Request body:** `{ "reason": "bank_rejected" }` (optional).
- **Success `200` (`data`):** `RefundSettlement` (as above, `status: "failed"`). **Failure:** `404` · `409` not `processing`.
### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=` ### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=`
- **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100). - **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100).
@@ -125,6 +144,11 @@ ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
## Changelog ## Changelog
- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice). - b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice).
- refinement-phase-6 — added `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` (the BNPL/manual
`processing → succeeded/failed` settlement, `RefundSettlement` shape), so the deferred `refund_payable ↔
escrow_held` clearing is now reachable. Refunds are persisted before the channel call (crash-window fix). The
`refund_ticket_required` gate was retired (a refund ticket is always auto-opened). Forward-dep FKs added on
`refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`, `invoices.partner_center_id`.
--- ---
+220
View File
@@ -3606,6 +3606,172 @@
] ]
} }
}, },
"/api/v1/admin_refunds/{id}/confirm_settlement": {
"post": {
"tags": [
"AdminRefunds"
],
"operationId": "AdminRefunds_ConfirmSettlement",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
},
"x-position": 1
}
],
"responses": {
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
}
}
}
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"403": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"500": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfRefundSettlementResult"
}
}
}
}
},
"security": [
{
"Bearer": []
}
]
}
},
"/api/v1/admin_refunds/{id}/mark_failed": {
"post": {
"tags": [
"AdminRefunds"
],
"operationId": "AdminRefunds_MarkFailed",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
},
"x-position": 1
}
],
"requestBody": {
"x-name": "body",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MarkRefundFailedBody"
}
}
},
"required": true,
"x-position": 2
},
"responses": {
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
}
}
}
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"403": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"500": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfRefundSettlementResult"
}
}
}
}
},
"security": [
{
"Bearer": []
}
]
}
},
"/api/v1/admin/reviews/moderation_queue": { "/api/v1/admin/reviews/moderation_queue": {
"get": { "get": {
"tags": [ "tags": [
@@ -17189,6 +17355,60 @@
} }
} }
}, },
"ApiResultOfRefundSettlementResult": {
"allOf": [
{
"$ref": "#/components/schemas/ApiResult"
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"data": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/RefundSettlementResult"
}
]
}
}
}
]
},
"RefundSettlementResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"refundId": {
"type": "integer",
"format": "int64"
},
"bookingId": {
"type": "integer",
"format": "int64"
},
"status": {
"type": "string"
},
"completedAt": {
"type": "string",
"format": "date-time",
"nullable": true
}
}
},
"MarkRefundFailedBody": {
"type": "object",
"description": "The mark-failed body (the id comes from the route).",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"nullable": true
}
}
},
"ApiResultOfPagedResultOfModerationQueueItemDto": { "ApiResultOfPagedResultOfModerationQueueItemDto": {
"allOf": [ "allOf": [
{ {
@@ -0,0 +1,31 @@
# After refinement-phase-6 — Money-path correctness completion
**Track:** backend (money path). Gate: `dotnet build` 0 new warnings · `dotnet test` 396 pass (+13). Migration
`RefinementPhase6MoneyFks` scaffolded (additive: 3 FK sets + invoice index + delete of a dead config seed row).
## What's now live (new endpoints — admin)
- **`POST api/v1/admin_refunds/{id}/confirm_settlement`** — settle a `processing` BNPL/manual refund
(`processing → succeeded`, posts the deferred `refund_payable ↔ escrow_held` clearing). Idempotent.
- **`POST api/v1/admin_refunds/{id}/mark_failed`** — fail a `processing` refund (no ledger). Body
`{ "reason": "..." }` (optional). Idempotent.
- Both return `RefundSettlement` `{ refundId, bookingId, status, completedAt }`. Contract:
`dev/contracts/domains/refunds-invoices.md`; snapshot `swagger.v1.json` refreshed.
## What changed under the hood (no client-visible shape change)
- The **BNPL provider cash-back callback** now auto-settles the matching `processing` refund (a
`refund/revert completed|confirmed|settled` / `cashback` event) — so a real BNPL revert reaches `succeeded`
without an admin click.
- **Refund create** persists the row (`approved`) before the external channel call (crash-window fix) — an
interrupted refund is now a reconcilable `approved` row, not a lost execution.
- **Forward-dep FKs** added: `refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`,
`invoices.partner_center_id` (+ index). All nullable, `NO ACTION`. No behavior change; integrity backstop only.
- **Audit:** `Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification` are now `IAuditable`
→ admin decisions leave an `audit_logs` diff row (IBAN redacted).
- **Retired** the orphaned `refund_ticket_required` config key (a refund ticket is always auto-opened).
## Frontend notes
- The customer refund-status flow is **unchanged** — a BNPL refund still shows `processing` with the ETA, and now
actually flips to `succeeded` once settled (via admin confirm or the provider callback). No new client work
required for the customer side.
- If/when an admin refund console surfaces settlement, the two new endpoints are the actions (staff-gated,
`sensitive` rate policy).
@@ -0,0 +1,95 @@
# Refinement Phase 6 — Money-path correctness completion — Report (2026-07-13)
**Track:** backend (money path) · **Depends on:** nothing hard (do before real BNPL/manual refunds — phase 8)
· **Gate:** `dotnet build` 0 new warnings · `dotnet test` 396 pass (383 prior + 13 new).
## The headline fix (6.1) — the unreachable BNPL/manual refund settlement is now wired
Before this phase, a card refund cleared its `refund_payable ↔ escrow_held` leg immediately, but a
BNPL-revert / manual-bank refund was left in `processing` with the clearing "deferred to reconciliation" — and
**no reconciliation path existed**: `Refund.MarkSucceededAsync` had zero callers, and nothing performed
`processing → succeeded`. Every BNPL/manual refund permanently overstated `escrow_held` and stranded
`refund_payable`; the ledger could never reconcile with the bank.
Now:
- **`ConfirmRefundSettlementCommand`** (`Features/Refunds/Commands/ConfirmRefundSettlement/`) transitions
`processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
**same commit**. It runs under the same `booking:{id}:refund` lock as `CreateRefundCommand` and **re-reads the
tracked refund inside the lock**, so a racing/replayed confirm sees committed truth and no-ops (never
double-clears). Idempotent: an already-`succeeded` refund is a no-op success.
- **`MarkRefundSettlementFailedCommand`** (`.../MarkRefundSettlementFailed/`) is the counterpart —
`processing → failed`, no ledger moves.
- **Admin surface:** `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` on `AdminRefundsController`.
- **BNPL callback branch:** `HandleBnplCallback` gained a `RefundConfirmed` action. A provider event whose type
says the revert/refund cash-back **completed/confirmed/settled** (or "cashback") resolves the `processing`
refund created against that order's `payment_transaction` (`GetProcessingRefundIdForTransactionAsync`) and
dispatches `ConfirmRefundSettlementCommand`. `ResolveAction` checks this **before** the order-level `settle`
branch so `revert_settled` doesn't fall through.
- Domain: the misnamed `Refund.MarkSucceededAsync` (not async, uncalled) was renamed `MarkSucceededReconciled`.
**Proof:** `RefundSettlementTests` — a BNPL refund lands `processing` (3 reversal legs, no clearing) → confirm →
`succeeded`, clearing posts, the ledger reconciles (Σdebit = Σcredit, `refund_payable` fully drained,
`escrow_held` credited back); a replayed confirm stays at 5 legs (no double-clear); `mark_failed` leaves 3 legs
and blocks a later confirm (409).
## 6.4 — crash-window closed
`CreateRefundCommand` now persists the refund row (`approved`) **and commits** *before* calling the external
channel; then executes the channel against the persisted row and commits the outcome (succeeded/processing/failed
+ ledger). Same claim-first / execute-second shape the webhook handler uses — a crash between provider success
and our commit now leaves a reconcilable `approved` row instead of a silently-executed refund with no record.
## 6.2 — forward-dep FKs added (additive migration `RefinementPhase6MoneyFks`)
Real FKs (all nullable, `ON DELETE NO ACTION`) on the columns b11 shipped FK-less "until the target ships"
(the targets all shipped in b13/b15): `refunds.ticket_id → messaging.Tickets`,
`nurse_clawbacks.original_payout_id`/`recovered_in_payout_id → payouts.NursePayouts`,
`invoices.partner_center_id → partner.PartnerCenters` (**+ index**). The three now-false config-doc comments were
corrected. Referential integrity no longer rests on application discipline alone.
## 6.3 — IAuditable extended to the admin-decided money & trust entities
`Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification` are now `IAuditable`, so the
`AuditFieldInterceptor` writes an append-only `audit_logs` diff row on create + every admin decision (approve /
reject / process / settle, and the verification `is_verified` decision). `NursePayout.IbanSnapshot` (encrypted)
carries `[AuditRedacted]` so the diff records a marker, never the plaintext IBAN.
## 6.6 — orphaned config key retired
`refund_ticket_required` (seed row id 19) had no consumer left (b15 unconditionally auto-opens a refund ticket).
The seed row is deleted by the migration and the false description removed; the test-host stub was dropped.
## 6.5 — the previously-untested admin money paths now have tests (+13 tests)
- **`ClawbackWriteOffTests`** — write-off posts a balanced `DEBIT bad_debt / CREDIT nurse_clawback_receivable`
group and resolves the clawback; 404 unknown; 409 second write-off. (Was zero-coverage.)
- **`Racing_same_key_insert_is_caught_as_an_idempotent_no_op`** (added to `PaymentWebhookTests`) — a provider
that omits `external_event_id` skips the read-dedup, so the `(provider_code, external_event_id)` UNIQUE is the
sole backstop (the exact state a true concurrent insert reaches); a colliding insert hits `DbUpdateException`
and is treated as an idempotent duplicate no-op (no confirm, no ledger).
- **`MessagingInternalBoundaryTests`** (Foundation, handler-level) — the `is_internal` boundary: user thread view
strips internal notes; admin view returns them; non-staff admin-view request is forbidden; non-staff can't
post an internal note; a staff internal note never surfaces in the user view.
- **`RefundSettlementTests`** — the 6.1 settlement (both channels) + idempotency + mark_failed.
## Test-infra note (why a refund test host changed)
Adding the `refunds.ticket_id` FK means SQLite (which EF enables FK enforcement on) rejects a refund whose
`ticket_id` points at a non-existent ticket. `RefundsTestHost` now **seeds a real `Ticket`** and exposes
`Senders()` whose `OpenTicket` hook returns that real id (replacing the `TestSenders.WithTicketHooks()` fake id 1
in the refund tests). `PaymentsTestHost`/`PayoutsTestHost` were unaffected (they leave the new FK columns null).
## Contracts / docs updated (same change)
- `dev/contracts/domains/refunds-invoices.md` — the two new endpoints + `RefundSettlement` shape + changelog +
corrected the `refund_ticket_required` note.
- `dev/contracts/openapi/swagger.v1.json` — refreshed (additive: the two routes + `RefundSettlementResult`).
- `server/CLAUDE.md` — refunds/payments section (settlement wiring + crash-window + FKs + retired config), the
audit-interceptor note (expanded IAuditable set + `[AuditRedacted]`), and the feature map.
## Follow-ups (out of scope, for later phases)
- **A `mark_failed` after a successful provider revert** leaves the reversal ledger posted with no clearing (the
money is genuinely in limbo — an ops reconciliation case). Deliberate: the reversal is not un-posted.
- The BNPL/PSP mocks stay until phase 8 (external rails); the settlement path is now complete behind them.
- `audit_logs` growth (2.3 grows it faster) — retention/archival is phase 9 (§7.4).
+16 -6
View File
@@ -88,7 +88,7 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
src/ src/
├── Core/ ├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) │ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) │ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/confirm-settlement/mark-failed [refinement-phase-6: the BNPL/manual `processing → succeeded` clearing]/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/ ├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService) │ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
@@ -131,7 +131,10 @@ worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`**
`NotificationRetentionHostedService` (the retention/`IJobScheduler` seam) is registered as a hosted `NotificationRetentionHostedService` (the retention/`IJobScheduler` seam) is registered as a hosted
service there too. Other domains call these contracts; they never re-create the tables. The service there too. Other domains call these contracts; they never re-create the tables. The
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity `AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
(currently `PlatformConfig`) in the same transaction as the change. (`PlatformConfig`, `PartnerCenter`, `Review`, and — refinement-phase-6 — the admin-decided money & trust
entities `Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`; encrypted columns
like `NursePayout.IbanSnapshot` carry `[AuditRedacted]` so the diff records a marker, never plaintext) in the
same transaction as the change.
**Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine, **Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine,
the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded
@@ -355,16 +358,23 @@ per-domain repos `IRefundRepository` + `IInvoiceRepository` on `IUnitOfWork`; co
external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card = external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card =
immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config
business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only
once the customer cash-back confirms. once the customer cash-back confirms — **reached (refinement-phase-6) by `ConfirmRefundSettlementCommand`**
(admin `POST admin_refunds/{id}/confirm_settlement` + the BNPL cash-back callback branch), which transitions
`processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
same commit (idempotent under `booking:{id}:refund`); `MarkRefundSettlementFailedCommand` (`.../mark_failed`)
is the counterpart. **The refund row is now persisted (approved) *before* the external channel call** — the
crash-window fix (claim-first / execute-second), matching the webhook handler.
- **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes - **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes
`vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0), `vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0),
never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row
(locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per (locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per
booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits
to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`). to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`).
- **Forward-deps as nullable columns, no FK.** `refunds.ticket_id` (tickets → b15; "ticket required" is the - **Forward-dep columns — FKs added in refinement-phase-6.** `refunds.ticket_id` (→ `messaging.Tickets`),
config-gated `refund_ticket_required` rule, off by default), `nurse_clawbacks.original_payout_id` / `nurse_clawbacks.original_payout_id` / `recovered_in_payout_id` (→ `payouts.NursePayouts`),
`recovered_in_payout_id` (nurse_payouts → b13), `invoices.partner_center_id` (partner_centers → b15). The `invoices.partner_center_id` (→ `partner.PartnerCenters`, + index) now carry real FKs (`ON DELETE NO ACTION`;
all nullable) — b15 unconditionally auto-opens the refund ticket so `refunds.ticket_id` is always non-null, and
the orphaned `refund_ticket_required` config key was retired (its rule had no consumer left). The
data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is
introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real
seam definition**. seam definition**.
@@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
using Baya.Application.Features.Refunds.Commands.CreateRefund; using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
using Baya.Application.Features.Refunds.Queries.ListRefunds; using Baya.Application.Features.Refunds.Queries.ListRefunds;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds; using Baya.Application.Models.Refunds;
@@ -37,4 +39,20 @@ public sealed class AdminRefundsController(ISender sender) : BaseController
[ProducesOkApiResponseType<PagedResult<RefundListItemDto>>] [ProducesOkApiResponseType<PagedResult<RefundListItemDto>>]
public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken) public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken)); => OperationResult(await sender.Send(query, cancellationToken));
// Reconciliation confirmed the customer cash-back for a processing BNPL/manual refund — settle it (posts the
// deferred refund_payable ↔ escrow_held clearing). Idempotent.
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<RefundSettlementResult>]
public async Task<IActionResult> ConfirmSettlement(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ConfirmRefundSettlementCommand(id), cancellationToken));
// Reconciliation reported the customer cash-back did not land — fail the processing refund (no ledger moves).
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<RefundSettlementResult>]
public async Task<IActionResult> MarkFailed(long id, MarkRefundFailedBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new MarkRefundSettlementFailedCommand(id, body.Reason), cancellationToken));
} }
/// <summary>The mark-failed body (the id comes from the route).</summary>
public record MarkRefundFailedBody(string? Reason);
@@ -29,6 +29,15 @@ public interface IRefundRepository
/// <summary>Tracked pending clawback — for the admin write-off. Null when absent or already resolved.</summary> /// <summary>Tracked pending clawback — for the admin write-off. Null when absent or already resolved.</summary>
Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken); Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken);
/// <summary>Tracked refund row — for the settlement confirm/fail transition of a <c>processing</c> refund.
/// Null when absent.</summary>
Task<Refund?> GetTrackedRefundByIdAsync(long id, CancellationToken cancellationToken);
/// <summary>The id of the <c>processing</c> refund for a captured transaction — the BNPL cash-back callback
/// resolves the refund it should settle from the same <c>payment_transaction</c> the revert was created
/// against. Null when there is no in-flight (processing) refund for it.</summary>
Task<long?> GetProcessingRefundIdForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken);
Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken); Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>The customer-facing status of a single refund, with the owning customer's user id for tenancy. /// <summary>The customer-facing status of a single refund, with the owning customer's user id for tenancy.
@@ -7,8 +7,10 @@ using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Bnpl.Commands.RevertBnplOrder; using Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder; using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
using Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder; using Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
using Baya.Application.Models.Bnpl; using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments; using Baya.Domain.Entities.Payments;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -22,7 +24,7 @@ internal sealed class HandleBnplCallbackCommandHandler(
IDateTimeProvider dateTimeProvider) IDateTimeProvider dateTimeProvider)
: IRequestHandler<HandleBnplCallbackCommand, OperationResult<BnplCallbackResult>> : IRequestHandler<HandleBnplCallbackCommand, OperationResult<BnplCallbackResult>>
{ {
private enum CallbackAction { None, Verify, Settle, Revert } private enum CallbackAction { None, Verify, Settle, Revert, RefundConfirmed }
public async ValueTask<OperationResult<BnplCallbackResult>> Handle(HandleBnplCallbackCommand request, CancellationToken cancellationToken) public async ValueTask<OperationResult<BnplCallbackResult>> Handle(HandleBnplCallbackCommand request, CancellationToken cancellationToken)
{ {
@@ -85,7 +87,7 @@ internal sealed class HandleBnplCallbackCommandHandler(
return Success(WebhookProcessingStatus.Processed, isDuplicate: true); return Success(WebhookProcessingStatus.Processed, isDuplicate: true);
} }
var dispatched = await DispatchAsync(action, bnpl.Id, verification.ExternalEventId, rawBody, cancellationToken); var dispatched = await DispatchAsync(action, bnpl, verification.ExternalEventId, rawBody, cancellationToken);
if (dispatched) if (dispatched)
webhookEvent.MarkProcessed(bnpl.PaymentTransactionId, now); webhookEvent.MarkProcessed(bnpl.PaymentTransactionId, now);
@@ -96,25 +98,48 @@ internal sealed class HandleBnplCallbackCommandHandler(
return Success(webhookEvent.ProcessingStatus, isDuplicate: false); return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
} }
private async Task<bool> DispatchAsync(CallbackAction action, long bnplTransactionId, string eventId, string rawBody, CancellationToken cancellationToken) private async Task<bool> DispatchAsync(CallbackAction action, BnplTransaction bnpl, string eventId, string rawBody, CancellationToken cancellationToken)
{ {
var result = action switch switch (action)
{ {
CallbackAction.Verify => (await sender.Send(new VerifyBnplOrderCommand(bnplTransactionId, rawBody), cancellationToken)).IsSuccess, case CallbackAction.Verify:
CallbackAction.Settle => (await sender.Send(new SettleBnplOrderCommand(bnplTransactionId, $"bnpl-settle-{bnplTransactionId}-{eventId}", rawBody), cancellationToken)).IsSuccess, return (await sender.Send(new VerifyBnplOrderCommand(bnpl.Id, rawBody), cancellationToken)).IsSuccess;
CallbackAction.Revert => (await sender.Send(new RevertBnplOrderCommand(bnplTransactionId, RefundPercentage: 1m, TicketId: null, ReasonNotes: "provider_callback", rawBody), cancellationToken)).IsSuccess, case CallbackAction.Settle:
_ => false return (await sender.Send(new SettleBnplOrderCommand(bnpl.Id, $"bnpl-settle-{bnpl.Id}-{eventId}", rawBody), cancellationToken)).IsSuccess;
}; case CallbackAction.Revert:
return result; return (await sender.Send(new RevertBnplOrderCommand(bnpl.Id, RefundPercentage: 1m, TicketId: null, ReasonNotes: "provider_callback", rawBody), cancellationToken)).IsSuccess;
case CallbackAction.RefundConfirmed:
{
// The provider confirmed the customer cash-back for an earlier revert. Resolve the processing
// refund created against this order's payment_transaction and settle it (posts the deferred
// refund_payable ↔ escrow_held clearing). No in-flight refund → retryable (not a silent success).
var refundId = await unitOfWork.RefundRepository.GetProcessingRefundIdForTransactionAsync(bnpl.PaymentTransactionId, cancellationToken);
if (refundId is null)
return false;
return (await sender.Send(new ConfirmRefundSettlementCommand(refundId.Value), cancellationToken)).IsSuccess;
}
default:
return false;
}
} }
private static CallbackAction ResolveAction(string eventType) private static CallbackAction ResolveAction(string eventType)
{ {
// A "revert/refund confirmed/completed/settled" (or "cashback") event closes an EARLIER revert's async
// customer cash-back — distinct from the order-level "settle". Checked first so "revert_settled" doesn't
// fall through to the order-settle branch below.
var isRevertScoped = eventType.Contains("revert", StringComparison.OrdinalIgnoreCase)
|| eventType.Contains("refund", StringComparison.OrdinalIgnoreCase);
if (eventType.Contains("cashback", StringComparison.OrdinalIgnoreCase)
|| (isRevertScoped && (eventType.Contains("complet", StringComparison.OrdinalIgnoreCase)
|| eventType.Contains("confirm", StringComparison.OrdinalIgnoreCase)
|| eventType.Contains("settl", StringComparison.OrdinalIgnoreCase))))
return CallbackAction.RefundConfirmed;
if (eventType.Contains("settl", StringComparison.OrdinalIgnoreCase)) if (eventType.Contains("settl", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Settle; return CallbackAction.Settle;
if (eventType.Contains("verif", StringComparison.OrdinalIgnoreCase)) if (eventType.Contains("verif", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Verify; return CallbackAction.Verify;
if (eventType.Contains("revert", StringComparison.OrdinalIgnoreCase) || eventType.Contains("refund", StringComparison.OrdinalIgnoreCase)) if (isRevertScoped)
return CallbackAction.Revert; return CallbackAction.Revert;
return CallbackAction.None; return CallbackAction.None;
} }
@@ -0,0 +1,79 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
/// <summary>
/// Closes the money loop left open by a BNPL/manual refund. A card refund clears its
/// <c>refund_payable ↔ escrow_held</c> leg immediately at create time; a BNPL/manual refund sits in
/// <c>processing</c> until the provider/bank confirms the customer actually got the cash — at which point this
/// posts the deferred clearing so the ledger reconciles with the bank. Runs under the same
/// <c>booking:{id}:refund</c> lock as <c>CreateRefundCommand</c>, and re-reads the refund row <b>inside</b> the
/// lock so a racing/replayed confirm sees the committed (already-succeeded) state and no-ops instead of
/// double-clearing.
/// </summary>
internal sealed class ConfirmRefundSettlementCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IDateTimeProvider dateTimeProvider,
INotificationDispatcher notifications)
: IRequestHandler<ConfirmRefundSettlementCommand, OperationResult<RefundSettlementResult>>
{
public async ValueTask<OperationResult<RefundSettlementResult>> Handle(ConfirmRefundSettlementCommand request, CancellationToken cancellationToken)
{
// A no-tracking read first to key the lock (and carry the customer's user id for the notification).
var projection = await unitOfWork.RefundRepository.GetStatusAsync(request.RefundId, cancellationToken);
if (projection is null)
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
await using var _ = await distributedLock.AcquireAsync($"booking:{projection.Refund.BookingId}:refund", cancellationToken);
// Load the tracked row INSIDE the lock so its status reflects committed truth even under contention.
var refund = await unitOfWork.RefundRepository.GetTrackedRefundByIdAsync(request.RefundId, cancellationToken);
if (refund is null)
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
// Idempotent: an already-succeeded refund (a replayed callback, or a card refund that cleared at create
// time) is a no-op success — the clearing must never post twice.
if (refund.Status == RefundStatus.Succeeded)
return Result(refund);
if (refund.Status != RefundStatus.Processing)
return OperationResult<RefundSettlementResult>.ConflictResult(
$"Only a processing refund can be settled (was {refund.Status}).");
var now = dateTimeProvider.UtcNow.UtcDateTime;
refund.MarkSucceededReconciled(now);
// The customer cash-back is confirmed → clear refund_payable ↔ escrow_held for the refunded total, in the
// same commit as the status flip so the ledger can never observe a settled refund with an unposted clearing.
var clearing = LedgerPosting.RefundPayableClearing(refund.BookingId, refund.Amount, refund.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(clearing, cancellationToken);
await unitOfWork.CommitAsync();
await NotifyCustomerAsync(projection.CustomerUserId, refund, cancellationToken);
return Result(refund);
}
private async Task NotifyCustomerAsync(int customerUserId, Refund refund, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new { booking_id = refund.BookingId, refund_id = refund.Id, refund.Status });
await notifications.DispatchAsync(
new Notification(customerUserId, "refund_completed", "Refund completed",
"Your refund has been completed and the funds are on their way to you.", payload),
cancellationToken);
}
private static OperationResult<RefundSettlementResult> Result(Refund refund)
=> OperationResult<RefundSettlementResult>.SuccessResult(
new RefundSettlementResult(refund.Id, refund.BookingId, refund.Status, refund.ProcessedAt));
}
@@ -0,0 +1,15 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
/// <summary>
/// Reconciliation confirmed the customer cash-back for a <c>processing</c> BNPL/manual refund: transitions it
/// <c>processing → succeeded</c>, stamps the settled instant, and posts the deferred
/// <c>refund_payable ↔ escrow_held</c> clearing in the same commit. Reached two ways — the admin
/// <c>confirm_settlement</c> action and the BNPL provider cash-back callback. Idempotent: a replay against an
/// already-succeeded refund is a no-op success (the clearing is never posted twice).
/// </summary>
public record ConfirmRefundSettlementCommand(long RefundId) : IRequest<OperationResult<RefundSettlementResult>>;
@@ -103,15 +103,21 @@ internal sealed class CreateRefundCommandHandler(
RefundPercentageApplied = context.CancellationRefundPercentage RefundPercentageApplied = context.CancellationRefundPercentage
}; };
// Execute the channel (external, behind its seam) BEFORE persisting, so a provider refusal leaves the // Crash-window fix: persist the refund as an approved INTENT (committed) BEFORE the external channel call,
// refund row failed with no ledger. The idempotency key makes a retried channel call a no-op. // so a crash between provider success and our commit leaves a reconcilable record rather than a silently
var executed = await ExecuteChannelAsync(refund, channel, context, request.ManualBankReference, amount, priorRefunded, idempotencyKey, now, cancellationToken); // executed refund with no row. This is the same claim-first / execute-second shape the webhook handler uses.
await unitOfWork.RefundRepository.AddRefundAsync(refund, cancellationToken); await unitOfWork.RefundRepository.AddRefundAsync(refund, cancellationToken);
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
// Execute the channel (external, behind its seam) against the already-persisted row. The idempotency key
// makes a retried channel call a no-op; a provider refusal marks the row failed with no ledger.
var executed = await ExecuteChannelAsync(refund, channel, context, request.ManualBankReference, amount, priorRefunded, idempotencyKey, now, cancellationToken);
if (!executed) if (!executed)
{
await unitOfWork.CommitAsync(); // persist the failed transition; no ledger is posted
return OperationResult<CreateRefundResult>.FailureResult("channel", "The refund channel refused the reversal."); return OperationResult<CreateRefundResult>.FailureResult("channel", "The refund channel refused the reversal.");
}
// Pre-payout (clean reversal) vs post-payout (clawback receivable) fork — an Iranian IBAN transfer is // Pre-payout (clean reversal) vs post-payout (clawback receivable) fork — an Iranian IBAN transfer is
// irreversible, so a paid-out nurse's payout leg becomes owed-back, never silently absorbed. // irreversible, so a paid-out nurse's payout leg becomes owed-back, never silently absorbed.
@@ -0,0 +1,52 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
/// <summary>
/// Fails a <c>processing</c> BNPL/manual refund whose customer cash-back reconciliation did not succeed. No ledger
/// moves (the clearing was never posted for a processing refund). Runs under the <c>booking:{id}:refund</c> lock
/// and re-reads the row inside it so a racing confirm/fail is serialized and a replay no-ops.
/// </summary>
internal sealed class MarkRefundSettlementFailedCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<MarkRefundSettlementFailedCommand, OperationResult<RefundSettlementResult>>
{
public async ValueTask<OperationResult<RefundSettlementResult>> Handle(MarkRefundSettlementFailedCommand request, CancellationToken cancellationToken)
{
var projection = await unitOfWork.RefundRepository.GetStatusAsync(request.RefundId, cancellationToken);
if (projection is null)
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
await using var _ = await distributedLock.AcquireAsync($"booking:{projection.Refund.BookingId}:refund", cancellationToken);
var refund = await unitOfWork.RefundRepository.GetTrackedRefundByIdAsync(request.RefundId, cancellationToken);
if (refund is null)
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
// Idempotent: already-failed is a no-op success.
if (refund.Status == RefundStatus.Failed)
return Result(refund);
if (refund.Status != RefundStatus.Processing)
return OperationResult<RefundSettlementResult>.ConflictResult(
$"Only a processing refund can be failed (was {refund.Status}).");
refund.MarkFailed(request.Reason ?? "settlement_failed");
await unitOfWork.CommitAsync();
return Result(refund);
}
private static OperationResult<RefundSettlementResult> Result(Refund refund)
=> OperationResult<RefundSettlementResult>.SuccessResult(
new RefundSettlementResult(refund.Id, refund.BookingId, refund.Status, refund.ProcessedAt));
}
@@ -0,0 +1,14 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
/// <summary>
/// The counterpart to <c>ConfirmRefundSettlementCommand</c>: reconciliation reports that the BNPL/manual customer
/// cash-back for a <c>processing</c> refund did <b>not</b> land. Transitions <c>processing → failed</c> and records
/// the reason; posts no ledger (nothing cleared). Idempotent: a replay against an already-failed refund is a
/// no-op success.
/// </summary>
public record MarkRefundSettlementFailedCommand(long RefundId, string? Reason) : IRequest<OperationResult<RefundSettlementResult>>;
@@ -66,6 +66,15 @@ public record RefundStatusDto(
/// to <c>ICurrentUser</c>) plus the DTO. A cross-customer access is a clean not-found, never a leak.</summary> /// to <c>ICurrentUser</c>) plus the DTO. A cross-customer access is a clean not-found, never a leak.</summary>
public record RefundStatusProjection(int CustomerUserId, RefundStatusDto Refund); public record RefundStatusProjection(int CustomerUserId, RefundStatusDto Refund);
/// <summary>What the settlement transition commands return — the refund's identity, its booking, the resulting
/// status (<c>succeeded</c>/<c>failed</c>) and when it was stamped settled. Reconciliation of a BNPL/manual
/// refund's async customer cash-back.</summary>
public record RefundSettlementResult(
long RefundId,
long BookingId,
string Status,
DateTime? CompletedAt);
/// <summary>What <c>CreateRefundCommand</c> returns — the created refund's identity, channel, terminal-ish /// <summary>What <c>CreateRefundCommand</c> returns — the created refund's identity, channel, terminal-ish
/// status, decomposed legs and (BNPL) ETA, and whether it opened a clawback. Money is a digit string.</summary> /// status, decomposed legs and (BNPL) ETA, and whether it opened a clawback. Money is a digit string.</summary>
public record CreateRefundResult( public record CreateRefundResult(
@@ -18,7 +18,7 @@ namespace Baya.Domain.Entities.Payouts;
/// <c>nurse_payout_booking_links</c> row + the ledger movement — never a boolean flag. /// <c>nurse_payout_booking_links</c> row + the ledger movement — never a boolean flag.
/// </para> /// </para>
/// </summary> /// </summary>
public class NursePayout : BaseEntity<long> public class NursePayout : BaseEntity<long>, IAuditable
{ {
public long BatchId { get; set; } public long BatchId { get; set; }
public NursePayoutBatch Batch { get; set; } = null!; public NursePayoutBatch Batch { get; set; } = null!;
@@ -28,7 +28,9 @@ public class NursePayout : BaseEntity<long>
/// <summary>The verified primary account paid (FK <c>nurse_bank_accounts</c>).</summary> /// <summary>The verified primary account paid (FK <c>nurse_bank_accounts</c>).</summary>
public long BankAccountId { get; set; } public long BankAccountId { get; set; }
/// <summary>The account's IBAN, frozen at build time and <b>encrypted at rest</b> through the field encryptor.</summary> /// <summary>The account's IBAN, frozen at build time and <b>encrypted at rest</b> through the field encryptor.
/// <see cref="AuditRedactedAttribute"/> so the audit-log diff records a redaction marker, never the plaintext IBAN.</summary>
[AuditRedacted]
public string IbanSnapshot { get; set; } = null!; public string IbanSnapshot { get; set; } = null!;
/// <summary>Σ eligible booking payouts for the window (IRR).</summary> /// <summary>Σ eligible booking payouts for the window (IRR).</summary>
@@ -17,7 +17,7 @@ namespace Baya.Domain.Entities.Payouts;
/// Money is IRR <c>BIGINT</c>, no floats. /// Money is IRR <c>BIGINT</c>, no floats.
/// </para> /// </para>
/// </summary> /// </summary>
public class NursePayoutBatch : BaseEntity<long> public class NursePayoutBatch : BaseEntity<long>, IAuditable
{ {
public DateOnly PeriodStart { get; set; } public DateOnly PeriodStart { get; set; }
@@ -14,7 +14,7 @@ namespace Baya.Domain.Entities.Refunds;
/// when a payout batch nets the clawback out — <c>nurse_payouts</c> arrives in b13. /// when a payout batch nets the clawback out — <c>nurse_payouts</c> arrives in b13.
/// </para> /// </para>
/// </summary> /// </summary>
public class NurseClawback : BaseEntity<long> public class NurseClawback : BaseEntity<long>, IAuditable
{ {
public long NurseId { get; set; } public long NurseId { get; set; }
public long BookingId { get; set; } public long BookingId { get; set; }
@@ -16,7 +16,7 @@ namespace Baya.Domain.Entities.Refunds;
/// differ. /// differ.
/// </para> /// </para>
/// </summary> /// </summary>
public class Refund : BaseEntity<long> public class Refund : BaseEntity<long>, IAuditable
{ {
/// <summary>The captured transaction being reversed (N:1 — a transaction may have several partial refunds).</summary> /// <summary>The captured transaction being reversed (N:1 — a transaction may have several partial refunds).</summary>
public long PaymentTransactionId { get; set; } public long PaymentTransactionId { get; set; }
@@ -101,8 +101,11 @@ public class Refund : BaseEntity<long>
ExpectedCustomerRefundEta = expectedCustomerRefundEta; ExpectedCustomerRefundEta = expectedCustomerRefundEta;
} }
/// <summary>Reconciliation confirmed the customer cash-back for a <c>processing</c> refund (BNPL/manual).</summary> /// <summary>Reconciliation confirmed the customer cash-back for a <c>processing</c> refund (BNPL/manual)
public void MarkSucceededAsync(DateTime now) /// the admin <c>confirm_settlement</c> or the provider cash-back callback. Stamps <see cref="ProcessedAt"/>
/// (the settled-at instant); the caller posts the <c>refund_payable ↔ escrow_held</c> clearing in the same
/// commit. (Not async — the name only mirrored the BNPL/manual "async settlement" concept.)</summary>
public void MarkSucceededReconciled(DateTime now)
{ {
Transition(RefundStatus.Succeeded); Transition(RefundStatus.Succeeded);
ProcessedAt = now; ProcessedAt = now;
@@ -11,7 +11,7 @@ namespace Baya.Domain.Entities.Verification;
/// reversed on suspension). The legacy <c>nurse_profiles.verification_status</c> column was deliberately /// reversed on suspension). The legacy <c>nurse_profiles.verification_status</c> column was deliberately
/// cut — never reintroduce a second copy of this state. /// cut — never reintroduce a second copy of this state.
/// </summary> /// </summary>
public class NurseVerification : BaseEntity<long> public class NurseVerification : BaseEntity<long>, IAuditable
{ {
public long NurseId { get; set; } public long NurseId { get; set; }
@@ -46,7 +46,8 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."), (16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
(17, "no_show_threshold_minutes", "60", ConfigDataType.Int, "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show."), (17, "no_show_threshold_minutes", "60", ConfigDataType.Int, "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show."),
(18, "no_show_scan_cadence_hours", "1", ConfigDataType.Int, "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today)."), (18, "no_show_scan_cadence_hours", "1", ConfigDataType.Int, "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today)."),
(19, "refund_ticket_required", "false", ConfigDataType.Bool, "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table."), // (19) refund_ticket_required — retired in refinement-phase-6. b15 unconditionally auto-opens a refund
// ticket, so the config-gated rule had no consumer left; the seed row is deleted by that migration.
(20, "bnpl_refund_eta_business_days", "10", ConfigDataType.Int, "Business days shown as the customer BNPL refund ETA (b11)."), (20, "bnpl_refund_eta_business_days", "10", ConfigDataType.Int, "Business days shown as the customer BNPL refund ETA (b11)."),
(21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."), (21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."),
(22, "payout_satna_threshold_irr", "1000000000", ConfigDataType.Decimal, "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13)."), (22, "payout_satna_threshold_irr", "1000000000", ConfigDataType.Decimal, "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13)."),
@@ -1,5 +1,6 @@
using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Invoices; using Baya.Domain.Entities.Invoices;
using Baya.Domain.Entities.PartnerCenters;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -8,8 +9,8 @@ namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig;
/// <summary> /// <summary>
/// <c>invoices</c> — one issued invoice per booking (UNIQUE <c>booking_id</c>) with a UNIQUE, sequential /// <c>invoices</c> — one issued invoice per booking (UNIQUE <c>booking_id</c>) with a UNIQUE, sequential
/// <c>invoice_number</c> drawn from <see cref="InvoiceNumberSequence"/>. VAT (<c>vat_irr</c>) is computed on the /// <c>invoice_number</c> drawn from <see cref="InvoiceNumberSequence"/>. VAT (<c>vat_irr</c>) is computed on the
/// commission line only. <c>partner_center_id</c> is a nullable column with <b>no FK</b> — <c>partner_centers</c> /// commission line only. <c>partner_center_id</c> is a nullable FK to <c>partner.PartnerCenters</c> (b15 shipped
/// is a forward-dep on b15. /// the table; refinement-phase-6 added the constraint + index, <c>ON DELETE NO ACTION</c>).
/// </summary> /// </summary>
internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice> internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
{ {
@@ -26,8 +27,11 @@ internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
builder.HasIndex(i => i.InvoiceNumber).IsUnique(); builder.HasIndex(i => i.InvoiceNumber).IsUnique();
builder.HasIndex(i => i.BookingId).IsUnique(); builder.HasIndex(i => i.BookingId).IsUnique();
builder.HasIndex(i => i.PartnerCenterId);
builder.HasOne<Booking>().WithMany().HasForeignKey(i => i.BookingId).IsRequired(); builder.HasOne<Booking>().WithMany().HasForeignKey(i => i.BookingId).IsRequired();
// Forward-dep FK to the b15 partner_centers table (refinement-phase-6). Optional (nullable); NO ACTION.
builder.HasOne<PartnerCenter>().WithMany().HasForeignKey(i => i.PartnerCenterId).OnDelete(DeleteBehavior.NoAction);
builder.HasQueryFilter(i => i.DeletedAt == null); builder.HasQueryFilter(i => i.DeletedAt == null);
} }
@@ -1,5 +1,6 @@
using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity; using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.Refunds; using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -8,8 +9,9 @@ namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary> /// <summary>
/// <c>nurse_clawbacks</c> — a first-class receivable opened when a booking is refunded after the nurse was /// <c>nurse_clawbacks</c> — a first-class receivable opened when a booking is refunded after the nurse was
/// already paid. <c>original_payout_id</c> / <c>recovered_in_payout_id</c> are nullable columns + indexes now /// already paid. <c>original_payout_id</c> / <c>recovered_in_payout_id</c> are nullable FKs to
/// with <b>no FK</b> — <c>nurse_payouts</c> is a forward-dep on b13, which sets the values and wires the FKs. /// <c>payouts.NursePayouts</c> (b13 shipped the table and sets the values; refinement-phase-6 added the
/// constraints, <c>ON DELETE NO ACTION</c>).
/// </summary> /// </summary>
internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawback> internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawback>
{ {
@@ -30,6 +32,10 @@ internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawba
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(c => c.NurseId).IsRequired(); builder.HasOne<NurseProfile>().WithMany().HasForeignKey(c => c.NurseId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(c => c.BookingId).IsRequired(); builder.HasOne<Booking>().WithMany().HasForeignKey(c => c.BookingId).IsRequired();
builder.HasOne<Refund>().WithMany().HasForeignKey(c => c.RefundId).IsRequired(); builder.HasOne<Refund>().WithMany().HasForeignKey(c => c.RefundId).IsRequired();
// Forward-dep FKs to the b13 payouts table (refinement-phase-6). Both optional (nullable); NO ACTION so
// deleting a payout never cascades into the receivable rows. Two distinct FKs to the same principal.
builder.HasOne<NursePayout>().WithMany().HasForeignKey(c => c.OriginalPayoutId).OnDelete(DeleteBehavior.NoAction);
builder.HasOne<NursePayout>().WithMany().HasForeignKey(c => c.RecoveredInPayoutId).OnDelete(DeleteBehavior.NoAction);
builder.HasQueryFilter(c => c.DeletedAt == null); builder.HasQueryFilter(c => c.DeletedAt == null);
} }
@@ -1,5 +1,6 @@
using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity; using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.Payments; using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds; using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -10,8 +11,8 @@ namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary> /// <summary>
/// <c>refunds</c> — 1:N per <c>payment_transaction</c>. The <c>amount = fee_leg + payout_leg</c> reconciliation /// <c>refunds</c> — 1:N per <c>payment_transaction</c>. The <c>amount = fee_leg + payout_leg</c> reconciliation
/// is a DB CHECK (the "Σ refunded ≤ captured" invariant is a handler check under the booking refund lock, not a /// is a DB CHECK (the "Σ refunded ≤ captured" invariant is a handler check under the booking refund lock, not a
/// single-row constraint). <c>ticket_id</c> is a nullable column + index now with <b>no FK</b> — the /// single-row constraint). <c>ticket_id</c> is a nullable FK to <c>messaging.Tickets</c> (b15 shipped the table;
/// <c>tickets</c> table is a forward-dep on b15, which wires the real FK target. /// refinement-phase-6 added the constraint, <c>ON DELETE NO ACTION</c>).
/// </summary> /// </summary>
internal sealed class RefundConfig : IEntityTypeConfiguration<Refund> internal sealed class RefundConfig : IEntityTypeConfiguration<Refund>
{ {
@@ -38,12 +39,14 @@ internal sealed class RefundConfig : IEntityTypeConfiguration<Refund>
builder.HasIndex(r => r.BookingId); builder.HasIndex(r => r.BookingId);
builder.HasIndex(r => r.RequestedByCustomerId); builder.HasIndex(r => r.RequestedByCustomerId);
builder.HasIndex(r => r.Status); builder.HasIndex(r => r.Status);
// Index in place for the b15 tickets wire-up; no FK yet (tickets does not exist).
builder.HasIndex(r => r.TicketId); builder.HasIndex(r => r.TicketId);
builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(r => r.PaymentTransactionId).IsRequired(); builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(r => r.PaymentTransactionId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId).IsRequired(); builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId).IsRequired();
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(r => r.RequestedByCustomerId).IsRequired(); builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(r => r.RequestedByCustomerId).IsRequired();
// Forward-dep FK to the b15 tickets table (refinement-phase-6). Optional (nullable); NO ACTION so deleting
// a ticket never cascades into money rows.
builder.HasOne<Ticket>().WithMany().HasForeignKey(r => r.TicketId).OnDelete(DeleteBehavior.NoAction);
builder.HasQueryFilter(r => r.DeletedAt == null); builder.HasQueryFilter(r => r.DeletedAt == null);
} }
@@ -0,0 +1,98 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefinementPhase6MoneyFks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 19L);
migrationBuilder.CreateIndex(
name: "IX_Invoices_PartnerCenterId",
schema: "payments",
table: "Invoices",
column: "PartnerCenterId");
migrationBuilder.AddForeignKey(
name: "FK_Invoices_PartnerCenters_PartnerCenterId",
schema: "payments",
table: "Invoices",
column: "PartnerCenterId",
principalSchema: "partner",
principalTable: "PartnerCenters",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_NurseClawbacks_NursePayouts_OriginalPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "OriginalPayoutId",
principalSchema: "payouts",
principalTable: "NursePayouts",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_NurseClawbacks_NursePayouts_RecoveredInPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "RecoveredInPayoutId",
principalSchema: "payouts",
principalTable: "NursePayouts",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Refunds_Tickets_TicketId",
schema: "payments",
table: "Refunds",
column: "TicketId",
principalSchema: "messaging",
principalTable: "Tickets",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Invoices_PartnerCenters_PartnerCenterId",
schema: "payments",
table: "Invoices");
migrationBuilder.DropForeignKey(
name: "FK_NurseClawbacks_NursePayouts_OriginalPayoutId",
schema: "payments",
table: "NurseClawbacks");
migrationBuilder.DropForeignKey(
name: "FK_NurseClawbacks_NursePayouts_RecoveredInPayoutId",
schema: "payments",
table: "NurseClawbacks");
migrationBuilder.DropForeignKey(
name: "FK_Refunds_Tickets_TicketId",
schema: "payments",
table: "Refunds");
migrationBuilder.DropIndex(
name: "IX_Invoices_PartnerCenterId",
schema: "payments",
table: "Invoices");
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[] { 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", "refund_ticket_required", null, null, "false" });
}
}
}
@@ -1266,15 +1266,6 @@ namespace Baya.Infrastructure.Persistence.Migrations
Value = "1" Value = "1"
}, },
new new
{
Id = 19L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.",
Key = "refund_ticket_required",
Value = "false"
},
new
{ {
Id = 20L, Id = 20L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
@@ -2932,6 +2923,8 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.HasIndex("InvoiceNumber") b.HasIndex("InvoiceNumber")
.IsUnique(); .IsUnique();
b.HasIndex("PartnerCenterId");
b.ToTable("Invoices", "payments"); b.ToTable("Invoices", "payments");
}); });
@@ -5466,6 +5459,11 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("BookingId") .HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("Baya.Domain.Entities.PartnerCenters.PartnerCenter", null)
.WithMany()
.HasForeignKey("PartnerCenterId")
.OnDelete(DeleteBehavior.NoAction);
}); });
modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b =>
@@ -5650,6 +5648,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null)
.WithMany()
.HasForeignKey("OriginalPayoutId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null)
.WithMany()
.HasForeignKey("RecoveredInPayoutId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) b.HasOne("Baya.Domain.Entities.Refunds.Refund", null)
.WithMany() .WithMany()
.HasForeignKey("RefundId") .HasForeignKey("RefundId")
@@ -5676,6 +5684,11 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("RequestedByCustomerId") .HasForeignKey("RequestedByCustomerId")
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("Baya.Domain.Entities.Messaging.Ticket", null)
.WithMany()
.HasForeignKey("TicketId")
.OnDelete(DeleteBehavior.NoAction);
}); });
modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b => modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b =>
@@ -55,6 +55,16 @@ internal sealed class RefundRepository : BaseAsyncRepository<Refund>, IRefundRep
public Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken) public Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken)
=> DbContext.Set<NurseClawback>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken); => DbContext.Set<NurseClawback>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
public Task<Refund?> GetTrackedRefundByIdAsync(long id, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
public Task<long?> GetProcessingRefundIdForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(r => r.PaymentTransactionId == paymentTransactionId && r.Status == RefundStatus.Processing)
.OrderByDescending(r => r.Id)
.Select(r => (long?)r.Id)
.FirstOrDefaultAsync(cancellationToken);
public async Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken) public async Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken)
{ {
var query = TableNoTracking; var query = TableNoTracking;
@@ -0,0 +1,137 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Features.Messaging.Commands.PostMessage;
using Baya.Application.Features.Messaging.Queries.GetTicketThread;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Messaging;
/// <summary>
/// Foundation (handler-level) coverage of the <c>is_internal</c> hard visibility boundary — previously only
/// exercised end-to-end (refinement-phase-6 §6.5). The user thread view strips internal notes in the projection;
/// the admin view returns them; a non-staff caller can neither post nor read one.
/// </summary>
public sealed class MessagingInternalBoundaryTests : IDisposable
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private readonly SqliteConnection _connection;
private readonly ApplicationDbContext _db;
private readonly UnitOfWork _uow;
private readonly int _customerUserId;
private readonly int _adminUserId;
private readonly long _ticketId;
public MessagingInternalBoundaryTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
_db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
_db.Database.EnsureCreated();
_uow = new UnitOfWork(_db);
var customer = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
var admin = new User { UserName = "admin1", PhoneNumber = "09120000009", Gender = "male", Name = "مدیر", FamilyName = "سیستم", IsActive = true };
_db.Users.AddRange(customer, admin);
_db.SaveChanges();
_customerUserId = customer.Id;
_adminUserId = admin.Id;
var ticket = new Ticket { ReferenceCode = "TKT-BND001", Category = TicketCategory.Support, OpenedById = customer.Id };
_db.Set<Ticket>().Add(ticket);
_db.SaveChanges();
_ticketId = ticket.Id;
_db.Set<TicketParticipant>().Add(new TicketParticipant { TicketId = ticket.Id, UserId = customer.Id, AddedById = customer.Id });
_db.Set<TicketMessage>().AddRange(
new TicketMessage { TicketId = ticket.Id, SenderId = customer.Id, Body = "public question", IsInternal = false, SentAt = Now },
new TicketMessage { TicketId = ticket.Id, SenderId = admin.Id, Body = "internal staff note", IsInternal = true, SentAt = Now.AddMinutes(1) });
_db.SaveChanges();
}
private ICurrentUser AsCustomer() => User(_customerUserId, RoleNames.Customer);
private ICurrentUser AsAdmin() => User(_adminUserId, RoleNames.Admin);
private static ICurrentUser User(int id, string role)
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(id);
u.Roles.Returns(new[] { role });
return u;
}
private IDateTimeProvider Clock()
{
var c = Substitute.For<IDateTimeProvider>();
c.UtcNow.Returns(Now);
return c;
}
private GetTicketThreadQueryHandler ThreadHandler(ICurrentUser user) => new(user, _uow, Clock());
private PostMessageCommandHandler PostHandler(ICurrentUser user) => new(user, _uow, Clock(), Substitute.For<INotificationDispatcher>());
[Fact]
public async Task User_view_strips_internal_messages()
{
var result = await ThreadHandler(AsCustomer()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: false), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Single(result.Result.Messages);
Assert.All(result.Result.Messages, m => Assert.False(m.IsInternal));
Assert.DoesNotContain(result.Result.Messages, m => m.Body == "internal staff note");
}
[Fact]
public async Task Admin_view_includes_internal_messages()
{
var result = await ThreadHandler(AsAdmin()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: true), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Result.Messages.Count);
Assert.Contains(result.Result.Messages, m => m is { IsInternal: true, Body: "internal staff note" });
}
[Fact]
public async Task Non_staff_admin_view_request_is_forbidden()
{
var result = await ThreadHandler(AsCustomer()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: true), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsForbidden);
}
[Fact]
public async Task Non_staff_cannot_post_an_internal_note()
{
var result = await PostHandler(AsCustomer()).Handle(new PostMessageCommand(_ticketId, "sneaky", IsInternal: true), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsForbidden);
Assert.DoesNotContain(_db.Set<TicketMessage>().AsNoTracking(), m => m.Body == "sneaky");
}
[Fact]
public async Task Staff_internal_note_is_never_returned_in_the_user_view()
{
var post = await PostHandler(AsAdmin()).Handle(new PostMessageCommand(_ticketId, "second internal note", IsInternal: true), CancellationToken.None);
Assert.True(post.IsSuccess);
var userView = await ThreadHandler(AsCustomer()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: false), CancellationToken.None);
Assert.True(userView.IsSuccess);
Assert.All(userView.Result.Messages, m => Assert.False(m.IsInternal));
Assert.DoesNotContain(userView.Result.Messages, m => m.Body == "second internal note");
}
public void Dispose()
{
_db.Dispose();
_connection.Dispose();
}
}
@@ -84,6 +84,45 @@ public class PaymentWebhookTests
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking()); Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
} }
[Fact]
public async Task Racing_same_key_insert_is_caught_as_an_idempotent_no_op()
{
using var host = new PaymentsTestHost();
var (_, reference) = await SeedPendingAsync(host);
// A provider that omits external_event_id skips the read-dedup, so the (provider_code, external_event_id)
// UNIQUE is the SOLE backstop — exactly the state a true concurrent insert reaches when both requests read
// "no existing event" before either commits. Pre-seed the colliding empty-key row so the handler's own
// insert loses the unique race and must be treated as an idempotent no-op (DbUpdateException → duplicate).
var existing = new PaymentWebhookEvent
{
ProviderCode = "zarinpal", ExternalEventId = string.Empty, EventType = "payment.succeeded",
SignatureValid = true, PayloadJson = "{}", ReceivedAt = Now.UtcDateTime
};
existing.MarkProcessed(null, Now.UtcDateTime);
host.Db.Set<PaymentWebhookEvent>().Add(existing);
host.Db.SaveChanges();
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
var sender = SenderRoutingConfirmTo(confirm);
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
// No external_event_id in the body → verification.ExternalEventId == "" (the read-dedup is skipped).
var body = $"{{\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}";
var result = await handler.Handle(
new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Result.Duplicate);
Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus);
// The insert lost the race → no confirm, no ledger, and only the pre-existing event row survives.
await sender.DidNotReceive().Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking());
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
}
[Fact] [Fact]
public async Task Unverified_signature_callback_mutates_nothing() public async Task Unverified_signature_callback_mutates_nothing()
{ {
@@ -0,0 +1,86 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Features.Refunds.Commands.WriteOffClawback;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Refunds;
/// <summary>
/// The admin bad-debt path (previously untested — refinement-phase-6 §6.5): writing off a <c>pending</c> nurse
/// clawback posts a balanced <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> group and is guarded
/// (404 unknown / 409 non-pending / idempotent-ish once resolved).
/// </summary>
public class ClawbackWriteOffTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private static async Task<(long ClawbackId, long BookingId)> SeedPendingClawbackAsync(RefundsTestHost host)
{
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var create = new CreateRefundCommandHandler(
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
host.Card(), host.Bnpl(), host.PayoutStatus(paid: true),
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), host.Senders());
var refund = await create.Handle(
new CreateRefundCommand(bookingId, null, RefundPercentage: 1m, null, null, "customer_request", null, null, null),
CancellationToken.None);
Assert.True(refund.IsSuccess);
Assert.NotNull(refund.Result.ClawbackId);
return (refund.Result.ClawbackId!.Value, bookingId);
}
[Fact]
public async Task Write_off_posts_a_balanced_bad_debt_group_and_resolves_the_clawback()
{
using var host = new RefundsTestHost();
var (clawbackId, bookingId) = await SeedPendingClawbackAsync(host);
var handler = new WriteOffClawbackCommandHandler(host.UnitOfWork, host.Clock(Now));
var result = await handler.Handle(new WriteOffClawbackCommand(clawbackId, "uncollectable_after_dispute"), CancellationToken.None);
Assert.True(result.IsSuccess);
var clawback = host.Db.Set<NurseClawback>().AsNoTracking().Single(c => c.Id == clawbackId);
Assert.Equal(ClawbackStatus.WrittenOff, clawback.Status);
// The correction group: DEBIT bad_debt == CREDIT nurse_clawback_receivable, both for the clawback amount.
var group = host.LedgerFor(bookingId).Where(l => l.SourceRefType == LedgerSourceRefType.Clawback).ToList();
Assert.Equal(2, group.Count);
Assert.Equal(8_500_000, group.Single(l => l.AccountType == LedgerAccountType.BadDebt && l.Direction == LedgerDirection.Debit).AmountIrr);
Assert.Equal(8_500_000, group.Single(l => l.AccountType == LedgerAccountType.NurseClawbackReceivable && l.Direction == LedgerDirection.Credit).AmountIrr);
Assert.Equal(group.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
group.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
}
[Fact]
public async Task Write_off_of_an_unknown_clawback_is_not_found()
{
using var host = new RefundsTestHost();
var handler = new WriteOffClawbackCommandHandler(host.UnitOfWork, host.Clock(Now));
var result = await handler.Handle(new WriteOffClawbackCommand(999_999, "x"), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsNotFound);
}
[Fact]
public async Task Second_write_off_of_the_same_clawback_is_a_conflict()
{
using var host = new RefundsTestHost();
var (clawbackId, _) = await SeedPendingClawbackAsync(host);
var handler = new WriteOffClawbackCommandHandler(host.UnitOfWork, host.Clock(Now));
var first = await handler.Handle(new WriteOffClawbackCommand(clawbackId, "uncollectable"), CancellationToken.None);
var second = await handler.Handle(new WriteOffClawbackCommand(clawbackId, "again"), CancellationToken.None);
Assert.True(first.IsSuccess);
Assert.False(second.IsSuccess);
Assert.True(second.IsConflict);
}
}
@@ -16,7 +16,7 @@ public class RefundHandlerTests
=> new( => new(
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(), host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
host.Card(), host.Bnpl(), host.PayoutStatus(nursePaid), host.Card(), host.Bnpl(), host.PayoutStatus(nursePaid),
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), TestSenders.WithTicketHooks()); Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), host.Senders());
private static CreateRefundCommand FullRefund(long bookingId) private static CreateRefundCommand FullRefund(long bookingId)
=> new(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null); => new(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null);
@@ -0,0 +1,122 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Refunds;
/// <summary>
/// The refinement-phase-6 fix: a BNPL/manual refund that lands in <c>processing</c> can be settled (admin
/// confirm / provider cash-back callback) so the deferred <c>refund_payable ↔ escrow_held</c> clearing posts and
/// the ledger reconciles — previously that path was unreachable. Also covers <c>mark_failed</c> and replay
/// idempotency.
/// </summary>
public class RefundSettlementTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset Later = new(2026, 8, 12, 10, 0, 0, TimeSpan.Zero);
private static CreateRefundCommandHandler CreateHandler(RefundsTestHost host)
=> new(
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
host.Card(), host.Bnpl(), host.PayoutStatus(paid: false),
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), host.Senders());
private static ConfirmRefundSettlementCommandHandler ConfirmHandler(RefundsTestHost host, INotificationDispatcher notifications)
=> new(host.UnitOfWork, host.Lock(), host.Clock(Later), notifications);
private static async Task<long> SeedProcessingBnplRefundAsync(RefundsTestHost host)
{
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000, gatewayType: PaymentGatewayType.Bnpl);
var created = await CreateHandler(host).Handle(
new CreateRefundCommand(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null),
CancellationToken.None);
Assert.True(created.IsSuccess);
Assert.Equal(RefundStatus.Processing, created.Result.Status); // BNPL waits — no clearing yet
Assert.Equal(3, host.LedgerFor(bookingId).Count); // reversal only
return created.Result.RefundId;
}
[Fact]
public async Task Confirm_settlement_posts_the_deferred_clearing_and_the_ledger_reconciles()
{
using var host = new RefundsTestHost();
var refundId = await SeedProcessingBnplRefundAsync(host);
var bookingId = host.Db.Set<Refund>().AsNoTracking().Single(r => r.Id == refundId).BookingId;
var notifications = Substitute.For<INotificationDispatcher>();
var result = await ConfirmHandler(host, notifications).Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(RefundStatus.Succeeded, result.Result.Status);
Assert.NotNull(result.Result.CompletedAt);
var legs = host.LedgerFor(bookingId);
Assert.Equal(5, legs.Count); // reversal (3) + clearing (2)
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
// The clearing leg: refund_payable is fully drained (credit at reversal == debit at clearing) and escrow
// is credited back the refunded total — i.e. escrow no longer overstates the held funds.
Assert.Equal(10_000_000, legs.Where(l => l.AccountType == LedgerAccountType.RefundPayable && l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr));
Assert.Equal(10_000_000, legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
await notifications.Received(1).DispatchAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Replayed_confirm_settlement_is_an_idempotent_no_op()
{
using var host = new RefundsTestHost();
var refundId = await SeedProcessingBnplRefundAsync(host);
var bookingId = host.Db.Set<Refund>().AsNoTracking().Single(r => r.Id == refundId).BookingId;
var handler = ConfirmHandler(host, Substitute.For<INotificationDispatcher>());
var first = await handler.Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
var replay = await handler.Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
Assert.True(first.IsSuccess);
Assert.True(replay.IsSuccess);
Assert.Equal(RefundStatus.Succeeded, replay.Result.Status);
// The clearing is posted exactly once — no double credit to escrow.
Assert.Equal(5, host.LedgerFor(bookingId).Count);
}
[Fact]
public async Task Confirm_settlement_of_a_missing_refund_is_not_found()
{
using var host = new RefundsTestHost();
var result = await ConfirmHandler(host, Substitute.For<INotificationDispatcher>())
.Handle(new ConfirmRefundSettlementCommand(999_999), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsNotFound);
}
[Fact]
public async Task Mark_failed_transitions_processing_to_failed_without_posting_a_clearing()
{
using var host = new RefundsTestHost();
var refundId = await SeedProcessingBnplRefundAsync(host);
var bookingId = host.Db.Set<Refund>().AsNoTracking().Single(r => r.Id == refundId).BookingId;
var handler = new MarkRefundSettlementFailedCommandHandler(host.UnitOfWork, host.Lock(), host.Clock(Later));
var result = await handler.Handle(new MarkRefundSettlementFailedCommand(refundId, "bank_rejected"), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(RefundStatus.Failed, result.Result.Status);
Assert.Equal(3, host.LedgerFor(bookingId).Count); // no clearing posted
// A settled confirm can no longer be applied to a failed refund.
var confirm = await ConfirmHandler(host, Substitute.For<INotificationDispatcher>())
.Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
Assert.False(confirm.IsSuccess);
Assert.True(confirm.IsConflict);
}
}
@@ -1,15 +1,21 @@
using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration; using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments; using Baya.Application.Contracts.Payments;
using Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket;
using Baya.Application.Features.Messaging.Commands.OpenTicket;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog; using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography; using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity; using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.Payments; using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User; using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence; using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common; using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups; using Baya.Tests.Setup.Setups;
using Mediator;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NSubstitute; using NSubstitute;
@@ -32,6 +38,10 @@ public sealed class RefundsTestHost : IDisposable
public long CustomerId { get; } public long CustomerId { get; }
public int CustomerUserId { get; } public int CustomerUserId { get; }
public long NurseId { get; } public long NurseId { get; }
/// <summary>A real seeded ticket row so the <c>refunds.ticket_id</c> FK (refinement-phase-6) is satisfied when
/// the auto-anchor hook returns this id.</summary>
public long TicketId { get; }
private readonly long _cityId; private readonly long _cityId;
private readonly long _categoryId; private readonly long _categoryId;
private readonly long _patientId; private readonly long _patientId;
@@ -89,6 +99,14 @@ public sealed class RefundsTestHost : IDisposable
Db.Set<NurseProfile>().Add(nurse); Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges(); Db.SaveChanges();
NurseId = nurse.Id; NurseId = nurse.Id;
var ticket = new Ticket
{
ReferenceCode = "TKT-TEST01", Category = TicketCategory.Refund, OpenedById = CustomerUserId
};
Db.Set<Ticket>().Add(ticket);
Db.SaveChanges();
TicketId = ticket.Id;
} }
/// <summary>Seeds a confirmed booking + its captured (succeeded) transaction. Amounts satisfy /// <summary>Seeds a confirmed booking + its captured (succeeded) transaction. Amounts satisfy
@@ -166,12 +184,11 @@ public sealed class RefundsTestHost : IDisposable
return c; return c;
} }
public IPlatformConfig Config(decimal vatRate = 0.10m, bool ticketRequired = false, int bnplEtaDays = 10) public IPlatformConfig Config(decimal vatRate = 0.10m, int bnplEtaDays = 10)
{ {
var cfg = Substitute.For<IPlatformConfig>(); var cfg = Substitute.For<IPlatformConfig>();
cfg.GetConfig<decimal>("vat_rate", Arg.Any<CancellationToken>()).Returns(vatRate); cfg.GetConfig<decimal>("vat_rate", Arg.Any<CancellationToken>()).Returns(vatRate);
cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(0.15m); cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(0.15m);
cfg.GetConfig<bool>("refund_ticket_required", Arg.Any<CancellationToken>()).Returns(ticketRequired);
cfg.GetConfig<int>("bnpl_refund_eta_business_days", Arg.Any<CancellationToken>()).Returns(bnplEtaDays); cfg.GetConfig<int>("bnpl_refund_eta_business_days", Arg.Any<CancellationToken>()).Returns(bnplEtaDays);
return cfg; return cfg;
} }
@@ -203,6 +220,19 @@ public sealed class RefundsTestHost : IDisposable
public IDistributedLock Lock() => new NoOpLock(); public IDistributedLock Lock() => new NoOpLock();
/// <summary>An <see cref="ISender"/> whose refund ticket-anchor hook returns the <b>real</b> seeded
/// <see cref="TicketId"/>, so the refund row satisfies the <c>refunds.ticket_id</c> FK.</summary>
public ISender Senders()
{
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<OpenTicketCommand>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(OperationResult<OpenTicketResult>.SuccessResult(
new OpenTicketResult(TicketId, "TKT-TEST01", "open", "refund"))));
sender.Send(Arg.Any<AutoCreateCoordinationTicketCommand>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(OperationResult<bool>.SuccessResult(true)));
return sender;
}
public IReadOnlyList<LedgerEntry> LedgerFor(long bookingId) public IReadOnlyList<LedgerEntry> LedgerFor(long bookingId)
=> Db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList(); => Db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList();