# Finance — Earnings + Wallet/Withdrawals + VAT — LOGIC parity

Demo (canonical) = React+TS at `/private/tmp/Navagoo_MI_dev/navagoo-app/src`.
Ours = Yii2 advanced template at repo cwd.

## 0. Architectural shape

**Demo.** A single pure-function financial engine: `lib/finance.ts`
(`/private/tmp/Navagoo_MI_dev/navagoo-app/src/lib/finance.ts:1-657`). Money flows
`Collect → Earn → Charge → Settle`. Every fee is **one row in a single `charges`
ledger**; rates are *stamped at creation and never recomputed*
(`finance.ts:1-7`). A booking's whole fee set is derived by one function,
`deriveBookingCharges` (`finance.ts:234-289`), called by the seed and by the
store on every booking transition, so the ledger always reflects current state.
Validated to the cent by `finance.test.ts:137-278` (5 CEO-signed worked examples)
and `vat.test.ts`.

**Ours.** No ledger. Two denormalised rows per paid booking:
- `Earnings` (`common/models/base/Earnings.php`) — the per-booking fee math, with
  calc methods `calculateShopVat` (`:547`), `calculateNetCollectedExclVat`
  (`:581`), `calculateNavagooMarketingFees` (`:595`), `calculateVatNavagoo`
  (`:610`), `calculateNavagooNetFees` (`:627`), all driven by
  `calculateFinancialFields` (`:646`).
- `ShopEarning` (`common/models/base/ShopEarning.php`) — the shop-facing payout
  row, `calculateNetCollectibleAmount` (`:268`) + `beforeSave` recompute (`:298`).
Subclasses are empty wrappers (`common/models/{Earnings,ShopEarning,Withdrawal}.php`).
Fees are **recomputed on save**, not stamped/frozen.

## 1. VAT split

**Demo.** `noVat(amount, vatRegistered, vatPct)` (`finance.ts:31-34`):
`vatRegistered ? amount / (1+vatPct/100) : amount`. `vatBreakdown`
(`finance.ts:67-71`) splits an inclusive price into `{base, vat}` for display
only; `vat.test.ts:9-31` locks `vatBreakdown(115,true,15) → base 100 / vat 15`
and the rounding-safe invariant `round2(base+vat)===inclusive`.

**Ours.** `Earnings::calculateShopVat` (`:547-573`): same formula —
`netAmount = final/(1+rate); shopVat = netAmount*rate`. Gated on
`shop->is_taxable == IS_TAXABLE_YES` (`:554`) — equivalent to demo's
`vatRegistered`. Rate is global `Settings::findOne(1)->taxes` (`:561`), demo's
is `config.vatPct`. **Match on the shop-VAT split.**

## 2. Marketing fee

**Demo.** `marketingFeeDraft` (`finance.ts:125-151`): basis =
`noVat(bookingValue,...)`; `amount = basis × marketingFeeRatePct/100`, then
**floored at `config.minMarketingFee`** (default 5, `finance.test.ts:46`); waived
to 0 inside the grace window. Worked example B2: `90×5%=4.50 → max(.,5)=5`
(`finance.test.ts:195`). Only emitted for `classification === 'navagoo_sourced'`
(`finance.ts:132`, `248`). VAT = `amount × vatPct/100` added **on top**.

**Ours.** `Earnings::calculateNavagooMarketingFees` (`:595-601`): basis =
`net_collected_excl_vat` (collected−shopVAT), `× shop->platform_commission/100`.
**Differences:**
- Basis is **collected-minus-VAT**, demo's is **booking-value-minus-VAT** — the
  same only when fully collected online (example A). On pay-on-visit / deposit /
  cancel they diverge.
- **No `minMarketingFee` floor** — ours can produce a fee below the demo's min 5.
- **No grace-window waiver.**
- **No `navagoo_sourced` vs `shop_owned` classification** — ours charges every
  booking the platform commission regardless of who sourced the customer. Demo
  charges marketing fee *only* to navagoo-sourced (`finance.ts:248`,
  example D `shop_owned → no marketing fee`, `finance.test.ts:237-256`).

## 3. Payment-processing (gateway) fee — **ABSENT in ours**

**Demo.** `paymentProcessingFeeDraft` (`finance.ts:95-118`): only when
`amountCollected>0`; `amount = collected×rate% + fixed`; VAT on top;
non-refundable; never reverses (example C `proc=3.57` survives the cancel,
`finance.test.ts:226`). It is a first-class settlement fee.

**Ours.** No payment-processing-fee concept anywhere (grep across
frontend/backend/common/api returns nothing). `Earnings` has a `gateway_collection`
column but no rate/fixed computation. **Missing.**

## 4. Notification (SMS/WhatsApp) + subscription charges — **ABSENT in ours**

**Demo.** `notifFeeDraft` (`finance.ts:154-177`, count×unit) and
`subscriptionChargeDraft` (`finance.ts:180-197`, billed to card,
`settlementMethod:'charge_to_card'`). Not part of our Earnings/Wallet feature.

## 5. Refund / reversal

