Finance-Accounts/plan.md

163 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# AR Aging App — Production-Ready Plan
**Date:** 19 Aug 2026
**Goal:** Upload Jan file → Jan data is saved and shown. Upload Feb later → Feb becomes a NEW month dataset; all previous months stay saved and selectable (never merged). Hosted on AWS with backups, automatic exchange rates, and a 5-user login.
**Bottom line:**
| | |
|---|---|
| AWS hosting cost | **≈ $5055 / month** |
| Exchange-rate API | **$0 / month** (Frankfurter, free) |
| Build effort | **~1217 dev days**, 5 phases, each shippable on its own |
---
## 1. What is wrong today (why months "merge" or disappear)
The app already stores each month as its own closing session in the database — the foundation is right. Four defects break it:
1. **Double-count bug (verified in code):** re-uploading a file with the same name overwrites it on disk (`ar-aging-app/backend/app/api/routes/files.py:38`) but inserts a **second** database row (`files.py:74`). Processing then parses the same file twice and **doubles every number**.
2. **Nothing prevents two sessions for the same month** — creating "January" twice by accident produces two competing datasets, no warning.
3. **New months start from zero** — default `opening_mode="zero"` even though carry-forward logic already exists in `routes/ar.py`, so months lose continuity unless someone remembers to switch it.
4. **Previous months silently vanish from Accounts Summary** — re-processing clears journal approval (correct audit control), but the summary grid only shows approved months, so the old month disappears with no explanation.
## 2. Confirmed decisions
- **Exchange rates:** free **Frankfurter API** — $0, no API key, central-bank (ECB) rates, historical month-end dates. The human confirmation gate (Control C5) stays.
- **Login:** simple per-user login, **5 users**. Approver/reviewer names come from the logged-in user.
- **File sizes:** uploads are **300500 MB** each → server sized with **8 GB RAM** for Excel parsing.
---
## 3. AWS architecture & monthly cost
### Recommended: one production server + S3 backups (≈ $5055/mo)
**Lightsail 8 GB instance** ($44/mo: 2 vCPU, 8 GB RAM, 160 GB SSD, static IP included) — or equivalent EC2 `t4g.large` (≈ $61/mo with EBS + IPv4). Runs everything via a new `docker-compose.prod.yml`:
| Container | Role |
|---|---|
| **nginx** | Serves built React app, proxies `/api` to backend, HTTPS (Let's Encrypt), `client_max_body_size 2g` + long timeouts for big uploads |
| **backend** | FastAPI/uvicorn, **single worker** (background jobs are in-process — documented constraint, fine for 5 users) |
| **MySQL 8** | Data on instance disk. Code already supports MySQL (`AR_DB_BACKEND=mysql`) and ships `migrate_sqlite_to_mysql.py` |
| **backup cron** | Nightly `mysqldump` + `aws s3 sync` of uploads/exports → versioned S3 bucket (lifecycle → cold storage after 90 days), via IAM role (no keys on disk) |
### Cost breakdown
| Item | Monthly cost |
|---|---|
| Lightsail 8 GB (EC2 t4g.large route ≈ $61) | $44 |
| S3 backups (~60 GB year one, versioned) | $1.503 |
| Weekly instance snapshots | $24 |
| Route 53 hosted zone (optional domain) | $0.50 |
| Frankfurter FX API | $0 |
| **Total** | **≈ $5055/mo** |
**Alternative (managed DB):** same server + **RDS MySQL db.t4g.small****$85100/mo** — buys automated patching + point-in-time restore. Not needed at this scale; upgrading later is a one-line config change (`MYSQL_HOST`).
**Storage decisions:**
- Uploads/exports stay on the **instance disk** — the parsing pipeline needs local file paths; round-tripping 500 MB files through S3 adds complexity for no benefit. S3 = durable **backup**, not primary storage.
- Data growth ~1 GB/month (~3.4M transaction rows) — well within sizing.
- Firewall inbound restricted to office IPs/VPN as defense-in-depth on top of login.
### Exchange-rate API pricing (researched)
| Provider | Free tier | Paid | Verdict |
|---|---|---|---|
| **Frankfurter** (chosen) | Unlimited, **no API key**, ECB rates, historical dates, self-hostable | — | ✅ $0, ideal for month-end closes |
| ExchangeRate-API | 1,500 req/mo, daily updates | Pro $10/mo (30k req, hourly) | fallback provider stub |
| Open Exchange Rates | 1,000 req/mo, USD base only | $12/mo | not needed |
| Fixer | 100 req/mo, no HTTPS on free | higher | ruled out |
App usage: ~13 marketplaces × a few fetches/month → even free tiers would never be exceeded.
---
## 4. Implementation phases
### Phase 1 — Month management & data separation (~34 days) ← HIGHEST PRIORITY
**1.1 Fix double-count bug**`backend/app/api/routes/files.py`:
- Stream uploads to a temp `.part` name; move into place after hashing (also stops a failed upload corrupting an existing good file).
- **Content dedup:** SHA-256 match with an existing file in the session → skip, report "identical content already uploaded as X".
- **Same filename → UPDATE the existing row** instead of inserting a second one; if month already processed, flag `needs_reprocess` (existing banner handles messaging).
- Batch-friendly response `{files, skipped}` so one duplicate doesn't fail a 13-file upload; UI shows skipped/replaced notices.
- One-time `cli.py dedupe-files` cleanup for existing bad rows, then a unique index on (session_id, filename).
**1.2 One session per month (guide, don't hard-block)**`routes/sessions.py`: 409 on duplicate `reporting_month` unless `allow_duplicate: true`; `NewClosing.tsx` shows "A closing for 2026-02 already exists — Open it | Create anyway".
**1.3 Default carry-forward**`NewClosing.tsx`: default `openingMode="carry_forward"` when prior processed months exist (backend logic already exists); default month = month after latest close.
**1.4 Month selector UX ("show previous options"):**
- Sessions list ordered as a month timeline with a `journal_approved` flag per row.
- New `MonthSwitcher` dropdown in the closing header (month · name · status) — jump between months from any screen.
- Dashboard shows "published / processed-but-unpublished" and flags duplicate months.
**1.5 Stop previous months vanishing from Accounts Summary:**
- `accounts_summary.py` also returns pending months (processed/blocked but unapproved, incl. "approval cleared by re-processing").
- Grid renders them as greyed columns linking to the journal tab ("2026-01 processed but unpublished — re-approve the journal"). Audit control untouched.
**1.6 Read-only lock after completion** — new `ensure_editable()` guard on all mutating endpoints (409 on completed months); new `POST /sessions/{id}/reopen`; frontend disables edit controls on locked months.
**Tests:** filename replace, sha256 skip, duplicate-month 409, completed-session 409s.
### Phase 2 — Login, 5 users (~2 days)
- New `User` table (username, display name, bcrypt password hash); users created via CLI (`add-user` / `set-password`) — no self-signup.
- `POST /api/auth/login` → signed bearer token (12 h expiry, new `AR_SECRET_KEY` env); every route requires login except `/api/health` + login.
- **Real identity in sign-offs:** journal review/approve, FX confirm, control verify, payout entry all record the logged-in user — free-text "your name" boxes removed.
- Frontend: `Login.tsx`, token auto-attached, 401 → redirect to login, user + logout in sidebar.
- New deps: `passlib[bcrypt]`, `itsdangerous`.
### Phase 3 — AWS deployment (~35 days incl. migration dry-run)
1. Production backend image (no `--reload`, `--proxy-headers`, single worker documented).
2. Production frontend image: multi-stage `node:20` build → `nginx` serving static + API proxy.
3. New `docker-compose.prod.yml` + `example.env.production` (`AR_DB_BACKEND=mysql`, `AR_CORS_ORIGINS=https://<domain>`, `AR_SECRET_KEY`, FX vars); HTTPS via certbot; DNS → static IP.
4. **Data migration:** MySQL up → schema auto-created → run existing `migrate_sqlite_to_mysql.py` → verify per-table row counts + to-the-cent reconciliation spot check → cut over; archive SQLite file to S3. **Timed dry run first** (3.4M rows/month).
5. Backups: nightly dump + S3 sync, weekly snapshots, S3 versioning + lifecycle.
6. Deploy runbook: `git pull && docker compose -f docker-compose.prod.yml up -d --build`.
### Phase 4 — Exchange-rate service (~2 days)
- New `backend/app/services/fx_service.py`: provider abstraction — **Frankfurter** default (`api.frankfurter.dev/v1/{date}?base=USD&symbols=CAD,GBP,AUD,EUR,PLN,SEK,TRY`), paid-provider stub via `FX_PROVIDER` env.
- Rates **inverted to USD-per-local** (the app's convention) — documented + locked with a test; one inversion mistake would mis-state every non-USD receivable.
- Month-end fetch seeds `fx_rates` per session **unconfirmed** → Control C5 still blocks the close until a human confirms — workflow unchanged, just pre-filled. Daily fetch fills `fx_rates_daily`.
- Cache table makes re-fetch idempotent + works offline after first fetch; provider failure → clear 502 "enter rates manually", never a silent default.
- UI: "Fetch month-end rates" button in the C5 panel (`Controls.tsx`); "Fetch daily rates" in `ArLedger.tsx`. Update "no third-party egress" copy (only currency codes are sent, never financial data).
- ⚠️ Verify Frankfurter publishes TRY; if not, Turkey stays manual (C5 still enforces) or paid stub takes over.
### Phase 5 — Hardening (~12 days)
1. **Stale-job recovery:** months stuck "processing" after a restart → marked error with a re-run message (otherwise a deploy mid-job blocks the month forever).
2. **Retention:** auto-purge old generated **exports only** (`AR_RETENTION_DAYS`, currently dead config) — uploaded source files never auto-deleted (audit source).
3. Health check: DB ping + disk-writable.
4. Logging: request timing + job start/finish/fail, shipped via Docker logs.
Deferred nice-to-haves: CI pipeline, Sentry, login rate-limiting, Alembic, S3-primary storage, audit-log table, MFA.
---
## 5. Verification (acceptance checks)
- Upload same CSV twice → one file row, totals unchanged; renamed-identical file → skipped with message.
- Close Jan → create Feb → warns nothing, carries forward Jan balances; Jan stays selectable, read-only, unchanged; both months on Accounts Summary.
- Fetch month-end rates → unconfirmed → C5 fails → Confirm all → C5 passes; EUR rate checked against ECB published figure.
- Unauthenticated API call → 401; all 5 users can log in; approvals record the real approver name.
- Prod compose rehearsed locally with a 500 MB file (memory watched) **before buying the instance**; restart mid-processing recovers; nightly backup object appears in S3; restore drill succeeds.
- All existing tests pass, incl. Jan-2026 reconciliation integration test **to the cent** after MySQL migration.
## 6. Key risks
| Risk | Mitigation |
|---|---|
| SQLite→MySQL migration (~3.4M rows/month) | Timed dry run, per-table counts, cent-level reconciliation before cutover |
| FX rate orientation (USD-per-local vs local-per-USD) | Fixture test against a known EUR rate |
| Read-only lock missing an endpoint | Sweep all mutating routes during implementation |
| 8 GB RAM sizing assumption | Validate with the real 283 MB test workbook in local rehearsal before instance purchase |
---
*Pricing sources: [ExchangeRate-API](https://www.exchangerate-api.com/#pricing) · [Open Exchange Rates](https://openexchangerates.org/signup) · [Frankfurter](https://frankfurter.dev/) · AWS Lightsail/EC2/RDS public pricing, Aug 2026. Costs are USD estimates, on-demand.*