feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -1361,3 +1361,60 @@ against the real `jsonwebtoken` and asserts the reader can no longer produce it.
|
||||
**Standing rule from here on:** read an environment variable with `envOr` /
|
||||
`envOrNull`, never with `??`, unless an empty string is a meaningful value for
|
||||
that specific variable.
|
||||
|
||||
## D26 — SMS through the owner's relay gateway, drivers per provider, 2026-10-05
|
||||
|
||||
The owner supplied the PHP client of their own SMS gateway — a relay at
|
||||
`http://185.231.112.101/api/createsms` that handles SMS for several panels and
|
||||
apps. Owner rulings:
|
||||
|
||||
| Topic | Ruling |
|
||||
|---|---|
|
||||
| Default | The relay **replaces Trez as the default**. Trez is **kept**, selectable with `SMS_PROVIDER=trez`; more providers may follow. |
|
||||
| Secret | The `secret_key` **lives in source** as the default. The gateway has a second verification factor held elsewhere, so the owner does not treat it as sensitive. `SMS_RELAY_SECRET` overrides it. (Contrast SEC-12, where the Trez credentials stay env-only.) |
|
||||
| Platform | **Every** message goes through Kavenegar (`platform: "kave"`). |
|
||||
| Host | The server's own IP is correct, as in the PHP client; `SMS_RELAY_HOST` overrides it. |
|
||||
| Address | The IP over HTTP is correct. HTTPS on that IP presents an expired `*.montpar.ir` certificate (checked 2026-10-05), so HTTPS is not usable on the IP; `SMS_RELAY_URL` can switch later. |
|
||||
| Replies | Undocumented; left to the implementation. A 2xx counts as sent unless the JSON body says `status: false`, `success: false`, `ok: false`, `status: "error"`/`"failed"` or carries `error`. |
|
||||
|
||||
Implementation: `apps/api/src/modules/sms/drivers/` — one `SmsDriver` per
|
||||
provider (`relay.driver.ts`, `trez.driver.ts`) registered in `drivers/index.ts`.
|
||||
A new provider is one file plus one registry line. `SmsService` keeps the
|
||||
cross-provider rules: trim, persist every message (SEC-11 for OTP unchanged),
|
||||
log failures and never throw (D20). Plan: `docs/SMS-RELAY-PLAN.md`.
|
||||
|
||||
Consequence: with no SMS variables set, **every environment now sends real
|
||||
SMS**, local development included. Set `SMS_PROVIDER=none` on a machine that
|
||||
must not send.
|
||||
|
||||
## D27 — accounting integration (Tahesab / Zarrin), switchable from the admin panel, 2026-10-05
|
||||
|
||||
The owner wants the app connected to the shop's accounting software, and the
|
||||
software differs per installation: **Tahesab** or **Zarrin**, picked with one
|
||||
switch in the admin panel (`none | tahesab | zarrin`). Plan and the full API
|
||||
survey: `docs/ACCOUNTING-INTEGRATION-PLAN.md`. Owner rulings:
|
||||
|
||||
| Topic | Ruling |
|
||||
|---|---|
|
||||
| Scope | Customer sync, balance sync, order documents, and ready-made items (کارساخته) ↔ products. **Everything else stays in the app** — repayment documents (melted gold, cash, cheque) are not sent. |
|
||||
| Balance | The accounting balance is **shown to the customer**; the **credit-limit check stays on the internal ledger**. |
|
||||
| Report | «گزارش» reads from the accounting software when the integration is on, so it agrees with the balance shown. Supersedes D24 for that case only; D24 remains the fallback. |
|
||||
| Customers | Created in the accounting software when an admin marks them **VERIFIED**; existing ones are linked by **mobile**. Accounts that exist only in the accounting software are **created in the app**, keyed by mobile (national code falls back to the mobile, as elsewhere). |
|
||||
| Documents | Sent at the debt-recognition point (`DELIVERED_TO_AGENT`) as **temporary** (موقت) documents for the accountant to finalise. Ready-made items only for now; gold/coin trading later. |
|
||||
| Products | One accounting کارساخته = one app **Product**. Title, code and stock belong to the accounting software; images, description, variants, category and **making charge belong to the app**. |
|
||||
| Visibility | **A product with no image is never shown in the app** — all products, automatically. |
|
||||
| Stock | Accounting stock is shown in the admin panel; at zero the product is unavailable in the app (pre-order variants excepted). |
|
||||
|
||||
Security carried over from the reference's mistakes (SEC-06/16/17): TLS
|
||||
verification is never disabled (a custom CA can be supplied instead), the
|
||||
token is write-only and encrypted at rest with `ACCOUNTING_SECRET_KEY`, the
|
||||
generic `const` settings routes refuse `accounting.*` keys, and statement
|
||||
details are scoped to the token's own customer.
|
||||
|
||||
Later rulings, same day (plan §11–§12, ACC-08/09): sync is real-time (change
|
||||
feed polled every 5 s, sends right after commit, order-time live stock that
|
||||
falls back to the last known figure); sync buttons on the wholesale-customer
|
||||
and product lists and a live balance on the app profile, all working whenever
|
||||
a provider is connected regardless of the feature switches; and the order
|
||||
status that posts the temporary document (پیشفاکتور) is an admin-chosen rule
|
||||
(`documents.triggerStatus`, default «تحویل مسئول فروش»).
|
||||
|
||||
+17
-3
@@ -132,7 +132,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
||||
| ID | Severity | Where | Description | Evidence | Suggested fix |
|
||||
|---|---|---|---|---|---|
|
||||
| **REL-01** | **Critical — before the real release** | TGT deployment env | **`OTP_MASTER_CODE` must be removed from the environment, and the SMS credentials configured.** While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. | `customer-auth.service.ts` `masterOtpCode()`; `main.ts` boot banner; D20 | Unset `OTP_MASTER_CODE`; set `SMS_TREZ_USERNAME` / `SMS_TREZ_PASSWORD` / `SMS_TREZ_SENDER_LINE`; restart and confirm the banner is gone. |
|
||||
| **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. |
|
||||
| **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. **Eased by D26 (2026-10-05):** the default `relay` driver needs no credentials, so SMS works on a fresh deployment; a gateway refusal is still only a log line. |
|
||||
| **PERS-01** | Medium | REF `AdminController.php:348` vs TGT `admin-customers.service.ts` `update()` | The reference **discards the submitted mobile on edit** (`$input['mobile'] = $customer->mobile;`), making it immutable once set; the target lets an operator change it. D19 ruled the lock NOT ported, so the target keeps the editable behaviour. Changing a mobile changes the customer's login identity. | `AdminController.php:348`; D19 answer B | If the lock is wanted later, apply it in `update()` and hide/disable the field on the edit form. |
|
||||
| **PERS-02** | Medium | REF `cities` table vs TGT `City` model | The reference's cities are a **two-level hierarchy** (`parent` + `level`, provinces at level 0) rendered as an `a-tree-select` with provinces **not selectable** (`citiesModel.js:38`). The target flattened this to a nullable `province` **string** and a flat `a-select` on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. | `2021_09_18_171700_city_table.php:18-19`; `henza_admin/src/mixins/citiesModel.js:20-48`; `schema.prisma` `model City` | Structural change; decide with the owner during the cities screen (Phase 1c). |
|
||||
| **PERS-03** | Low | REF `bulkCustomer.vue:111`, `singleCustomer.vue:29` vs TGT | The reference collects a **Jalali birth date** on both the wholesale (`birth_date`) and retail (`birth_day`) customer forms via `DateInput`. The target's `Customer` model has no column for it and both forms omit the field. | `henza_admin/.../bulkCustomer.vue:111`; `schema.prisma` `model Customer` (no birth field) | Additive: add a nullable `birthDate` column and the picker, if the owner wants it. |
|
||||
@@ -162,9 +162,9 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
||||
| ID | Severity | Where | Description | Evidence | Suggested fix |
|
||||
|---|---|---|---|---|---|
|
||||
| **SEC-16** | High (reference bug, NOT ported) | REF `Customer/TahesabController.php::detail()` | The document-detail endpoint took the factor code straight from the URL and performed **no ownership check at all** — the method never consults `auth()`. Any signed-in customer could read any other customer's accounting document by guessing or enumerating a code. | `public function detail(Request $request, $id)` → proxies `GetHesabDetailByFactorCode?Factor_Code=$id` with no customer scope | **Not ported.** In the target the id is only a filter on top of a `customerId` scope taken from the token, so another customer's id returns nothing. Asserted in both directions by `test-report.cjs` §6. |
|
||||
| **REP-01** | Medium (documented gap) | REF external Tahesab system vs TGT `LedgerEntry` | The reference's statement carried columns the target has no source for: foreign currency (`rial_V2`/`rial_Kh2`/`tahesabP2`), `vaznPlastik`, `nO_kar` (نام کارساخته), `jens_felez` (جنس فلز) and the external system's internal ids (`gid`/`rec`/`reg`). | `henza_pwa/src/type/accounting.ts`; `AccountingDetail.vue:34-46` | The API returns `null`/zero for these and the UI **omits** the row rather than showing a fabricated value. If the business needs them, they need columns on `LedgerEntry` first. |
|
||||
| **REP-01** | Medium (documented gap) | REF external Tahesab system vs TGT `LedgerEntry` | The reference's statement carried columns the target has no source for: foreign currency (`rial_V2`/`rial_Kh2`/`tahesabP2`), `vaznPlastik`, `nO_kar` (نام کارساخته), `jens_felez` (جنس فلز) and the external system's internal ids (`gid`/`rec`/`reg`). | `henza_pwa/src/type/accounting.ts`; `AccountingDetail.vue:34-46` | The API returns `null`/zero for these and the UI **omits** the row rather than showing a fabricated value. If the business needs them, they need columns on `LedgerEntry` first. **Update 2026-10-05 (D27):** with the accounting integration on, «گزارش» reads the accounting statement and `nO_kar` / `jens_felez` are filled from it. |
|
||||
| **REP-02** | Low (performance) | TGT `customer-report.service.ts` `summary()` | The running balance makes a row depend on every entry before it, so the whole confirmed ledger for the customer is folded in memory and the page sliced afterwards. Cheap at one customer's own document counts; linear in their history. | `summary()` `findMany` without `take` | If a customer ever accumulates enough entries for it to matter, move the fold to a stored running total written at entry time. |
|
||||
| **SEC-17** | Medium (reference, not applicable to target) | REF `Customer/TahesabController.php` | The reference calls the external accounting system with `Http::withoutVerifying()` (TLS certificate verification **disabled**) against a bare IP, with a bearer token and an `sfx` secret hardcoded in the source and embedded in the URL. | `->withToken("S3F1P8…")`, `sfx=wcG97tkF…` in four URLs | Not applicable — the target makes no external call. Listed so it is not reintroduced if a Tahesab integration is ever added; the credentials in that reference file should be rotated regardless. |
|
||||
| **SEC-17** | Medium (reference, not applicable to target) | REF `Customer/TahesabController.php` | The reference calls the external accounting system with `Http::withoutVerifying()` (TLS certificate verification **disabled**) against a bare IP, with a bearer token and an `sfx` secret hardcoded in the source and embedded in the URL. | `->withToken("S3F1P8…")`, `sfx=wcG97tkF…` in four URLs | Not applicable — the target makes no external call. Listed so it is not reintroduced if a Tahesab integration is ever added; the credentials in that reference file should be rotated regardless. **Update 2026-10-05 (D27):** a Tahesab integration now exists; it keeps TLS verification on (a custom CA can be supplied), stores the token encrypted and write-only, and never logs it. |
|
||||
|
||||
## Found by the pre-delivery Docker Compose run (2026-09-23)
|
||||
|
||||
@@ -172,3 +172,17 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
||||
|---|---|---|---|---|---|
|
||||
| **DEPLOY-02** | ~~**Critical**~~ **FIXED** | TGT `auth.module.ts`, `customer-auth.service.ts`, `jwt.strategy.ts`, `main.ts`, `upload-path.ts`, `sms.service.ts` | **Admin login returned 500 for every user in Docker.** `docker-compose.yml` passes each optional variable as `${X:-}`, so an unconfigured one arrives as `""`, not `undefined`. The code read them with `process.env.X ?? 'default'`, which only falls back on `undefined` — so the empty string won and `jsonwebtoken` threw `"expiresIn" should be a number of seconds or string representing a timespan` on **every** sign. Invisible to every suite that ran against a local dev process, where the variable is genuinely absent. The same pattern also meant a blank `UPLOAD_DIR` would have served static files from the **filesystem root**, and a blank `PORT` would have bound a random ephemeral port (`Number('') === 0`), making the container unreachable. | Container log: `ERROR [ExceptionsHandler] "expiresIn" should be a number of seconds or string representing a timespan` | ✅ Added `src/common/env.ts` (`envOr` / `envOrNull`) which treats blank as unset, and applied it to every auth, port, upload and SMS read. Pinned by `src/common/env.spec.ts` (9 tests), which reproduces the original throw against the real `jsonwebtoken` and asserts the reader can no longer produce it. Re-verified end to end in compose: **32/32**. |
|
||||
| **DEPLOY-03** | Medium (process) | TGT `apps/api/Dockerfile` | The image is `COPY release/ ./` — it ships a **pre-built bundle** produced on the developer machine by `pnpm build:release`, not a build performed inside the image. So `docker compose build` happily produces an image from a **stale** `release/app.js` and reports success. At the start of this run that bundle was four days old, and the rebuilt container was missing every route added since. | `apps/api/release/app.js` dated Sep 19 while the source was Sep 23; the container mapped none of the new routes | **`pnpm build:release` MUST be run before `docker compose build`.** Noted in DELIVERY.md's deploy section. A longer-term fix is to build inside the Dockerfile, or to have the build fail when `release/` is older than `src/`. |
|
||||
|
||||
## Accounting integration — found while building D27 (2026-10-05)
|
||||
|
||||
| ID | Severity | Where | Description | Evidence | Suggested fix |
|
||||
|---|---|---|---|---|---|
|
||||
| **ACC-01** | Medium (unverified) | TGT `accounting-sync/providers/zarrin.provider.ts` | The Zarrin driver follows the published docs only — there is no Zarrin installation to test on. Open points: which sign of `listMandeHesabha` means "owes" (a setting, default negative), whether stones belong in `mablagh` or `mablaghSang` on `foroushSakhte` (sent in `mablagh`), and the exact response envelopes. | Owner: «نه، فعلاً نداریم» (no test install). Unit tests use the docs' sample outputs. | Before real use: a trial on temporary documents on a real install (owner's decision 11), check one balance and one document against the Zarrin UI. |
|
||||
| **ACC-02** | Low (by design) | TGT D27 | Order documents are sent **temporary** (owner ruling), and a temporary Tahesab document does **not** move the balance until the accountant finalises it. Until then the balance the customer sees from the accounting software does not include those orders. | Measured on the Tahesab test server: balance delta 0 for a temporary `VKHKar`, −Vazn×Ayar/750 once final. | Accountant finalises documents promptly; the report marks temporary rows «موقت — هنوز در مانده اثر ندارد». |
|
||||
| **ACC-03** | **High** (pre-existing, NOT fixed — ledger is protected) | TGT `orders/orders.service.ts` `updateStatus()` | The debt guard is `DELIVERED_TO_AGENT && !order.deliveredAt`, but `deliveredAt` is only set at `DELIVERED_TO_CUSTOMER`. An order moved into DELIVERED_TO_AGENT a second time (e.g. PREPARING → DELIVERED_TO_AGENT again via «تغییر وضعیت») posts the gold and rial debt **twice**. | Read at `orders.service.ts:135-160`. The new accounting hook is not affected (it dedupes on the existing document row). | Guard on an existing `ORDER_STOCK` ledger entry for the order instead of `deliveredAt`; needs owner approval (PROTECTED area). |
|
||||
| **ACC-04** | Low (pre-existing) | TGT `src/prisma/migrate.ts` | On a completely empty database `runMigrations` fails: `0002_admin_schema.sql` sorts before `20260910113720_first` and alters tables that do not exist yet. Existing databases are unaffected. | Reproduced while preparing the D27 throwaway test database (`relation "City" does not exist`). | Rename to sort after `first`, with a `LEGACY_NAME_ALIASES` entry so applied databases do not re-run it. |
|
||||
| **ACC-05** | Low (behaviour change) | TGT `catalog.service.ts` | D27 rule: a product with **no image** is no longer listed in the app nor orderable — for every product, not only those from the accounting software. | Dev DB on 2026-10-05: 0 of 5 active products affected. | Before release run `select count(*) from "Product" p where p."isActive" and not exists (select 1 from "ProductImage" i where i."productId" = p.id);` on production and photograph those first. |
|
||||
| **ACC-06** | Medium (fixed 2026-10-05) | TGT `accounting-sync/accounting-http.ts` | A real Tahesab install reached by IP could not connect. Tahesab serves its own self-signed certificate issued for `localhost` / `127.0.0.1`, so verification failed (`DEPTH_ZERO_SELF_SIGNED_CERT`), and pasting it as a CA still failed the name check (`ERR_TLS_CERT_ALTNAME_INVALID`). «تست اتصال» also tested only *saved* settings. | Owner's server (`https://<ip>:4040`, DBname `tahesabDB5`): token and DB were fine (raw `CheckHealth` = OK). The same server also answers to the default `TahesabDB` — a different database (40 customer groups instead of 19). | Fixed: a pasted certificate is now also a **pin** — exactly that certificate is accepted under any name; the chain check stays on, any other certificate is still name-checked, and verification is never switched off. New «دریافت گواهی از سرور» button shows the SHA-256 fingerprint to compare before pinning; «تست اتصال» uses the form as typed. Verified against the owner's server; `accounting-http.spec.ts`. Admins must check the DB name in the test result. |
|
||||
| **ACC-07** | Medium (fixed 2026-10-05) | TGT `accounting-sync` (config, worker, admin) | "Connected but nothing syncs": the switch was saved on «زرین» while only Tahesab had an address/token. The worker found no usable provider and did nothing, with no message; «سینک الان» answered "started". Separately, an empty «گروههای مشتری برای ورود» meant *all groups* — on the owner's server that includes صندوق, کارمندان, سرمایهگذار, آبشده, کلاهبرداران… which would have become app customers. | Owner's Tahesab: 228 accounts in 15 groups, 23 کارساخته; read-only provider calls (groups, customers, balances, items, stock) all fine. | Fixed: saving a provider without address/token is refused; the screen shows why nothing syncs (`inactiveReason`); «سینک الان» refuses with the reason (or "feature off"). Customer pull now refuses to run with no groups selected; creating a customer with no «گروه مشتری جدید» goes to NEEDS_REVIEW instead of an ungrouped account. |
|
||||
| **ACC-08** | Medium (done 2026-10-05) | TGT `accounting-sync` (worker, providers, sync, catalog) | Owner: sync must be real-time, not delayed or manual. It was: outbox tick 30 s, balances every 10 min, products every 30 min, stock read from the stored copy at order time. | Neither Tahesab nor Zarrin has webhooks. Tahesab `DoListUpdatedAsnad` lists new/edited documents (≈400 ms); proven on the public test server (document posted → account+item seen in the next poll, 0.7 s). Zarrin `parLastTenRadif` is unverified (ACC-01). | Done: fast loop (2 s) for outbox + change poll every 5 s (fixed; the admin setting was removed at the owner's request); `kickSoon()` right after a hook enqueues; targeted balance/stock refresh; order-time live stock with 2.5 s timeout, falling back to the last known stock (owner's ruling); balance freshness 5 s. Interval jobs stay as the safety net (deletions, edits, new customers). `getChanges` tests added (55 accounting tests). See plan §11. |
|
||||
| **ACC-09** | Medium (done 2026-10-05) | TGT `accounting-sync`, `orders`, `admin-orders`, admin lists, both apps' profile | Owner: sync also on demand — a button on the wholesale-customer list and the product list; the app profile reads its own balance live on every visit; an optional rule for the order status that posts the temporary document (پیشفاکتور). | Owner: buttons/profile work whenever connected, regardless of the feature switches; the rule lives on «اتصال حسابداری». Admin order delete and status rollback could leave a temporary document behind once it can be posted before the debt. | Done: `AccountingQuickController` (`admin/accounting-status`, `admin/customers-omde/accounting-sync`, `admin/products/accounting-sync`, `customer/me/accounting-balance`), `documents.triggerStatus` (default DELIVERED_TO_AGENT), `orderStatusReached` hook from createOrder/updateStatus/rollback, withdrawal on delete/rollback, delete job independent of the order row. 8 hook tests. Plan §12. |
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|---|---|---|
|
||||
| `docs/GO-LIVE-PLAN.md` | Stack upgrade, release isolation, data migration and cutover from the live henza system. **§0 explains how to continue on another machine.** | Owner go-ahead per step |
|
||||
| `docs/PWA-PORT-PLAN.md` | Third user app `apps/user-app-classic`: a pixel-exact port of `henza_pwa@b171fff` onto the new API. | Owner rulings on its tensions 1–5 and Q1–Q9 |
|
||||
| `docs/ACCOUNTING-INTEGRATION-PLAN.md` | Tahesab / Zarrin integration, switchable in the admin panel (D27). **Implemented 2026-10-05**, off until switched on; progress in its §10. | Zarrin trial on a real install (ACC-01); ACC-03 ruling |
|
||||
|
||||
The "Current state" table below is the parity project's and was last updated 2026-09-22.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user