feat: Inventory Investment System (two-stream investor financing) - #22
Conversation
…uite Pure engine (FIFO allocation, sale split, invariant, partial funding, buyback math) plus transactional services: accounting (create/sale/adjust/reverse), buyback (per-stream ledger entries, paidProfit = max(0, availableProfit)), profit-only withdrawals. Immutable ledger with idempotency-key doc IDs via tx.create and in-tx duplicate query on referenceOrderItemId. requireInvestor() guard, investment audit actions, 84-assertion engine test suite, read-only ledger-replay reconciliation script.
11 routes: admin investor/investment CRUD, ledger, buyback quote/execute, reports with invariant health, withdrawals (investor-own or admin), IDOR-proof investor dashboard and detail. Order integration: Dispatched triggers processInvestmentSalesForOrder (per-item FIFO, allowPartial, idempotent); cancellation reverses with compensating rev_ ledger entries. CSV exports for investors/investments/ledger.
Admin: /admin/investments overview (investments/investors/withdrawals tabs, CSV exports) and /admin/investments/[id] detail (balances, allocations, ledger, buyback, manual adjustments); sidebar link. Investor portal: /investor dashboard (summary, withdrawal request/history) and /investor/investments/[id] (allocations, stream-filtered ledger). Server-gated layouts and middleware gate (investor OR admin); APIs remain the security boundary. Collections parity in legacy frontend firebase-admin copy.
Resolves react-hooks/set-state-in-effect lint error while keeping the hydration render identical to server output.
README 6.12 two-stream model (invariant, streams, recognition point, FIFO, idempotency, buyback, worked example, verification scripts) + collections list. Project state doc updated through Phase 4 hardening.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- GET /api/investor/statement: full account position (original vs additional capital, recovered/remaining, realized/withdrawn/available profit, account value, buyback-value-today, ROI) + monthly breakdown + complete transaction history from the immutable ledger. Session-resolved for investors; admins may pass ?investorId=. - New print-friendly /investor/statement page (Print / Save PDF). - Investor dashboard: Remaining Capital, Available Profit, Account Value, ROI cards + statement link. - Admin investors tab: Remaining / Available / ROI columns (live from investment docs) + per-investor Statement link.
…count Final accounting decision (external review): for investor-funded sales, actual net profit = investor profit + Valore (owner) profit. At Dispatched the investment ledger is processed FIRST; store-share crediting then recognises net - max(0, investor profit) per item via the new pure ownerRecognizedItemProfitMajor(). The per-item deduction is persisted as investmentRecognition on the order item and reused by the cancellation reversal (symmetric by construction). Non-funded sales unchanged. Also: cancel route reversal gate now uses the NORMALIZED completed-family status (Delivered/Fulfilled aliases reverse too); investor statement distinguishes capital recovered through sales vs returned through buyback (splitCapitalBySource); dashboard owner breakdown scales funded items to the recognised portion; canonical example (900/600/100 -> 200 = 80 + 120) plus non-interference, loss-clamp, partial-funding and capital-by-source tests (106 passing).
Final pre-merge financial review (2026-08-18) ? resolvedAccounting decision: For every investor-funded sale, �ctual net profit = investor profit + Valore (owner) profit. The investor share is carved out of the item's net profit before owner crediting ? the owner P&L never recognises the full net profit while the investor ledger recognises the share on top. Single source: ownerRecognizedItemProfitMajor() in �ackend/src/lib/investments/finance.ts. Canonical example (tested): Revenue 900, perfume cost 600, direct costs 100 ? net 200 = investor 80 (40%) + Valore 120. Owners are credited 120 (split 60/40 ? 72 + 48); total economic profit is exactly 200. Recognition event: order status Dispatched (the completed-family key; Delivered/Completed/Fulfilled aliases normalize to it) is THE financial recognition event for both owner P&L and investor ledger. Cancellation from it reverses both sides symmetrically: the owner reversal reuses the per-item investmentRecognition deduction persisted at credit time; the investor ledger reverses via Buyback (re-verified): positive profit ? remaining inventory cost + available profit; zero ? remaining cost only; negative ? capital only, loss written off with an explicit ledger adjustment (_buyback_writeoff) ? history stays visible (negative profit_generated entries remain in monthly reports and the immutable ledger). Investor statement: now distinguishes capital invested (original/additional per investment doc), capital recovered through sales vs returned through buyback (splitCapitalBySource), remaining inventory exposure, realized profit, withdrawable profit, and profit already withdrawn. Tests: 106/106 engine tests pass (�ackend/scripts/test-investments.ts), including new ?12 owner-P&L integration (canonical example, non-interference of funded/unfunded items, loss clamp, partial funding, rounding exactness) and ?13 capital-by-source. Reconciliation check-investments.ts: all checks pass (live DB has no investment data yet ? fix lands before first use). Backend + frontend sc --noEmit clean. Known limits (documented): lot cost basis is locked at funding; if perfume.purchasePricePerMl drifts, owner-side net uses the store basis while the deduction uses the actual ledgered investor profit ? the identity owner recognised + investor profit = owner-basis net still holds. Items whose investment processing fails produce no investor entries and no deduction (consistent either way). |
…iliation tests Accounting integration audit of the owner P&L carve-out: traced every owner-profit consumer (credit, both cancel reversals, dashboard breakdown, owner-accounts recompute, withdrawable-revenue deductions) and confirmed no double count remains. /api/investments/reports now returns profitAttribution (Gross Economic Profit = Investor Profit + Valore Retained Profit, computed from persisted per-item investmentRecognition over completed-family orders, with invariantHolds and loss-clamp visibility) rendered as a panel on /admin/investments. New test section 14: combined business P&L reconciliation over a mixed funded/non-funded/loss/partial batch (117/117). Reconciliation script passes; production has zero investment docs so no historical migration is needed.
Adversarial pre-merge review found and fixed: (1) concurrent/duplicate Dispatched PUTs could double-credit owners (loser credited FULL profit because its investment items were skipped as already-processed); owner crediting is now computed up front and committed in one Firestore transaction guarded by a profitCreditedAt claim. (2) Both cancel paths now share an atomic profitReversedAt claim; the standalone cancel route also now reverses owner-revenue-base credits (pre-existing under-reversal) and rejects re-cancelling a Cancelled order. (3) Stock restoration is atomic behind a shared stockRestoredAt claim (re-cancel previously inflated inventory). orderIntegration heals retries by recovering skipped items' amounts from the ledger. New scripts/e2e-investments.ts: 62-assertion live E2E (real HTTP + Firestore, namespaced fixtures, full cleanup) covering recognition, carve-out, idempotency, reversal symmetry, withdrawals, buyback, statements and a 13-check security battery. Full QA: 117/117 engine, 62/62 E2E, reconciliation pass, tsc/eslint clean, both builds green.
Inventory Investment System
External investors finance perfume inventory (no equity). Capital is recovered per-ml as funded stock sells; profit is split by a configurable share; investments close via profit-only withdrawals and store buyback. Built across Phases 2–4, hardened and QA'd in Phase 5.
Summary
amount = recoveredCapital + remainingInventoryCost.Architecture
backend/src/lib/investments/finance.ts— pure engine: FIFO allocation, partial-funding planner, sale split, invariant validation, buyback math. Fully unit-tested.backend/src/lib/investments/accountingService.ts— createInvestment / processInvestmentSale / recordAdjustment / reverseSalesForOrder, all insidedb.runTransaction.backend/src/lib/investments/buybackService.ts— quote + atomic execute (per-stream ledger entries;paidProfit = max(0, availableProfit)).backend/src/lib/investments/withdrawalService.ts— pending → approved (balance re-validated + deducted in tx) → paid.backend/src/lib/investments/orderIntegration.ts— order-lifecycle glue; never throws into order flow.Business Logic
allowPartial: only funded ml is consumed; revenue/costs prorated by funded ml.pricingSnapshot.Database / Firestore Changes
New collections (6):
investors— profile + denormalized totals (ledger-controlled)investments— principal, recovered, remaining, availableProfit, withdrawnProfit, status (active|recovering|closed|bought_back)investmentAllocations— per-perfume lots: fundedMl / remainingMl / soldMl, lockedcostPerMlMinor, status (open|depleted|bought_back)investmentTransactions— immutable ledger; doc ID = deterministic idempotency key;stream: capital|profit|none; written only viatx.createinvestmentWithdrawals—pending|approved|rejected|paidbuybacks— closure recordsIndexes: none required — all queries are equality-only (merged single-field indexes) or sorted in memory by design.
Rules: unchanged deny-all; Admin SDK is the only writer.
APIs (11 new routes)
Admin:
/api/investors(+[id]),/api/investments(+[id],/ledger,/buyback,/reports),/api/investment-withdrawals/[id](approve/reject/paid).Investor (session-resolved, IDOR-proof):
/api/investor/dashboard,/api/investor/investments/[id],/api/investment-withdrawals(own).Extended:
/api/export?type=investors|investments|investment-ledger;orders/[id]PUT +/cancelwired to investment sale processing/reversal.Services / Components / Dashboards
/admin/investments(tabs: investments / investors / withdrawals, CSV exports) and/admin/investments/[id](balances, allocations, ledger, buyback, manual adjustments)./investor(summary cards, investments table, withdrawal request + history) and/investor/investments/[id](allocations, stream-filtered ledger).monthlyBreakdown,byPerfume, withdrawal stats.Testing
backend/scripts/test-investments.ts— 84 assertions, all green: golden worked example, FIFO, rounding/integer safety, losses, invariant, buyback math, lifecycle simulation, withdrawal caps, partial-funding planner, reversal restoration, buyback stream separation.backend/scripts/check-investments.ts— read-only live reconciliation: invariant, lot capital, ml conservation, full ledger replay (capital + profit streams), investor counters. Exits non-zero on drift.tsc --noEmitclean, ESLint 0 errors, production builds pass.Security
requireInvestor()passes investor OR admin; never satisfiesrequireAdmin(). All mutating admin endpoints callrequireAdmin().tx.create+ in-transaction ledger query onreferenceOrderItemId./investor; Firestore client access remains fully denied.Performance
/api/investments/reportsfull-scans the investment collections per admin load — fine at current volume, paginate later.Migration Notes
Rollback Strategy
/api/export?type=investment-ledger); balances remain fully reproducible from the immutable ledger at any later date.Known Limitations
Future Improvements