Skip to content

feat: Inventory Investment System (two-stream investor financing) - #22

Merged
Tayebbb merged 11 commits into
mainfrom
feature/inventory-investment-system
Aug 19, 2026
Merged

feat: Inventory Investment System (two-stream investor financing)#22
Tayebbb merged 11 commits into
mainfrom
feature/inventory-investment-system

Conversation

@Tayebbb

@Tayebbb Tayebbb commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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

  • Two-stream accounting: Stream A (capital recovery at lot-locked cost/ml, never split, never withdrawable) and Stream B (profit, split investor/business, can go negative on losses).
  • Hard invariant enforced in every transaction: amount = recoveredCapital + remainingInventoryCost.
  • Fully integrated with the order lifecycle at the existing profit-recognition point (Dispatched), with idempotent processing and exact compensating reversal on cancellation.
  • Admin management UI + read-only investor portal with profit-withdrawal requests.

Architecture

  • backend/src/lib/investments/finance.tspure 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 inside db.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

  • Sales consume allocations FIFO (oldest lot first); multi-lot sales prorate revenue/costs by ml, remainder to last lot.
  • Oversells use allowPartial: only funded ml is consumed; revenue/costs prorated by funded ml.
  • Selling price excludes delivery fee; selling costs = packaging + bottle from the item pricingSnapshot.
  • Losses reduce profit (can go negative); capital always recovers in full.
  • Personal-collection perfumes can never be investor-funded.

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, locked costPerMlMinor, status (open|depleted|bought_back)
  • investmentTransactionsimmutable ledger; doc ID = deterministic idempotency key; stream: capital|profit|none; written only via tx.create
  • investmentWithdrawalspending|approved|rejected|paid
  • buybacks — closure records

Indexes: 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 + /cancel wired to investment sale processing/reversal.

Services / Components / Dashboards

  • Admin: /admin/investments (tabs: investments / investors / withdrawals, CSV exports) and /admin/investments/[id] (balances, allocations, ledger, buyback, manual adjustments).
  • Investor portal: /investor (summary cards, investments table, withdrawal request + history) and /investor/investments/[id] (allocations, stream-filtered ledger).
  • Reports endpoint recomputes aggregates on read + invariant health, monthlyBreakdown, byPerfume, withdrawal stats.

Testing

  • backend/scripts/test-investments.ts84 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.
  • Both apps: tsc --noEmit clean, ESLint 0 errors, production builds pass.

Security

  • requireInvestor() passes investor OR admin; never satisfies requireAdmin(). All mutating admin endpoints call requireAdmin().
  • Investor identity always resolved from the signed session (userId → email), never from query params; foreign investment IDs return 404.
  • Replay/duplicate protection: ledger doc IDs are idempotency keys via tx.create + in-transaction ledger query on referenceOrderItemId.
  • Defense-in-depth: server-gated layouts + frontend middleware gate on /investor; Firestore client access remains fully denied.

Performance

  • All money math is integer minor-units (no float drift).
  • Equality-only Firestore queries; no composite indexes needed.
  • Ledger/withdrawal listing sorts in memory (documented trade-off to avoid index management at current scale).
  • Known tech debt: /api/investments/reports full-scans the investment collections per admin load — fine at current volume, paginate later.

Migration Notes

  • No data migration required — all collections are new; existing orders/finances untouched.
  • No new environment variables.
  • Deploy backend and frontend together (frontend middleware + investor portal expect the new APIs).

Rollback Strategy

  • Revert this branch; new collections are additive and inert without the code (no existing feature reads them).
  • If investments were created before rollback: export ledger CSVs first (/api/export?type=investment-ledger); balances remain fully reproducible from the immutable ledger at any later date.

Known Limitations

  • Store-side owner profit crediting still counts full item profit; investor share is tracked in the parallel investment ledger (business-books netting is a pending product decision).
  • Partial refunds are manual admin adjustments only.
  • Reversal after buyback is refused (manual adjustment required).
  • Re-dispatch after cancellation cannot re-process sales (idempotency keys persist) — unreachable via the order state machine.

Future Improvements

  • Aggregate/paginate the reports endpoint at scale.
  • Optional netting of investor share out of store owner profit split.
  • Email notifications for withdrawal status changes.
  • Composite index + server-side ledger ordering if entries per investment exceed ~1k.

Tayebbb added 5 commits August 2, 2026 21:33
…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.
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
valore-parfums Ready Ready Preview Aug 19, 2026 2:12pm
valore-parfums-backend Ready Ready Preview Aug 19, 2026 2:12pm

- 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).
@Tayebbb

Tayebbb commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

Final pre-merge financial review (2026-08-18) ? resolved

Accounting 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
ev_ compensating entries. The standalone cancel route now gates on the NORMALIZED status (alias-stored orders reverse too).

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.
@Tayebbb
Tayebbb merged commit f44897e into main Aug 19, 2026
1 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant