parity(api,admin): split customer name, add making-charge offset, wire SMS
Applies the two approved additive migrations and fixes every consumer.
D0 — Customer.firstName / lastName
The reference has had two columns since 2020
(2020_10_01_120430_customers_table.php:18-19); the port collapsed them, so
the edit form showed the whole name in the "first name" box and saved an
empty last name. Adds both columns, backfills first-token/rest, and keeps
fullName in sync with a customer_sync_full_name trigger. Nothing dropped.
The trigger is SQL, so Prisma cannot see it: writing the parts rebuilds
fullName, writing fullName re-derives the parts, and writing both in one
statement lets THE PARTS WIN. Documented in the migration header, the
Customer model doc comment and DECISIONS.md §D14, because a write can now
store something other than what was passed.
D9 — Customer.makingChargeOffsetPercent
Legacy customers.percent, the per-customer making-charge offset that the
app adds to the variant's making charge (henza_pwa
Mixins/product/index.ts:69-75). toRow() was returning the referring
AGENT's commission rate instead (CUST-02). DB default 0 reproduces
today's behaviour exactly, so no existing customer's price moves; the
reference's application-layer default of 3 for NEW customers
(AdminController.php:338) lives in the create form, and the field is
required on create with the reference's own message (:354).
update() and updateCredit() both accepted percent and silently discarded
it; both now persist it. Sort/filter aliases fname/lname pointed at
fullName and now point at the real columns.
SMS — Trez provider (Sms.php:57-82)
New global SmsModule/SmsService: form-urlencoded POST, 20s timeout, body
trimmed, every message persisted. send() never throws, matching the
reference, which discarded Sms::Send's result so a gateway outage never
aborted the order or approval that triggered it. The OTP text is
byte-identical, including the Android SMS Retriever markers. Credentials
come from env only; production refuses to boot without them rather than
silently locking every customer out. .env.example carries names, no values.
Admin panel: the split(' ') fallback is removed from singleCustomer.vue,
and bulkCustomer.vue defaults percent to 3, requires it, coerces the
Decimal on load so diff-send works, and only re-sends `name` when a name
part changed. ReagentCustomer.vue is untouched — its «درصد کمیسیون» is
AdminUser.wagePercent, a different field.
Tests: 32 jest (incl. 14 new SMS config/template), 9 trigger assertions,
53 auth smoke, and the full D12 round-trip on the customer entity —
«محمد علی» + «احمدی» survives create, edit, a byte-identical no-op save,
and single-field edits. Typecheck clean on api and admin-panel.
Note: migrate dev is interactive-only and could not run here, so the
migrations were applied with migrate deploy after a read-only drift check.
The only drift is the pre-existing _goldbank_migrations table, which
Prisma would DROP; it was NOT dropped (DEAD-07).
[ref: henza_server/app/Models/Sms.php, app/Http/Controllers/Adm/AdminController.php]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7b598e9e8e
commit
8133a2bdf4
@@ -306,3 +306,95 @@ this wrote the missing `_prisma_migrations` row **without running any SQL**, so
|
||||
| `src/modules/pricing/variant-price.spec.ts` | 11 tests against **hand calculations** derived from the PHP source, covering with/without the offset, the VAT-on-non-metal-portion-only rule, floor-to-1000, and the equivalence between the app folding the offset into `percent_pay` and the server deriving it. |
|
||||
|
||||
`npx jest` → **18 passed** (7 existing + 11 new), 2 suites.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-22 (later) — D0 + percent applied, SMS provider implemented
|
||||
|
||||
### Migrations applied
|
||||
|
||||
`migrate dev` could not run (interactive-only, and this shell is not a TTY).
|
||||
Drift was checked read-only first: the only difference is the pre-existing
|
||||
`_goldbank_migrations` table, which Prisma would DROP — **it was not dropped**
|
||||
(DEAD-07). Applied with `migrate deploy`, which touches only the pending files:
|
||||
|
||||
```
|
||||
Applying migration `20260922120000_customer_name_parts`
|
||||
Applying migration `20260922120100_customer_making_charge_offset`
|
||||
All migrations have been successfully applied.
|
||||
→ 11 migrations found. Database schema is up to date!
|
||||
```
|
||||
|
||||
Backfill result (`node scripts/report-name-split.cjs`): 8 customers, 7 split
|
||||
reliably, 0 single-token, 0 empty, **1 needs review** — `customer-dev-test`
|
||||
(«کاربر تست توسعه»), a row this session created itself.
|
||||
|
||||
### Data deletions (standing rule 2)
|
||||
|
||||
| When | What | How many | Why |
|
||||
|---|---|---|---|
|
||||
| Round-trip test | `Customer` rows created by the test: 2 successful + 1 whose Persian text the Windows shell had mangled into `?` | 3 | Test fixtures. Customer count back to its original 8. |
|
||||
| Trigger test | 1 temporary `Customer` row per run, deleted by the script itself and reported on stdout | 1/run | Test fixture. |
|
||||
|
||||
### API
|
||||
|
||||
- `toRow()` returns the **real** `fname`/`lname` (D0) and `percent` now reads
|
||||
`makingChargeOffsetPercent` **from the customer** instead of the referring
|
||||
agent's commission rate (D9 / CUST-02).
|
||||
- `create()` stores the name parts, and **requires `percent`** with the
|
||||
reference's own message, `وارد کردن percent الزامی است` — Laravel's stock
|
||||
`required` text (`resources/lang/fa/validation.php:99`) with no attribute
|
||||
translation for `percent`, so it is verbatim.
|
||||
- `update()` writes the parts (letting the trigger rebuild `fullName`) and
|
||||
**persists `percent`**, which it previously ignored entirely.
|
||||
- `updateCredit()` accepted `percent` in its signature and never wrote it —
|
||||
fixed.
|
||||
- Sort/filter aliases `fname`/`lname` pointed at `fullName`; they now point at
|
||||
the real columns, and `percent` is aliased too. Grid search covers both parts.
|
||||
- `createSingle()` (retail form, D13) stores the parts too. No storefront
|
||||
behaviour changed — only the shared columns are kept consistent.
|
||||
|
||||
### SMS — implemented (owner-approved)
|
||||
|
||||
- New `SmsModule` / `SmsService` (`@Global`), a port of `Sms.php:57-82`: POST,
|
||||
form-urlencoded, 20s timeout, `PhoneNumber`/`Username`/`Password`/`Mobile`/
|
||||
`Message`, body trimmed, every message persisted as `Sms::set()` did.
|
||||
- **Failure handling matches the reference**: `send()` never throws. The
|
||||
reference discarded `Sms::Send`'s result, so a gateway outage never aborted
|
||||
the order, the document approval or the customer creation that triggered it.
|
||||
Failures are logged and returned in `SmsSendResult`.
|
||||
- **OTP text is byte-identical** to `ApiMobileController.php:47`, including the
|
||||
`<#> code is …` first line and the trailing `aMo6iFyUQaY` — the Android SMS
|
||||
Retriever hash. A unit test asserts the exact string.
|
||||
- `assertSmsConfigured()` runs in `main.ts` **before the server listens**: in
|
||||
production a missing key throws, naming it; outside production it warns and
|
||||
codes are logged locally, so today's dev flow is unchanged.
|
||||
- Credentials come from the environment only. `.env.example` lists the six names
|
||||
with **empty values**; `.env` is gitignored (verified).
|
||||
- The 12 B2B message templates are implemented per flow in their own phase —
|
||||
admin in Phase 1, app in Phase 2 — not pre-emptively.
|
||||
|
||||
### Admin panel
|
||||
|
||||
- `singleCustomer.vue` — the `split(' ')` fallback is **removed** (D0 item 6).
|
||||
- `bulkCustomer.vue` — `percent` defaults to **3** on create and is required
|
||||
with the legacy message; the edit path coerces the Decimal string to a number
|
||||
so diff-send does not re-send an unchanged field; `name` is only sent when a
|
||||
name part actually changed, so the whole name is no longer re-submitted on
|
||||
every save.
|
||||
- `ReagentCustomer.vue` — **unchanged**. Its «درصد کمیسیون» is
|
||||
`AdminUser.wagePercent`, a genuinely different field from `customers.percent`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `npx jest` — **32 passed** (7 existing + 11 price formula + 14 SMS config/template).
|
||||
- `scripts/test-name-trigger.cjs` — **9 assertions**, all three sync directions.
|
||||
- `scripts/auth-smoke.sh` — **53 passed, 0 failed**.
|
||||
- **Round-trip test (D12 rule E0)** on the customer entity, live:
|
||||
create «محمد علی» + «احمدی» + percent 3 → edit form shows each field
|
||||
separately and correctly → no-op save leaves the row **byte-identical** →
|
||||
changing only `lname` changes `lastName` and `fullName` (trigger) and nothing
|
||||
else → changing only `percent` changes exactly that column. The two-token
|
||||
first name «محمد علی» survives intact, which is the defect the owner
|
||||
originally reported.
|
||||
- Typecheck: api ✅ · admin-panel ✅.
|
||||
|
||||
@@ -713,3 +713,67 @@ owner's ruling the target's behaviour stays.** Recorded here as a deliberate
|
||||
security deviation from the reference. The visible result is identical whenever
|
||||
the client sends the true variant weight, which the reference app always does
|
||||
(`cart.vue:178`); it differs only for a forged request, which is the point.
|
||||
|
||||
---
|
||||
|
||||
# D14 — Migration workflow, and the `customer_sync_full_name` trigger (2026-09-22)
|
||||
|
||||
## Workflow
|
||||
|
||||
| Environment | Command | Rule |
|
||||
|---|---|---|
|
||||
| Development | `npx prisma migrate dev` | the only way schema changes are applied |
|
||||
| Production | `npx prisma migrate deploy` | **only** this — never `migrate dev`, never `migrate reset` |
|
||||
| Anywhere | `prisma db execute` | **no longer used for schema changes** |
|
||||
|
||||
If `migrate dev` reports drift and offers a reset: **stop, do not accept, report
|
||||
it.** `_prisma_migrations` is never edited by hand.
|
||||
|
||||
### What actually happened on 2026-09-22
|
||||
|
||||
`migrate dev` **refused to run**: it is interactive by design and this shell is
|
||||
not a TTY. That is a safety property, not a problem — a reset prompt could never
|
||||
be auto-accepted here.
|
||||
|
||||
Before falling back, drift was checked read-only with `prisma migrate diff`. It
|
||||
reported one pre-existing difference that has nothing to do with this work:
|
||||
|
||||
```
|
||||
-- DropTable
|
||||
DROP TABLE "_goldbank_migrations";
|
||||
```
|
||||
|
||||
`_goldbank_migrations` is a **bespoke ledger from the earlier port**, holding 12
|
||||
historical rows (`0001_init.sql`, `seed:initial`, …). It is not in
|
||||
`schema.prisma`, so Prisma considers it foreign and would drop it. **It was not
|
||||
dropped.** Dropping it is a destructive operation, it is unapproved, and the
|
||||
rows are the only record of how the pre-Prisma port was applied. Logged as
|
||||
**DEAD-07**; leave it until the owner decides.
|
||||
|
||||
The two new migrations were therefore applied with **`migrate deploy`**, which
|
||||
applies exactly the pending files and touches nothing else — so the drift was
|
||||
neither "fixed" nor acted upon. Result: `11 migrations found. Database schema is
|
||||
up to date!`
|
||||
|
||||
## The `customer_sync_full_name` trigger
|
||||
|
||||
Created by `20260922120000_customer_name_parts`. **Prisma does not know it
|
||||
exists**, so a write through Prisma Client can produce a row different from what
|
||||
was written. Documented in three places so the next developer cannot miss it:
|
||||
the migration header, the `Customer` model doc comment in `schema.prisma`, and
|
||||
here.
|
||||
|
||||
| Write | Result |
|
||||
|---|---|
|
||||
| the parts only | `fullName` is rebuilt as `first + ' ' + last` |
|
||||
| `fullName` only | the parts are re-derived (first token / the rest) |
|
||||
| **both in one statement** | **THE PARTS WIN**; the submitted `fullName` is discarded |
|
||||
|
||||
**Why the parts win.** After D0 the parts are what the three forms edit, so they
|
||||
carry the operator's intent; `fullName` is the derived, legacy-facing value.
|
||||
The opposite precedence would silently undo an edit made in the two boxes the
|
||||
operator actually typed into.
|
||||
|
||||
Verified live by `apps/api/scripts/test-name-trigger.cjs` — 9 assertions across
|
||||
all three directions plus insert, a multi-token name, and a no-op update. It
|
||||
creates one temporary row and deletes it, reporting the deletion.
|
||||
|
||||
@@ -112,3 +112,9 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
||||
| SEC-12 | **Critical** | REF `henza_server/app/Models/Sms.php:73-77,101-105` | Live SMS-panel production credentials (username, plaintext password, sender line) are **committed to git** in the reference, duplicated across two methods. An older provider's credentials sit commented out beside them. Anyone with repo access can send SMS on the owner's account at their cost. | Read the file. | Not ported — the target will read them from env only. **The owner should rotate these credentials**; they must be assumed compromised. |
|
||||
| STUB-05 | Medium | REF `ApiMobileController.php:154-158,601-605`; `Adm/OrderController.php:54-55`; `Customer/OrderController.php:141-145` | Admin notification recipients are **hardcoded mobile numbers** scattered across controllers (six distinct numbers). Staff changes require a code deploy, and an ex-employee keeps receiving order data. | Read the files. | Port as `SMS_ADMIN_RECIPIENTS` (comma-separated, default empty) rather than copying the literals. Pending the SMS approval. |
|
||||
| DEAD-06 | Low | REF `henza_pwa/src/views/cart.vue:181-182` | The cart sends `wage_weight: 5.7856000000000005` and `weight: 6.303744000000001` as **literal constants**, each with a `// how to get?!` comment. The server ignores both: it recomputes weight from `rash_weight`, and the `wage_weight` use is commented out (`ApiMobileController.php:549-551`). | Read both files. | Dead payload fields — do not port. |
|
||||
|
||||
## Found while applying the D0 / percent migrations (2026-09-22)
|
||||
|
||||
| ID | Severity | Where | Description | Evidence | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| DEAD-07 | Medium | TGT DB table `_goldbank_migrations` | A bespoke migration ledger left by the earlier (pre-Prisma) port, holding 12 rows (`0001_init.sql`, `0002_admin_schema.sql`, `seed:initial`, …). It is not declared in `schema.prisma`, so `prisma migrate diff` reports it as drift and proposes `DROP TABLE`. **It was NOT dropped** — that is destructive, unapproved, and these rows are the only record of how the pre-Prisma port was applied. Because of it, `migrate dev` on a fresh checkout may offer a reset; the answer is always no. | `prisma migrate diff --from-schema-datasource --to-schema-datamodel` | Open — owner decides: keep and add to the schema as an ignored model, or archive the rows and drop. Until then use `migrate deploy`. |
|
||||
|
||||
+35
-11
@@ -10,8 +10,8 @@
|
||||
| **Phase** | Security hotfix — **stages 1 and 2 ✅ complete** |
|
||||
| **Branch** | `parity/security-hotfix` |
|
||||
| **Status** | Both stages done and verified live: **46/46** smoke assertions, **7/7** jest unit tests, typecheck + build clean on all three projects. |
|
||||
| **Next step** | **Migrations are written and shown — awaiting approval to run them** (`migrate dev`), then `01-ADMIN-MATRIX.md` field-by-field (D12) |
|
||||
| **Blocked on** | approval to run the two migrations; approval to implement the SMS provider |
|
||||
| **Next step** | **Start `01-ADMIN-MATRIX.md`** — field-by-field per D12, rid mapping matrix per D1 |
|
||||
| **Blocked on** | nothing |
|
||||
| **Last updated** | 2026-09-22 |
|
||||
|
||||
### Security hotfix — final result
|
||||
@@ -51,7 +51,7 @@ while the id and the id-addressed routes remain.**
|
||||
|
||||
## ⚠️ Read `DECISIONS.md` before doing anything
|
||||
|
||||
All user rulings live there (D0–D13). Highlights that change how work is done:
|
||||
All user rulings live there (D0–D14). Highlights that change how work is done:
|
||||
|
||||
- **D12 — the matrix is FIELD-BY-FIELD, not page-by-page**, and a 4-step
|
||||
round-trip test per entity is mandatory. Both rules were added to the
|
||||
@@ -79,8 +79,8 @@ All user rulings live there (D0–D13). Highlights that change how work is done:
|
||||
- [x] **P0.6 — GATE passed** — `PROTECTED.md` confirmed, D0–D12 recorded
|
||||
- [x] P0.7 — Urgent traces delivered: `percent`, `@Public` list, login leak
|
||||
- [x] P0.8 — Skill updated with rules C0 (field-by-field) and E0 (round-trip test)
|
||||
- [ ] **P1.0 — user approves: 5 ledger routes, `percent` column, D0 migration** ← HERE
|
||||
- [ ] Phase 1 — Admin parity (branch `parity/phase-1-admin`, `01-ADMIN-MATRIX.md`)
|
||||
- [x] **P1.0 — approved and DONE:** 5 ledger routes protected, `percent` column added, D0 migration applied, SMS provider implemented
|
||||
- [ ] Phase 1 — Admin parity (branch `parity/phase-1-admin`, `01-ADMIN-MATRIX.md`) ← HERE
|
||||
- [ ] **GATE:** "Admin parity is complete and tested. Shall we move on to the apps (user-app & user-app-v2)?"
|
||||
- [ ] Phase 2 — Apps parity (`02-APPS-MATRIX.md`, both user apps)
|
||||
- [ ] Phase 3 — Work through `ISSUES.md` (only when user asks)
|
||||
@@ -103,12 +103,12 @@ All user rulings live there (D0–D13). Highlights that change how work is done:
|
||||
| D11 | `singleCustomer.vue` → diff-send. |
|
||||
| D12 | **Field-by-field matrix + mandatory round-trip test.** Added to the skill. |
|
||||
| D13 | **Retail storefront (wallet, cashback, `ConfigStatic['percent']`) preserved but OUT OF SCOPE.** Nothing deleted or disabled; no parity work; matrices list it ⏸️ with no gap/work row; ask before touching shared surfaces. |
|
||||
| D14 | **`migrate dev` in development, `migrate deploy` in production and nowhere else.** Never `migrate reset`; never edit `_prisma_migrations`. On drift + a reset offer: **stop and report**. |
|
||||
|
||||
## Open — awaiting user approval
|
||||
|
||||
1. **Protect the 5 `@Public` ledger-write routes** (`DECISIONS.md` group A).
|
||||
2. **`percent` schema change** — add `makingChargeOffsetPercent` to `Customer`.
|
||||
3. **D0 migration file** — must be shown and approved before it runs.
|
||||
Nothing. The 5 ledger routes, the `percent` column and the D0 migration are all
|
||||
done and verified (see CHANGELOG 2026-09-22).
|
||||
|
||||
## Key context (survives context loss)
|
||||
|
||||
@@ -166,9 +166,33 @@ show the total is unchanged for an ordinary customer. Depends on
|
||||
`makingChargeOffsetPercent` existing. Full reasoning: `DECISIONS.md`
|
||||
§"Report — MON-08".
|
||||
|
||||
## Waiting on the owner (2026-09-22)
|
||||
## Done 2026-09-22 (was: waiting on the owner)
|
||||
|
||||
| # | Item | What is ready |
|
||||
|---|---|---|
|
||||
| 1 | Run the **D0** and **percent** migrations | Both SQL files written, storefront impact assessed, backfill report script written, 11 unit tests passing. Command: `npx prisma migrate dev`. |
|
||||
| 2 | Implement the **SMS provider** (Trez) | Provider, all 12 templates and env-var names documented in `DECISIONS.md`. Nothing written yet. **SEC-12: the reference's committed credentials must be rotated.** |
|
||||
| 1 | Run the **D0** and **percent** migrations | ✅ **Applied** with `migrate deploy` (`migrate dev` is interactive-only and this shell is not a TTY). Drift checked read-only first — the only difference is the pre-existing `_goldbank_migrations` table, which was **NOT** dropped (DEAD-07). Service layer, all three forms, trigger test and the full round-trip test done. |
|
||||
| 2 | Implement the **SMS provider** (Trez) | ✅ `SmsModule`/`SmsService` live, OTP wired, text byte-identical, boot check fails in production on a missing key, `.env.example` has names only. The 12 B2B templates land per flow in their own phase. **SEC-12: the owner is rotating the credentials and setting them in `.env`.** |
|
||||
|
||||
|
||||
## ⚠️ Standing rules added 2026-09-22 (D14)
|
||||
|
||||
1. **`migrate dev` in development, `migrate deploy` in production and nowhere
|
||||
else.** Never `migrate reset`, never edit `_prisma_migrations` by hand. If
|
||||
drift is reported and a reset is offered: **stop and report**, do not accept.
|
||||
`prisma db execute` is no longer used for schema changes.
|
||||
2. **Every data deletion is logged in `CHANGELOG.md`**, including throwaway rows
|
||||
a session creates itself.
|
||||
|
||||
## ⚠️ `customer_sync_full_name` is a DATABASE TRIGGER
|
||||
|
||||
Prisma knows nothing about it, so writing `Customer` can store something
|
||||
different from what you wrote:
|
||||
|
||||
| Write | Result |
|
||||
|---|---|
|
||||
| the parts only | `fullName` is rebuilt |
|
||||
| `fullName` only | the parts are re-derived (first token / the rest) |
|
||||
| **both at once** | **THE PARTS WIN**; the submitted `fullName` is discarded |
|
||||
|
||||
Re-read after writing if you need the stored value. See `DECISIONS.md` §D14 and
|
||||
the doc comment on the `Customer` model in `schema.prisma`.
|
||||
|
||||
Reference in New Issue
Block a user