refinement phase 6
This commit is contained in:
@@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
+36
-11
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+79
@@ -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));
|
||||||
|
}
|
||||||
+15
@@ -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>>;
|
||||||
+10
-4
@@ -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.
|
||||||
|
|||||||
+52
@@ -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));
|
||||||
|
}
|
||||||
+14
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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)."),
|
||||||
|
|||||||
+6
-2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+6048
File diff suppressed because it is too large
Load Diff
+98
@@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-9
@@ -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 =>
|
||||||
|
|||||||
+10
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user