**Demo.** `refundZoneFor` (`finance.ts:318-323`) maps cancel-time vs
`cancelFullHours`/`cancelPartialHours` to `full|partial|none`. `customerRefund`
(`finance.ts:326-338`): shop-cancel → full; deposit → 0 (non-refundable);
else by zone (`partial → ×partialRefundPct/100`). Marketing fee is **partially
reversed** by a negative ledger row on a customer cancellation (`reversalDraft`
`finance.ts:205-222`; wired in `deriveBookingCharges:269-282`). Example C:
original 12.25, reversal −6.13, net marketing 6.12 (`finance.test.ts:221-231`).

**Ours.** `refund_value` is a stored column subtracted in
`calculateFinalCollectedAmount` (`Earnings.php:531-538`:
`subtotal+serviceFee−refund`) and `ShopEarning` (`refund_value` default 0). There
is **no zone computation, no shop-vs-customer rule, no deposit-non-refundable
rule, and no fee-reversal row** — the refund is just a pre-entered number. **Missing
business logic.**

## 6. Net collectible / payout

**Demo.** `bookingSettlement` (`finance.ts:358-383`):
`netPayout = (collected−refund) + tips − marketingFees − processingFees − feeVat`,
summing only UNPAID non-pending fee rows. Distinguishes four views:
`bookingRevenue` (`:392`), `bookingFeesIncurred` (`:424`), `bookingNetEarnings`
(`:439`), `bookingNavagooPayout` (`:455`).

**Ours.** `ShopEarning::calculateNetCollectibleAmount` (`:268-293`):
`final_collected + tip − navagoo_fees − vat_navagoo`, branched by
`booking_method` (Mobile vs Walk-in-Shop where final_collected is dropped;
Walk-in-Specialist excluded by the controller filter
`EarningsController.php:72-78`). **Conceptually matches** the demo's payout for the
fully-online case, BUT: no processing fee subtracted, marketing-fee basis differs
(§2), and there is one flat figure vs the demo's four reconciling views.

## 7. Settlement eligibility & withdrawable balance

**Demo.** `isEligible` (`finance.ts:472-478`): booking terminal
(completed | no_show | cancelled) AND `daysBetween(now, transactionDate) ≥
settlementHoldDays`. `shopSettlement` (`finance.ts:493-520`) aggregates
`eligibleNet` and `meetsWithdrawalMin = eligibleNet ≥ minWithdrawalAmount`.
`shopRunningBalance` (`finance.ts:588-619`) + `shopCarriedBalance`
(`finance.ts:622-631`) model **carry-forward debt** when fees exceed collectable
earnings (negative balance = shop owes Navagoo).

**Ours.** `EarningsController::actionIndex` (`:147-205`) and
`AgentsWalletController::actionCreate` (`:248-360`): take `ShopEarning` rows in
`PENDING`/`REQUESTED` with `withdrawal_id IS NULL`, filter by
`daysElapsed ≥ shop->minimum_elapsed_period_days` (default 7,
`EarningsController.php:167`), sum `net_collectible_amount`, and only expose it
for withdrawal if `total ≥ shop->minimum_withdrawal_amount` (default 5000,
`:166,197`). **Matches** demo's hold-days + min-withdrawal gating. **Differences:**
- Hold measured from `created_at` of the earning row, not the booking's
  *transaction date* (`transactionDate` = completed/cancelled/no-show timestamp,
  `finance.ts:462-464`).
- **No carry-forward / negative-balance model.** A booking that nets negative
  (B2, `−5.75`) cannot exist as a ShopEarning the way the demo carries it.
- Eligibility is by `settlement_status`, not by recomputing booking terminality.

## 8. Withdrawal creation flow

**Demo.** Pure selectors feed a transfer-request; reversals/paid fees are netted
via `outstandingChargeTotals` (`finance.ts:541-559`) and consumed-booking sets so
nothing is double-settled.

**Ours.** `AgentsWalletController::actionCreate` (`:210-520`):
builds `Withdrawal`, sums collected/tips/fees/VAT across eligible earnings
(`:393-454`), de-duplicates tip vs booking rows by heuristics (`:298,406`), then
`processEarnings` (`:529-549`) stamps `settlement_status=REQUESTED` +
`withdrawal_id` on each `ShopEarning`/`Earnings`, and `processPaymentsForEarnings`
(`:557-570`) flags the `Payment`. Status set
`SETTLEMENT_STATUS_REQUESTED`/`STATUS_READY_TO_WITHDRAWAL` (`:463-464`). This is a
real, working withdrawal pipeline the demo doesn't fully model (the demo stops at
balance selectors). The tip-vs-booking double-count guarding (`:296-301`) is a
data-shape workaround with no demo equivalent.

## Summary of logic gaps (ours vs canonical)
1. No frozen ledger — fees recomputed on save (drift risk vs stamped rates).
2. No payment-processing (gateway) fee. (§3)
3. No customer classification → marketing fee charged to everyone. (§2)
4. No `minMarketingFee` floor. (§2)
5. No grace-window waiver. (§2)
6. Marketing-fee basis = collected-excl-VAT vs demo's booking-value-excl-VAT. (§2)
7. No refund-zone / shop-vs-customer / deposit-non-refundable rules; no
   marketing-fee reversal row. (§5)
8. No carry-forward / negative-balance model. (§7)
9. Hold period measured from earning `created_at`, not booking transaction date.
