diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:21:51 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:21:51 +0800 |
| commit | b8811922f4e167d50efb8817da35a12d8a9d653b (patch) | |
| tree | b85cc56677be5d4d58ad2277f00cbc935933301e /src/FundLab.Domain/Redemption.fs | |
| parent | 3d9398234a54774544eb48a1caed6a1b2d5132eb (diff) | |
| download | fund-lab-b8811922f4e167d50efb8817da35a12d8a9d653b.tar.gz | |
Add redemption order minimal closed loop (3d-3)
Redeem confirmed holdings end to end with exact decimal semantics:
submission freezes position units (reserved_units) after an
available-units check without touching cash, confirmation settles at
the trade-date NAV with evidence and deferral rules identical to
subscriptions, credits the fund with proceeds net of the stated fee,
and writes off position cost pro rata (full redemption removes the
position row). Cash conservation holds at every step: frozen shares
and receivable cash never appear as available cash before settlement.
Domain gains RedemptionPolicy (request validation plus proceeds/fee/
cost-release computation) with unit tests. Persistence adds
redemption_orders, idempotency tables, reserved_units on positions
and Create/Confirm repository members; the API exposes
POST/GET /funds/{id}/redemptions and POST .../confirm. The frontend
adds a 06/REDEEM panel (submit, confirm, pending reasons, confirmed
proceeds/cost display), frozen-share display on the holdings panel,
and refresh wiring. Browser QA gains a K-series covering freeze,
settlement, cash accounting, replay and over-redemption rejection.
Diffstat (limited to 'src/FundLab.Domain/Redemption.fs')
| -rw-r--r-- | src/FundLab.Domain/Redemption.fs | 84 |
1 files changed, 84 insertions, 0 deletions
diff --git a/src/FundLab.Domain/Redemption.fs b/src/FundLab.Domain/Redemption.fs new file mode 100644 index 0000000..a3e9188 --- /dev/null +++ b/src/FundLab.Domain/Redemption.fs @@ -0,0 +1,84 @@ +namespace FundLab.Domain + +open System + +module RedemptionPolicy = + let unitsMaximum = 99999999999999999999.99999999m + let cashMaximum = 999999999999999999.99m + + let roundDown (scale: int) (value: decimal) : decimal = + let factor = decimal (pown 10 scale) + Decimal.Truncate(value * factor) / factor + + let validateRequest + (availableUnits: decimal) + (requestedUnits: decimal) + (feeAmount: decimal) + : Result<unit, string> = + if requestedUnits <= 0m then + Error "redemption units must be positive" + elif requestedUnits > unitsMaximum then + Error "redemption units exceed supported precision" + elif requestedUnits > availableUnits then + Error "requested units exceed available holdings" + elif feeAmount < 0m then + Error "redemption fee cannot be negative" + elif feeAmount > cashMaximum then + Error "redemption fee exceeds supported precision" + else + Ok () + + type RedemptionComputation = + { + GrossCash: decimal + Fee: decimal + Proceeds: decimal + RedeemedCost: decimal + } + + let compute + (requestedUnits: decimal) + (unitNav: decimal) + (feeAmount: decimal) + (positionCost: decimal) + (positionUnits: decimal) + : Result<RedemptionComputation, string> = + if unitNav <= 0m then + Error "unit nav must be positive" + elif unitNav < 0.00000001m then + Error "unit nav is below database precision" + elif requestedUnits <= 0m then + Error "redemption units must be positive" + elif positionUnits <= 0m then + Error "position units must be positive" + elif requestedUnits > positionUnits then + Error "redemption units exceed position units" + elif feeAmount < 0m then + Error "redemption fee cannot be negative" + else + let gross = requestedUnits * unitNav + let grossRounded = roundDown 2 gross + + if grossRounded <= 0m then + Error "redemption cash rounds to zero" + elif feeAmount > grossRounded then + Error "redemption fee exceeds redemption value" + else + let proceeds = grossRounded - feeAmount + + if proceeds <= 0m then + Error "redemption proceeds rounds to zero" + else + let redeemedCost = + if requestedUnits = positionUnits then + positionCost + else + roundDown 2 (positionCost * requestedUnits / positionUnits) + + Ok + { + GrossCash = grossRounded + Fee = feeAmount + Proceeds = proceeds + RedeemedCost = redeemedCost + } |
