refactor(billing): adopt Money value object across Wallet, Invoice and BillingPlan#1320
Conversation
…d BillingPlan Follow-up to fullstackhero#1316. Promoting Money to BuildingBlocks left Billing modeling money two ways: Money on TopupRequest, but raw decimal+Currency on Wallet, WalletTransaction, Invoice, InvoiceLineItem and BillingPlan. This folds the remaining money-bearing fields into Money and reintroduces the arithmetic (Add/Subtract/Multiply/Round) trimmed from fullstackhero#1316, now justified by real production callers: - Add -> Wallet.Credit balance, Invoice subtotal aggregation over line items - Subtract -> Wallet.Debit balance - Multiply -> BillingPlan.TermPrice (annual fallback = monthly * 12) - Round -> InvoiceLineItem amount (quantity * unitPrice, away-from-zero) Persistence: aggregate-level amounts that already had a paired Currency column reuse it via OwnsOne (Wallet.Balance, Invoice.SubtotalAmount, BillingPlan.MonthlyBasePrice) — pure no-op remap. Sub-entity/optional amounts gain a per-row currency column (WalletTransaction.Currency, InvoiceLineItem.AmountCurrency, BillingPlan.AnnualPriceCurrency), backfilled from the owning aggregate's currency before the NOT NULL columns are enforced. Contract DTOs stay decimal (unwrapped at the mapping boundary), so there is no API or frontend impact. BuildingBlocks change (Money arithmetic) is the reintroduction pre-approved by the maintainer in the fullstackhero#1316 review. Verified: build clean (-warnaserror), unit (Framework 112, Billing 123, Catalog 65, Arch 51), integration 730/731 on real Postgres, backfill exercised on populated data (EUR/GBP/BRL inherited from parent), no pending model changes vs snapshot.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
iammukeshm
left a comment
There was a problem hiding this comment.
Verdict: approved.
This is exactly the follow-up requested in #1316, delivered as specified. The arithmetic reintroduction (Add/Subtract/Multiply/Round + operators) was pre-approved in that review on the condition that it lands together with real production callers — and every method now has one: Add → Wallet.Credit balance + Invoice.RecalculateTotals, Subtract → Wallet.Debit, Multiply → BillingPlan.TermPrice annual fallback, Round → InvoiceLineItem amount. Golden-rule-4 (BuildingBlocks) approval bar is satisfied by that recorded review.
What I verified:
- Money arithmetic — the
with-expression cloning is safe because the constructor uppercasesCurrency, so theOrdinalcomparison inEnsureSameCurrencynever sees mixed case. Signed amounts stay allowed (ledger semantics from #1316), and away-from-zeroRound(2)exactly preserves the oldInvoiceLineItemrounding — the existing 0.005 → 0.01 test passes untouched. - Migration —
20260701063312is correctly ordered after20260626155932. The backfill is sound:WalletTransactionandInvoiceLineItemboth have required cascade FKs to their parents, so no orphan rows can defeat the NOT NULL promotion;AnnualPriceCurrencyis only set whereAnnualPriceexists, matching the optional owned navigation. The three aggregate-level adoptions (Wallet.Balance,Invoice.SubtotalAmount,MonthlyBasePrice) genuinely reuse existing columns — pure no-op remaps, verified against the snapshot diff. - Behavior preservation —
RecalculateTotalsseeds the fold withMoney.Zero(SubtotalAmount.Currency)(set atCreateDraft, never unset), and line items always inherit the invoice currency, so theAddcurrency check cannot false-positive. Contract DTOs staydecimal; no API or frontend impact. - CI — fully green: unit, integration (Testcontainers), coverage gate, DbMigrator container smoke, both scaffold builds, CodeQL.
Non-blocking follow-up notes:
Wallet.Debitbuilds the negativeMoneytwice —debitfor the subtraction plus an inlinenew Money(-amount, Balance.Currency)for the ledger row. Cosmetic; feel free to fold into one.- Good next follow-up:
Wallet.Credit/Debitstill take rawdecimaland stamp the wallet's own currency, soMarkInvoicePaidAsynccreditinginvoice.SubtotalAmount.Amountwould silently treat an EUR invoice as a USD wallet credit at face value. Pre-existing behavior, but now that both sides areMoney, passing it through and lettingAdd/Subtractthrow on mismatch closes the hole for free. Happy to take that as a small follow-up PR. - Your NU1903 heads-up is stale —
mainalready pinnedMicrosoft.OpenApito the patched 2.9.0 in #1319, so no separate PR needed there.
Thanks for the thorough PR description and for exercising the backfill on populated multi-currency data — that made this easy to review.
What
Follow-up to #1316. Promoting
MoneytoBuildingBlocksleft the Billing module modeling money two ways:MoneyonTopupRequest, but rawdecimal+CurrencyonWallet,WalletTransaction,Invoice,InvoiceLineItemandBillingPlan. As requested in the #1316 review ("land the follow-up promptly so the half-state stays short-lived"), this folds the remaining money-bearing fields intoMoneyand reintroduces theMoneyarithmetic (Add/Subtract/Multiply/Roundand the+/-/*operators) that was trimmed from #1316, now justified by real production callers:AddWallet.Creditbalance,Invoice.RecalculateTotalssubtotal over line itemsSubtractWallet.DebitbalanceMultiplyBillingPlan.TermPrice(annual fallback = monthly base * 12)RoundInvoiceLineItemamount (quantity * unitPrice, away-from-zero)Persistence
Aggregate-level amounts that already had a paired
Currencycolumn reuse it throughOwnsOne, so those adoptions are pure no-op remaps with no schema change:Wallet.BalanceInvoice.SubtotalAmountBillingPlan.MonthlyBasePriceSub-entity and optional amounts gain a per-row currency column, since a
Moneyvalue object carries its own currency:WalletTransaction.Currency(new, NOT NULL)InvoiceLineItem.AmountCurrency(new, NOT NULL)BillingPlan.AnnualPriceCurrency(new, nullable, only set whenAnnualPriceis present)Migration
20260701063312_BillingMoneyValueObjectAdoptionadds the three columns as nullable, backfills each from the owning aggregate's currency (WalletTransactionfrom itsWallet,InvoiceLineItemfrom itsInvoice,BillingPlan.AnnualPriceCurrencyfrom the plan's ownCurrency), then enforces NOT NULL on the two required ones. Existing rows keep their aggregate's currency; no data loss.Contract impact
None. All contract DTOs stay
decimaland are unwrapped at the mapping boundary (same pattern #1316 used forTopupRequestDto), so there is no API or frontend change.BuildingBlocks
The only
BuildingBlockschange is reintroducing theMoneyarithmetic, which is exactly the reintroduction pre-approved in the #1316 review.Moneystays dependency-free and passes the BuildingBlocks independence, immutability and layering architecture tests.Verification
-warnaserror, 0 errors.dotnet ef migrations has-pending-model-changesreports no drift versus the snapshot.Heads-up (unrelated)
A fresh
dotnet restorecurrently fails repo-wide onNU1903because the transitively resolvedMicrosoft.OpenApi 2.0.0(viaMicrosoft.AspNetCore.OpenApi/Scalar.AspNetCore) is flagged by advisory GHSA-v5pm-xwqc-g5wc, andTreatWarningsAsErrorspromotes it to an error. This is pre-existing and independent of this PR (it affectsmainon any clean restore). Happy to send a separate PR bumpingMicrosoft.OpenApito the patched2.7.5if useful.