Files
tehran_tala/DELIVERY.md
T
s.esmaeiliandClaude Opus 5 3f549f1749 parity(apps+api): report + report/detail on the internal ledger [ref: henza_pwa views/report]
Ports "گزارش" end to end (D24), the last missing app page.

In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.

API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
  after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
  Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
  balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
  جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
  the row rather than rendering a fabricated value (REP-01)

SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.

Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
  modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
  the reference's hasMCode() gate has no target equivalent

Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:49:49 +03:30

12 KiB
Raw Blame History

DELIVERY — gold-banking-monorepo

Branch: release/delivery · Prepared 2026-09-23

Contains everything from parity/security-hotfix and parity/phase-1-admin. Both merged with no conflicts (phase-1-admin already contained security-hotfix, so the merge was a fast-forward). main was not touched.


⚠ Before the real release — do these three things

  1. Set JWT_CUSTOMER_SECRET to its own random value. This one is a real security boundary, not a default. If it is unset, the API signs customer and sales-expert app tokens with JWT_SECRET — the same key admin tokens use — so the two token realms stop being cryptographically separate. The only thing still keeping an app token off the admin routes is the typ check in jwt.strategy.ts, and a sales expert's app token carries their AdminUser id in sub. Two independent defences become one. The API prints a loud banner at boot while it is missing. Tracked as REL-03 (Critical).
  2. Remove OTP_MASTER_CODE from the environment. While it is set, any active customer can sign in with that one fixed code. Unset or empty disables it completely, and the API prints a warning banner at boot for as long as it is armed. Tracked as REL-01 (Critical) in docs/parity/ISSUES.md.
  3. Configure the SMS credentials (SMS_TREZ_*). Until then no customer can receive a real login code — messages are written to the log instead of sent. Tracked as REL-02.

1. Environment variables

Names only — no values here, and none are committed. The full annotated list is in .env.example; docker-compose.yml passes each one through to the API container.

Required

Variable Purpose
DATABASE_URL PostgreSQL connection string. Inside Docker the host is db.
POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB Credentials for the bundled db service.
JWT_SECRET Signs admin tokens. Without it the API falls back to a well-known development string — set it.
JWT_CUSTOMER_SECRET Signs customer and sales-expert app tokens. Must be its own value — see the warning at the top of this file. Unset, it falls back to JWT_SECRET and the two token realms merge.

Optional

Variable Default Purpose
PORT / API_PORT 3000 API listen port.
JWT_EXPIRES_IN 12h Admin token lifetime.
JWT_CUSTOMER_EXPIRES_IN 30d Customer token lifetime.
UPLOAD_DIR <api>/storage/uploads Where uploaded files are written.
ADMIN_PANEL_PORT, USER_APP_PORT, USER_APP_V2_PORT 5180–5182 Dev servers only.
RUN_MIGRATIONS true Set false to skip the boot-time migration run.
RUN_SEED true Set false to skip seeding.
PROFILE_ADMIN_MOBILES (blank) Comma-separated admin mobiles told when a customer completes their profile. The reference hardcoded four numbers; blank means nobody is notified and the upload still succeeds.

SMS — all optional; blank means "log, do not send"

SMS_PROVIDER (trez default, or none), SMS_TREZ_URL, SMS_TREZ_USERNAME, SMS_TREZ_PASSWORD, SMS_TREZ_SENDER_LINE.

With these unset the server still starts normally and logs a warning; a failed or skipped SMS never blocks an operation, including customer creation.

⚠ OTP_MASTER_CODE — delivery only

A fixed OTP accepted for any active customer, alongside the real code.

  • Read only from the environment; never hardcoded.
  • Unset or empty → completely disabled.
  • Must be exactly 4 digits. Both apps' login box is four single-character inputs and only submits at four, so a longer code works over the API but cannot be typed into either app.
  • Every login through it is logged with the customer's mobile.
  • The real OTP keeps working unchanged while it is armed.

2. Running it

Development

pnpm install
pnpm dev                 # all apps via turbo

Ports come from the repo-root .env (single source of truth): API 3000, admin-panel 5180, user-app 5181, user-app-v2 5182.

Build

pnpm build               # all workspaces
pnpm build:release       # the production/Docker build

Deploy (Docker)

docker compose up -d --build

Services: db (Postgres 15), api (port 3000), admin-panel, user-app, user-app-v2 (each nginx on port 80). The apps expose no host ports — Dokploy/Traefik routes to them by domain. Uploads persist in the api_uploads volume; without it every previously uploaded image 404s after a redeploy.


3. How migrations run

Automatically, inside the API container, at boot. No manual step.

src/prisma/bootstrap.ts calls runMigrations() before Nest starts. That runner (src/prisma/migrate.ts) applies the .sql files from prisma/migrations and records each one in its own tracking table, _goldbank_migrations, so every file runs exactly once.

It exists because the deploy image is copy-only and ships no Prisma CLI, so prisma migrate deploy is not available there. This is why the database has two migration ledgers — _prisma_migrations (written by the Prisma CLI in development) and _goldbank_migrations (written by the runtime runner). Both are intentional. _goldbank_migrations is declared in schema.prisma with @@ignore so drift checks do not propose dropping it.

Never run prisma migrate reset, and never edit either table by hand. In development, migrations are created with prisma migrate dev; in production the container runner applies them.

