# 01 · Architecture

## Goals that shape the design

1. **Single source of truth.** Every module references `merchants.merchant_id` (`ZS-00001`). No module stores its own copy of merchant data.
2. **Rules live in the database as well as the UI.** Approval separation, locked months, restock limits and audit permanence are enforced by constraints and triggers in `schema.sql`, so a bug or a direct SQL edit cannot bypass them.
3. **Manual today, API tomorrow.** Sales, orders and bank transactions carry `source` (`manual` / `csv_import` / `api`) and `external_id`. An integration only becomes another writer into the same tables.
4. **Easy for non-technical staff.** Each merchant page answers: current stage, status, next action, PIC, due date, pending documents, pending verification, open issues.

## System overview

```
            Browser (desktop first, responsive to mobile)
                              │  HTTPS, SSO session cookie
                              ▼
┌────────────────────────────────────────────────────────────────────┐
│ Web app  (Next.js / TypeScript)                                    │
│  • UI per module (prototype = reference)                           │
│  • Server actions / API routes — check permission, then            │
│    BEGIN; SET LOCAL app.user_id = …; <write>; <business audit>;    │
│    <outbox notification>; COMMIT;                                  │
│  • CSV/Excel import service: parse → validate → preview → commit   │
│  • Report/export service: XLSX, CSV, PDF                           │
└───────────────┬──────────────────────────┬─────────────────────────┘
                │                          │
                ▼                          ▼
     PostgreSQL (schema.sql)        Private object storage
     tables, views, triggers        documents, payment proofs,
     audit_logs (append-only)       import files, error reports
                │
                ▼
     Worker (cron / queue)
      • 00:30 create daily settlement batch
      • 09:00 overdue tasks / issues / repayments → notifications
      • 10:30 Finance report reminder if today's report not sent
      • every 5 min: send notifications outbox
      • future: ZUS POS / ZUS Order / Bank pollers → integration_runs
```

## Recommended stack

| Layer | Recommendation | Why | Acceptable alternatives |
|---|---|---|---|
| Frontend + backend | Next.js 14+ (App Router, TypeScript) | One codebase, server-side permission checks, good table/form libraries | Laravel, Django, .NET |
| UI kit | shadcn/ui or Mantine + TanStack Table | Tables, filters, modals, tabs out of the box | Ant Design, MUI |
| Database | PostgreSQL 15+ managed | Triggers/constraints used heavily; point-in-time restore | — (schema is Postgres-specific) |
| ORM / queries | Drizzle or Prisma (with raw SQL for views) | Typed queries; migrations | Kysely, SQLAlchemy |
| Auth | Google Workspace or Microsoft Entra ID SSO via Auth.js / NextAuth | Uses existing company accounts, MFA from the identity provider | Supabase Auth with email OTP (only if SSO is unavailable) |
| Files | S3-compatible private bucket | Signed URLs, versioning | Supabase Storage, Azure Blob |
| Email | SES / Postmark / Microsoft Graph | Deliverability, logs | SMTP relay |
| Excel parsing | SheetJS (xlsx) or ExcelJS | Read .xlsx and .csv the same way | — |
| PDF | Server-side HTML → PDF (Playwright) | Same templates as the screen | pdfmake |
| Hosting | Vercel / AWS / Azure in an Asia region (Singapore) | Low latency to Malaysia | Any Docker host |

## Authentication & sessions

- Sign-in only through company SSO. After the identity provider returns, the app looks up `users.email`; if not found or `status = 'inactive'`, access is denied. There is no self-registration.
- Session: HTTP-only, secure, SameSite=Lax cookie; idle timeout 30 min, absolute 12 h.
- Update `users.last_login_at` on each sign-in and write an audit entry.
- If SSO is not possible, use email one-time codes (no passwords stored) plus TOTP for Full Access and Management.

## Authorisation

- Permission checks happen **on the server** for every action (see `02-roles-permissions.md`). The UI only hides buttons as a convenience.
- Operations users are scoped to merchants where they are `assigned_pic_id` or hold a task. Apply this filter in every list query and in object lookups (no direct-URL access to other merchants).
- Database-level guards (triggers) back up the most important rules: verifier role, executor ≠ verifier, month lock, restock limit, activation gates, audit immutability.

## Write pattern (every mutating request)

```sql
BEGIN;
SET LOCAL app.user_id = '<acting user uuid>';
-- 1. the change itself (INSERT/UPDATE)
-- 2. a business audit row, e.g. action='Rejected', remarks='SSM – document expired'
-- 3. notification rows in `notifications` (outbox) if the event needs email
COMMIT;
```

Row-level triggers already write a technical audit row (previous/new JSON) for every insert/update/delete on business tables; the business audit row adds the human-readable action and reason the requirement asks for.

## Files

- Accept PDF, JPG, PNG up to 10 MB (enforced in `documents`); check MIME by content, not extension.
- Store at `merchants/<merchant_id>/<doc_type>/v<version>/<sha256>.<ext>`; keep every version.
- Download through short-lived signed URLs after a permission check; never public links.
- Optional: virus scan on upload (ClamAV or the cloud provider's scanner).

## Excel / CSV import pipeline

1. Upload file → store original in object storage, compute SHA-256; reject if `import_batches(kind, sha256)` already exists.
2. Parse (CSV and .xlsx) against the template in `import-templates/`.
3. Validate each row: required fields, date format, merchant exists and is active, amounts > 0, duplicates against the database and within the file, locked month.
4. Show preview with counts (total, success, failed, duplicate, invalid) and the reason per failed row; offer the error report as a downloadable CSV.
5. On confirm, insert valid rows in one transaction with `source = 'csv_import'` and `import_batch_id`; write the audit entry.

## Integration readiness (post-MVP)

| Future API | Writes to | Replaces |
|---|---|---|
| ZUS POS API | `sales` (`source='api'`, `external_id`) | Sales CSV upload |
| ZUS Order API | `restock_orders`, `restock_order_items` | Manual order entry / CSV |
| Bank API | `bank_transactions`, then auto-match to `settlements` by reference/amount/date | Bank statement upload and manual matching |

Each integration runs as a worker job, records `integration_runs`, upserts on `external_id`, and goes through the same validation as an import. The UI does not change: rows simply arrive with a different `source`.

## Security checklist

- TLS everywhere; HSTS on `ops.<domain>`
- SSO with MFA from the identity provider; allow-listed users only
- Server-side permission check on every action; Ops scoping by PIC/task
- Database user for the app has no `TRUNCATE`/`DROP`; `audit_logs` protected by triggers (consider also revoking UPDATE/DELETE on it from the app role)
- Bank account numbers masked in UI; full numbers visible to Full Access and Finance only
- Rate-limit login and import endpoints
- Security headers (CSP, frame-ancestors none)
- Dependency scanning in CI
- Personal data (IC numbers, phone, address) is subject to PDPA 2010: restrict exports to roles that need them and log every export

## Backup & recovery

- Managed PostgreSQL with automated daily backups and point-in-time recovery (minimum 14 days)
- Weekly logical dump (`pg_dump`) copied to a separate account/region, kept 90 days
- Object storage versioning on; lifecycle to cold storage after 1 year
- Quarterly restore test into a staging environment

## Environments

`dev` (seeded with fake data) → `staging` (copy of production structure, masked data) → `production`. Migrations run automatically on deploy; `schema.sql` is the baseline migration.