Seeding runs once after migrations, gated on a marker row, and is skipped when no seed script is present.


4. What was verified on this branch

All green on release/delivery:

Suite Result
pnpm build (5 workspaces) ✅ all pass
jest (api unit) 45/45
smoke-admin.cjs — admin flows 43/43
smoke-apps.cjs — customer flows, both apps 43/43
scan-admin-b2b.cjs — every B2B read endpoint 34 ok, 0 broken
smoke-admin-b2b-writes.cjs — order + document writes 25/25
auth-smoke.sh — permissions/401/403 32/32
test-sales-expert-roundtrip.cjs 33/33
test-sales-expert-app-login.cjs — expert app login (D21) 36/36
test-complete-profile.cjs — «تکمیل پروفایل» 38/38
test-report.cjs — «گزارش» 40/40
test-name-trigger.cjs 9/9
typecheck (api, admin-panel, user-app, user-app-v2) ✅ clean

Flows walked end to end: admin login, customer creation, sales-expert create/edit, product and variant create/edit, order list/detail/status change, wholesale order placement from the admin panel, cash document creation + confirm + un-confirm; and in both customer apps: OTP login with the master code, product browsing, cart, order placement, order history.

Money paths were checked against the reference's own formulas, not just for a 200: making charge rash × (1 + (wage − discount)/100) reproduced exactly, and the cash-document conversion price / (price_per_gram / 4.331802) moved the customer's gold debt by the expected amount and restored it exactly on un-confirm.

Every test script deletes only the rows it created; the write test also asserts the customer's balances end exactly as they started.


5. Known issues — not done tonight

Nothing here blocks the delivery. Full detail in docs/parity/ISSUES.md.

Missing app pages

None. Every page the reference PWA has now exists in both apps.

Delivered tonight

Feature State
Sales-expert app login (D21) ✅ Done. An expert signs into both apps with their AdminUser account and the ordinary OTP flow; the master code works for them too. They get the whole app — browsing, cart, ordering, order history — plus their own two screens.
«سفارش مشتریان» (ChildOrder) ✅ Done in both apps. Their referred customers' orders at «تحویل مسئول فروش», searchable, infinite-scrolled.
«مشتریان من» (customerChoice) ✅ Done in both apps. Their referred customers, searchable, with a read-only detail sheet.
APP-02 — admin home slider in v2 ✅ Fixed. v2 now prefers admin-configured slides and keeps its category-derived hero as the fallback.
«تکمیل پروفایل» (complete_profile) ✅ Done in both apps. Three document uploads (جواز کسب، سردر مغازه، کارت ملی), then the profile is marked verified. Reached from the profile screen, and only while the documents are missing — exactly the reference's condition.
«گزارش» (report + report/detail) ✅ Done in both apps. Two tabs («کلیات حساب» / «جزئیات حساب»), a running gold and rial balance per document, and a per-document detail sheet. Built on the internal ledger (owner's option الف) — no connection to the external accounting system the reference proxied. See D24.

An expert's own order lands on their own trading account, with the making charge from AdminUser.wagePercent and the credit limit from SalesExpertProfile.gramLevel — the same two numbers the reference read off the agent's own customer row. See D21.

Two reference behaviours were deliberately not ported, both money/session risks and both recorded in D21:

  1. myChild minted an api_token per listed customer so the agent could act as them — a session-hijack primitive.
  2. The child-order edit drawer (PUT /order/child/{id}) recalculated an order's weights and prices from client-sent values. The card opens a read-only summary instead.

Behavioural gaps

ID What
APP-02 user-app-v2 has no getHomeSlider, so slides configured in the admin panel appear in user-app only; v2 always derives its hero from categories. Not fixed because v2's home is its own design and rewiring it during a delivery risks the page being demonstrated.
PERS-02 The reference's cities are a province → city tree (provinces not selectable); the target flattened this to a province string and a flat select on all three Persons forms. The city itself is still captured correctly.
PERS-03 The reference collects a Jalali birth date on the customer forms; the target has no column for it.
PERS-01 The reference makes a customer's mobile immutable on edit; the target allows editing it. Owner ruled this NOT ported — the target keeps its current behaviour.
PERF-01 listReagents() runs one aggregate per row (N+1) for the per-agent sales total. Harmless at the current agent count.

Parity work not reached

Phase 1 was not completed: the field-by-field audit stopped after the Persons section, and products/categories/variants, financial documents, users and permissions, SMS templates, promotions, cities, branches, workshops and reports have had only the breakage scan above, not a full parity audit. The Phase 1 gate was lifted for tonight only (D19) and still stands as the next milestone.

PERM-01 (the database holds 178 distinct permission ids against the UI's 174) is still awaiting a decision, because resolving it means deleting rows.


6. Decisions that shaped this build

Recorded in docs/parity/DECISIONS.md:

  • D13 — the retail storefront (single*, wallet, comments, customer.php, site.php) is preserved but out of scope. Nothing there was changed.
  • D19 — delivery mode: implement the main gaps, do not stop to ask, choose the lowest-risk option, change nothing protected.
  • D20 — SMS can never block a boot or an operation; OTP_MASTER_CODE for the delivery.
  • D11 is cancelled — superseded by D13.