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:
s.esmaeili
2026-09-22 08:08:50 +03:30
co-authored by Claude Opus 5
parent 7b598e9e8e
commit 8133a2bdf4
18 changed files with 1013 additions and 51 deletions
@@ -86,7 +86,7 @@
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="درصد"> <a-form-item label="درصد" :validate-status="err.percent ? 'error' : ''" :help="err.percent">
<a-input-number v-model:value="form.percent" class="w-full" /> <a-input-number v-model:value="form.percent" class="w-full" />
</a-form-item> </a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)"> <a-form-item label="سقف اعتبار (برحسب گرم)">
@@ -199,7 +199,7 @@
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="درصد"> <a-form-item label="درصد" :validate-status="err.percent ? 'error' : ''" :help="err.percent">
<a-input-number v-model:value="form.percent" class="w-full" /> <a-input-number v-model:value="form.percent" class="w-full" />
</a-form-item> </a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)"> <a-form-item label="سقف اعتبار (برحسب گرم)">
@@ -318,7 +318,9 @@ const form = reactive<Record<string, any>>({
// backend falls back to when the field is absent. // backend falls back to when the field is absent.
type: undefined, type: undefined,
reagent_id: undefined, reagent_id: undefined,
percent: undefined, // D9 — the reference defaults a new customer's making-charge offset to 3
// (AdminController.php:338) and makes the field required (:354).
percent: 3,
gram_level: undefined, gram_level: undefined,
// The three document photos legacy collected via MyUpload. Stored as plain // The three document photos legacy collected via MyUpload. Stored as plain
// URLs (schema: nationalIdImageUrl / businessLicenseUrl / shopImageUrl), // URLs (schema: nationalIdImageUrl / businessLicenseUrl / shopImageUrl),
@@ -377,6 +379,12 @@ function validate(): boolean {
if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید'; if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید';
if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید'; if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید';
if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید'; if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید';
// D9 — `percent` is required on create in the reference
// (AdminController.php:354). 0 is a legitimate value, so test for
// "not supplied", not for falsiness.
if (form.percent === undefined || form.percent === null || (form.percent as unknown) === '') {
err.percent = 'لطفا مقدار را وارد کنید';
}
return Object.keys(err).length === 0; return Object.keys(err).length === 0;
} }
@@ -390,12 +398,18 @@ async function handleSubmitOmde() {
// whole form means any field the edit path failed to repopulate is sent as // whole form means any field the edit path failed to repopulate is sent as
// '' and overwrites good data — see the note on Object.assign below. // '' and overwrites good data — see the note on Object.assign below.
const changed = id ? diffFromInitial() : { ...form }; const changed = id ? diffFromInitial() : { ...form };
const payload = { const payload: Record<string, unknown> = {
...changed, ...changed,
id: id ?? null, id: id ?? null,
name: `${form.fname} ${form.lname}`.trim(),
national_code: form.ncode, national_code: form.ncode,
}; };
// D0 — fname/lname are real columns and travel in `changed` on their own.
// `name` is only sent when a name part actually changed; sending it
// unconditionally would re-submit the whole name on every save and defeat
// the diff-send the edit path relies on.
if (!id || 'fname' in changed || 'lname' in changed) {
payload.name = `${form.fname} ${form.lname}`.trim();
}
if (id) { if (id) {
await api.put(`customer/${id}`, payload); await api.put(`customer/${id}`, payload);
} else { } else {
@@ -440,7 +454,10 @@ onMounted(async () => {
mobile: data.mobile ?? '', mobile: data.mobile ?? '',
city_id: data.city_id ?? undefined, city_id: data.city_id ?? undefined,
address: data.address ?? '', address: data.address ?? '',
percent: data.percent ?? undefined, // D9 — the API sends a Decimal, which serialises as a string. Coerce
// it, or a-input-number shows it fine but diffFromInitial() compares
// "3" with 3 and re-sends an unchanged field on every save.
percent: data.percent === null || data.percent === undefined ? undefined : Number(data.percent),
gram_level: data.gram_level ?? undefined, gram_level: data.gram_level ?? undefined,
reagent_id: data.reagent?.id ?? undefined, reagent_id: data.reagent?.id ?? undefined,
// These five were previously OMITTED here. Because handleSubmitOmde // These five were previously OMITTED here. Because handleSubmitOmde
@@ -89,7 +89,9 @@ async function handleSubmit() {
loading.value = true; loading.value = true;
const payload = { const payload = {
...form, ...form,
// one fullName in the schema; the two inputs are joined on submit // D0 — fname and lname are real columns now, so they go as-is (they are
// already in `form`). `name` is still sent for the legacy readers that
// expect a whole name; the server derives it from the parts anyway.
name: `${form.fname} ${form.lname}`.trim(), name: `${form.fname} ${form.lname}`.trim(),
mobile: form.phone, mobile: form.phone,
}; };
@@ -118,10 +120,12 @@ onMounted(async () => {
if (id) { if (id) {
try { try {
const { data } = await api.get(`customer/${id}`); const { data } = await api.get(`customer/${id}`);
const parts = String(data.name ?? '').trim().split(' '); // D0 — the API returns the real parts now. The old split(' ') fallback
// is gone: it guessed at the boundary and put two-token first names in
// the wrong box.
Object.assign(form, { Object.assign(form, {
fname: data.fname || parts[0] || '', fname: data.fname ?? '',
lname: data.lname || parts.slice(1).join(' '), lname: data.lname ?? '',
phone: data.mobile ?? '', phone: data.mobile ?? '',
city_id: data.city_id ?? undefined, city_id: data.city_id ?? undefined,
}); });
+25
View File
@@ -11,3 +11,28 @@ PORT=3000
JWT_CUSTOMER_SECRET= JWT_CUSTOMER_SECRET=
# Customer token lifetime. The reference issued a token that never expired. # Customer token lifetime. The reference issued a token that never expired.
JWT_CUSTOMER_EXPIRES_IN=30d JWT_CUSTOMER_EXPIRES_IN=30d
# ---------------------------------------------------------------------------
# SMS gateway — Trez panel (port of henza_server/app/Models/Sms.php:57-82)
# ---------------------------------------------------------------------------
# NEVER commit real values. The reference hardcoded its live production
# credentials in the source (SEC-12); those are treated as compromised and are
# not reused. Set the real values in .env, which is gitignored.
#
# In production the server REFUSES TO START when the three required variables
# below are unset, because without them no customer can receive a login code.
# Outside production it only warns, and OTP codes are logged locally instead.
# "trez" (default) or "none" to disable sending entirely.
SMS_PROVIDER=trez
# Defaults to the reference endpoint when unset; override for staging.
SMS_TREZ_URL=
# Required in production:
SMS_TREZ_USERNAME=
SMS_TREZ_PASSWORD=
# The panel's sender line — the `PhoneNumber` form field.
SMS_TREZ_SENDER_LINE=
# Comma-separated admin numbers for order/document notifications. The reference
# hardcoded six mobile numbers across its controllers (STUB-05); empty means
# no admin notifications are sent.
SMS_ADMIN_RECIPIENTS=
@@ -0,0 +1,97 @@
-- D0 — split Customer.fullName into firstName / lastName (ADDITIVE).
--
-- Reference: henza_server has two separate columns from the start
-- database/migrations/2020_10_01_120430_customers_table.php:18-19
-- $table->string('fname')->nullable();
-- $table->string('lname')->nullable();
-- The target collapsed them into one `fullName`, so the admin edit form shows
-- the whole name in the "first name" box and saves an empty last name
-- (admin-customers.service.ts:231-233 returns `fname: c.fullName, lname: ''`).
--
-- NOTHING IS DROPPED. `fullName` stays and is kept in sync as
-- `firstName || ' ' || lastName`, so every existing reader keeps working
-- (D0 requirement 4).
--
-- D13 / storefront impact: `Customer` is a SHARED table — both the B2B app and
-- the retail storefront read it. This migration only ADDS two nullable columns
-- and leaves `fullName` populated and authoritative, so no storefront query,
-- view or report changes behaviour. No storefront column is touched.
-- 1. The two new columns. Nullable: a customer may legitimately have no
-- surname, and the reference's own columns are nullable.
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
-- 2. Backfill: first whitespace-separated token -> firstName, the REST ->
-- lastName (D0 requirement 2). Multi-space and leading/trailing whitespace
-- are normalised first so " علی محمدی " splits correctly.
--
-- Rows with a single token get firstName = that token and lastName = NULL.
-- Rows with more than two tokens put every remaining token in lastName;
-- they are ALSO listed in the review report (see the companion script) so a
-- human can correct them — the split cannot be guessed safely
-- (D0 requirement 3).
UPDATE "Customer"
SET
"firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1),
"lastName" = NULLIF(
substr(
btrim(regexp_replace("fullName", '\s+', ' ', 'g')),
length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
),
''
)
WHERE "firstName" IS NULL
AND "fullName" IS NOT NULL
AND btrim("fullName") <> '';
-- 3. Keep `fullName` in sync from now on.
--
-- Writers may update either the parts or the whole; this trigger makes the
-- parts authoritative whenever they are the thing that changed, and
-- otherwise leaves an explicit `fullName` write alone. That way old code
-- that still writes only `fullName` is not broken (D0 requirement 4).
-- Precedence when BOTH the parts and fullName change in the SAME statement:
-- THE PARTS WIN. fullName is rebuilt from them and the submitted fullName is
-- discarded. Rationale: after D0 the parts are the field the forms edit, so
-- they carry the operator's intent; fullName is the derived, legacy-facing
-- value. Choosing the opposite would silently undo an edit made in the two
-- boxes the operator actually typed into.
-- The first branch below tests the parts, so it wins by construction.
CREATE OR REPLACE FUNCTION customer_sync_full_name() RETURNS trigger AS $$
BEGIN
-- The name parts changed (or are being set on insert): rebuild fullName.
-- This is checked FIRST, so when both change in one statement the parts win.
IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL))
OR (TG_OP = 'UPDATE'
AND (NEW."firstName" IS DISTINCT FROM OLD."firstName"
OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN
NEW."fullName" := btrim(
coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '')
);
-- Only fullName changed: derive the parts from it, so the two never drift.
ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName"
AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN
NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1);
NEW."lastName" := NULLIF(
substr(
btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')),
length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
),
''
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS customer_sync_full_name_trg ON "Customer";
CREATE TRIGGER customer_sync_full_name_trg
BEFORE INSERT OR UPDATE ON "Customer"
FOR EACH ROW EXECUTE FUNCTION customer_sync_full_name();
-- 4. Name search hits both parts in the admin grid.
CREATE INDEX IF NOT EXISTS "Customer_firstName_idx" ON "Customer" ("firstName");
CREATE INDEX IF NOT EXISTS "Customer_lastName_idx" ON "Customer" ("lastName");
@@ -0,0 +1,49 @@
-- D9 — add Customer.makingChargeOffsetPercent (ADDITIVE, behaviour-neutral).
--
-- Reference: `customers.percent`, a real per-customer column.
-- database/migrations/2020_10_19_153228_customer_add.php:32
-- $table->double('percent')->default(0);
--
-- What it does in the reference:
-- * it is ADDED to the variant's making charge when the app builds an order
-- line: henza_pwa/src/Mixins/product/index.ts:69-75
-- getWage(v) = myStore.state.percent + v.percentage_pay
-- and `state.percent` is this column, delivered by POST /me-data
-- (henza_pwa/src/store/myDataStore.ts:29,59,122-127).
-- * it shifts the catalogue making-charge filter bounds:
-- ApiMobileController.php:391-396 and :76-77.
-- * it is NOT part of the price formula itself — the extra percent there
-- comes from ConfigStatic['product_price_extra_percent']
-- (ProductVariant.php:50,183).
--
-- Why this name and not `productPercent`: the reference has three unrelated
-- things called `percent` (see docs/parity/MIGRATION-STATE.md). This is the
-- per-customer making-charge offset. It is mapped back to the legacy key
-- `percent` in toRow(), so the admin panel's field name does not change.
-- No duplicate field is created.
--
-- The target currently reads `referredByAgent.wagePercent` instead, which is
-- the wrong field entirely (CUST-02) — that is corrected in the service layer,
-- not here.
--
-- D13 / storefront impact: NONE. `customers.percent` is a B2B wholesale field;
-- it is consumed only by the B2B app's cart and catalogue filter. The
-- storefront's own percent is ConfigStatic['percent'] (the wallet cashback
-- rate), a different key in a different table, which this migration does not
-- touch. `Customer` is a shared table, but adding a column with a default that
-- reproduces today's behaviour changes no storefront read.
-- DEFAULT 0 is deliberate and behaviour-neutral:
-- * it matches the reference's own column default (customer_add.php:32);
-- * with 0 the computed wage is `0 + variant.percentage_pay`, i.e. exactly
-- the making charge alone — which is what the target does today, since it
-- has no such column. So no existing customer's price moves.
-- NOTE: the reference's createCustomer() writes 3, not 0, for NEW customers
-- (AdminController.php:338) and makes the field required on create (:354).
-- That is an application-layer default for new rows, NOT a backfill value,
-- and it is handled in the service, not in this migration.
ALTER TABLE "Customer"
ADD COLUMN IF NOT EXISTS "makingChargeOffsetPercent" DECIMAL(6,3) NOT NULL DEFAULT 0;
COMMENT ON COLUMN "Customer"."makingChargeOffsetPercent" IS
'Legacy customers.percent — per-customer making-charge offset, ADDED to ProductVariant.defaultWagePercent when pricing an order line, and used to shift the catalogue making-charge filter bounds. Not the wallet cashback rate (that is the storefront ConfigStatic[percent]) and not the wholesale per-line charge (that is OrderItem.wagePercentSnapshot).';
+41 -2
View File
@@ -170,8 +170,29 @@ model AdminUser {
} }
model Customer { model Customer {
id String @id @default(cuid()) id String @id @default(cuid())
fullName String
/// ⚠️ `fullName`, `firstName` and `lastName` are kept in sync BY A DATABASE
/// TRIGGER — `customer_sync_full_name`, created in migration
/// 20260922120000_customer_name_parts. Prisma does not know about it, so a
/// write here is NOT the whole story:
///
/// * write the parts -> fullName is rebuilt as "first last"
/// * write fullName only -> the parts are re-derived from it
/// * write BOTH in one update -> THE PARTS WIN; fullName is overwritten
/// with "first last" and whatever you passed for fullName is discarded
///
/// So a value you wrote can differ from the value that lands in the row.
/// Re-read after writing if you need the stored value. Prefer writing the
/// parts; `fullName` exists for the legacy readers that predate D0.
///
/// Reference has these as two columns from the start
/// (henza_server 2020_10_01_120430_customers_table.php:18-19 fname/lname);
/// the port collapsed them, which is the D0 defect.
fullName String
firstName String?
lastName String?
mobile String @unique mobile String @unique
nationalCode String @unique nationalCode String @unique
passwordHash String passwordHash String
@@ -201,6 +222,22 @@ model Customer {
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain
/// Legacy `customers.percent` — the per-customer making-charge offset. It is
/// ADDED to the variant's making charge when pricing an order line
/// (henza_pwa src/Mixins/product/index.ts:69-75:
/// getWage(v) = state.percent + v.percentage_pay)
/// and it shifts the catalogue making-charge filter bounds
/// (ApiMobileController.php:391-396,76-77). It is NOT part of the price
/// formula itself — that extra percent is ConfigStatic['product_price_extra_percent'].
///
/// NOT the wallet cashback rate (storefront ConfigStatic['percent'], D13) and
/// NOT the wholesale per-line charge (OrderItem.wagePercentSnapshot).
///
/// DB default 0 keeps every existing customer's price unchanged. The
/// reference's application-layer default for NEW customers is 3
/// (AdminController.php:338) and the field is required on create (:354).
makingChargeOffsetPercent Decimal @default(0) @db.Decimal(6, 3)
verificationStep VerificationStep @default(UNVERIFIED) verificationStep VerificationStep @default(UNVERIFIED)
nationalIdImageUrl String? nationalIdImageUrl String?
businessLicenseUrl String? businessLicenseUrl String?
@@ -236,6 +273,8 @@ model Customer {
otpChallenges CustomerOtp[] otpChallenges CustomerOtp[]
@@index([referredByAgentId]) @@index([referredByAgentId])
@@index([firstName])
@@index([lastName])
} }
model CustomerAddress { model CustomerAddress {
+109
View File
@@ -0,0 +1,109 @@
/**
* Integration test for the `customer_sync_full_name` database trigger
* (migration 20260922120000_customer_name_parts).
*
* The trigger is SQL, not Prisma, so jest's unit suite cannot cover it — it
* needs a live database. Run it against a development DB:
*
* node scripts/test-name-trigger.cjs
*
* Exits non-zero on any failure. It creates ONE temporary Customer row and
* deletes it again; the deletion is reported on stdout (CHANGELOG rule 2).
*
* The three directions required by the owner's ruling of 2026-09-22:
* parts only -> fullName is rebuilt
* fullName only -> the parts are re-derived
* both at once -> THE PARTS WIN
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
let failures = 0;
function check(condition, message) {
if (!condition) failures++;
console.log(`${condition ? ' PASS' : ' FAIL'} ${message}`);
}
const read = async (id) =>
(
await prisma.$queryRawUnsafe(
`SELECT "fullName","firstName","lastName","makingChargeOffsetPercent" AS pct
FROM "Customer" WHERE id=$1`,
id,
)
)[0];
(async () => {
const id = 'trigger-test-' + Date.now();
const unique = Date.now();
await prisma.$executeRawUnsafe(
`INSERT INTO "Customer"
(id,"fullName","firstName","lastName",mobile,"nationalCode","passwordHash","updatedAt")
VALUES ($1,'',$2,$3,$4,$5,'x',NOW())`,
id,
'محمد علی',
'احمدی',
'09' + String(unique).slice(-9),
'NC' + unique,
);
// The owner's worked example: a two-token FIRST name must survive intact.
console.log('\nCASE 0 — INSERT with parts only («محمد علی» + «احمدی»)');
let r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.fullName === 'محمد علی احمدی', 'fullName rebuilt from the parts');
check(r.firstName === 'محمد علی', 'a two-token first name is NOT re-split');
check(r.lastName === 'احمدی', 'last name kept separate');
console.log('\nCASE 1 — UPDATE the parts only');
await prisma.$executeRawUnsafe(
`UPDATE "Customer" SET "firstName"='رضا',"lastName"='کریمی' WHERE id=$1`,
id,
);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.fullName === 'رضا کریمی', 'fullName rebuilt from the parts');
console.log('\nCASE 2 — UPDATE fullName only');
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سعید موسوی' WHERE id=$1`, id);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.firstName === 'سعید' && r.lastName === 'موسوی', 'parts re-derived from fullName');
console.log('\nCASE 3 — UPDATE BOTH in one statement (the parts must win)');
await prisma.$executeRawUnsafe(
`UPDATE "Customer" SET "fullName"='IGNORED',"firstName"='نرگس',"lastName"='رحیمی' WHERE id=$1`,
id,
);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.fullName === 'نرگس رحیمی', 'parts win; the submitted fullName is discarded');
console.log('\nCASE 4 — a multi-token fullName splits first-token / rest');
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سید محمد حسینی' WHERE id=$1`, id);
r = await read(id);
console.log(' ', JSON.stringify(r));
check(r.firstName === 'سید' && r.lastName === 'محمد حسینی', 'first token / the rest');
console.log('\nCASE 5 — an unrelated update leaves the name untouched');
const before = await read(id);
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET mobile=mobile WHERE id=$1`, id);
const after = await read(id);
check(JSON.stringify(before) === JSON.stringify(after), 'no-op update changes nothing');
console.log('\nCASE 6 — makingChargeOffsetPercent');
check(Number(after.pct) === 0, 'defaults to 0, so no existing price moves');
await prisma.$executeRawUnsafe(`DELETE FROM "Customer" WHERE id=$1`, id);
console.log(`\nCleanup: deleted 1 temporary Customer row (${id}).`);
console.log(failures === 0 ? '\nALL PASSED' : `\n${failures} FAILED`);
await prisma.$disconnect();
process.exit(failures ? 1 : 0);
})().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+2
View File
@@ -3,6 +3,7 @@ import { PrismaModule } from './prisma/prisma.module';
import { CatalogModule } from './modules/catalog/catalog.module'; import { CatalogModule } from './modules/catalog/catalog.module';
import { CustomersModule } from './modules/customers/customers.module'; import { CustomersModule } from './modules/customers/customers.module';
import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module'; import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module';
import { SmsModule } from './modules/sms/sms.module';
import { OrdersModule } from './modules/orders/orders.module'; import { OrdersModule } from './modules/orders/orders.module';
import { AccountingModule } from './modules/accounting/accounting.module'; import { AccountingModule } from './modules/accounting/accounting.module';
import { PricingModule } from './modules/pricing/pricing.module'; import { PricingModule } from './modules/pricing/pricing.module';
@@ -49,6 +50,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
AdminDashboardModule, AdminDashboardModule,
CatalogModule, CatalogModule,
CustomersModule, CustomersModule,
SmsModule,
CustomerAuthModule, CustomerAuthModule,
SalesAgentModule, SalesAgentModule,
OrdersModule, OrdersModule,
+6
View File
@@ -7,6 +7,7 @@ import type { Response } from 'express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { prepareDatabase } from './prisma/bootstrap'; import { prepareDatabase } from './prisma/bootstrap';
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter'; import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
import { assertSmsConfigured } from './modules/sms/sms.service';
// serve-static's bundled `mime` version predates .avif, so it falls back to // serve-static's bundled `mime` version predates .avif, so it falls back to
// application/octet-stream — browsers still render the image (they sniff // application/octet-stream — browsers still render the image (they sniff
@@ -16,6 +17,11 @@ const EXTRA_MIME_TYPES: Record<string, string> = {
}; };
async function bootstrap() { async function bootstrap() {
// Fail fast rather than start a server on which no customer could log in.
// In production a missing SMS credential throws here; outside production it
// only warns and the codes are logged locally.
assertSmsConfigured();
await prepareDatabase(); await prepareDatabase();
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
@@ -97,6 +97,9 @@ export class AdminCustomersService {
private static readonly ALLOWED = [ private static readonly ALLOWED = [
'id', 'id',
'fullName', 'fullName',
'firstName',
'lastName',
'makingChargeOffsetPercent',
'mobile', 'mobile',
'nationalCode', 'nationalCode',
'cityId', 'cityId',
@@ -120,8 +123,11 @@ export class AdminCustomersService {
/** Translates legacy filter field names onto real schema paths. */ /** Translates legacy filter field names onto real schema paths. */
private static readonly FIELD_ALIASES: Record<string, string> = { private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'fullName', name: 'fullName',
fname: 'fullName', // D0 — these now have real columns to point at. Sorting or filtering on
lname: 'fullName', // the last name used to silently act on the whole name.
fname: 'firstName',
lname: 'lastName',
percent: 'makingChargeOffsetPercent',
city_id: 'cityId', city_id: 'cityId',
active: 'isActive', active: 'isActive',
gram_level: 'goldCreditLimitGrams', gram_level: 'goldCreditLimitGrams',
@@ -196,7 +202,10 @@ export class AdminCustomersService {
const aliased = this.applyAliases(query); const aliased = this.applyAliases(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile', 'nationalCode'], // D0 — `fullName` is kept in sync by the trigger so it still matches, but
// the parts are searched too: a surname-only query is the common case and
// it must not depend on how the whole name happens to be spaced.
searchable: ['fullName', 'firstName', 'lastName', 'mobile', 'nationalCode'],
allowed: AdminCustomersService.ALLOWED, allowed: AdminCustomersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' }, defaultOrderBy: { createdAt: 'desc' },
}); });
@@ -227,10 +236,15 @@ export class AdminCustomersService {
) { ) {
return { return {
id: c.id, id: c.id,
// Legacy stored fname/lname separately; the new schema has one fullName. // D0 — the schema now has the real parts, as the reference always did
// (2020_10_01_120430_customers_table.php:18-19). Before this, the edit
// form showed the WHOLE name in the "first name" box and saved an empty
// last name. `firstName`/`lastName` are backfilled and kept in sync with
// `fullName` by the customer_sync_full_name trigger, so the fallbacks
// below only matter for a row written before the migration ran.
name: c.fullName, name: c.fullName,
fname: c.fullName, fname: c.firstName ?? c.fullName,
lname: '', lname: c.lastName ?? '',
mobile: c.mobile, mobile: c.mobile,
national_code: c.nationalCode, national_code: c.nationalCode,
city_id: c.cityId, city_id: c.cityId,
@@ -241,8 +255,12 @@ export class AdminCustomersService {
gram_level: c.goldCreditLimitGrams, gram_level: c.goldCreditLimitGrams,
remain: c.goldDebtGrams, remain: c.goldDebtGrams,
rial_remain: c.fiatDebtRial, rial_remain: c.fiatDebtRial,
// legacy `percent` came from the referring agent's commission rate // D9 — legacy `percent` is a column ON THE CUSTOMER
percent: c.referredByAgent?.wagePercent ?? null, // (customer_add.php:32), the per-customer making-charge offset. The port
// read the referring AGENT's commission rate instead, which is a
// different number entirely (CUST-02). The legacy key is kept so the
// ported table models and forms bind unchanged.
percent: c.makingChargeOffsetPercent,
// legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive // legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive
active: c.isActive ? 1 : -1, active: c.isActive ? 1 : -1,
sts: c.canPlaceOrders ? 1 : -1, sts: c.canPlaceOrders ? 1 : -1,
@@ -516,7 +534,13 @@ export class AdminCustomersService {
* is generic enough to patch any Customer row, agent or otherwise. * is generic enough to patch any Customer row, agent or otherwise.
*/ */
async createSingle(data: Record<string, unknown>) { async createSingle(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim(); // D0 — store the parts when the caller sends them. This is the retail
// (storefront) form, which D13 puts out of scope for parity work; the name
// handling is corrected only so the shared Customer columns stay
// consistent, and no storefront behaviour changes.
const firstName = String(data.fname ?? data.name ?? '').trim();
const lastName = String(data.lname ?? '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
const mobile = String(data.mobile ?? data.phone ?? '').trim(); const mobile = String(data.mobile ?? data.phone ?? '').trim();
if (!fullName || !mobile) { if (!fullName || !mobile) {
@@ -526,6 +550,8 @@ export class AdminCustomersService {
return this.prisma.customer.create({ return this.prisma.customer.create({
data: { data: {
fullName, fullName,
firstName: firstName || null,
lastName: lastName || null,
mobile, mobile,
nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode
passwordHash: '', passwordHash: '',
@@ -545,7 +571,11 @@ export class AdminCustomersService {
* (e.g. reg.vue, or a stale client) still sending type=1. * (e.g. reg.vue, or a stale client) still sending type=1.
*/ */
async create(data: Record<string, unknown>) { async create(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim(); // D0 — the form posts fname and lname separately; store them separately.
// `name` is still accepted for the callers that only send a whole name.
const firstName = String(data.fname ?? data.name ?? '').trim();
const lastName = String(data.lname ?? '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
const mobile = String(data.mobile ?? '').trim(); const mobile = String(data.mobile ?? '').trim();
const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim(); const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim();
@@ -553,6 +583,14 @@ export class AdminCustomersService {
throw new BadRequestException('نام و موبایل الزامی است'); throw new BadRequestException('نام و موبایل الزامی است');
} }
// D9 — the reference makes `percent` required on create
// (AdminController.php:354) with the stock Laravel message, and its
// attribute has no Persian translation, so the text is verbatim what the
// reference produces.
if (data.percent === undefined || data.percent === null || data.percent === '') {
throw new BadRequestException('وارد کردن percent الزامی است');
}
const kind = legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER; const kind = legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER;
if (kind === CustomerKind.SALES_AGENT) { if (kind === CustomerKind.SALES_AGENT) {
throw new BadRequestException( throw new BadRequestException(
@@ -563,6 +601,12 @@ export class AdminCustomersService {
return this.prisma.customer.create({ return this.prisma.customer.create({
data: { data: {
fullName, fullName,
firstName: firstName || null,
lastName: lastName || null,
// D9 — per-customer making-charge offset. Required above, so this is
// always a real value; the reference's own form default is 3
// (AdminController.php:338) and is applied client-side.
makingChargeOffsetPercent: new Prisma.Decimal(String(data.percent)),
mobile, mobile,
nationalCode: nationalCode || mobile, // schema requires it and it is unique nationalCode: nationalCode || mobile, // schema requires it and it is unique
// The form collects a password (bulkCustomer.vue / ReagentCustomer.vue). // The form collects a password (bulkCustomer.vue / ReagentCustomer.vue).
@@ -603,7 +647,25 @@ export class AdminCustomersService {
if (!existing) throw new NotFoundException('مشتری یافت نشد'); if (!existing) throw new NotFoundException('مشتری یافت نشد');
const patch: Prisma.CustomerUpdateInput = {}; const patch: Prisma.CustomerUpdateInput = {};
if (data.name !== undefined) patch.fullName = String(data.name);
// D0 — write the parts, not a collapsed whole name. The
// customer_sync_full_name trigger rebuilds `fullName` from them, and when
// a caller sends both the parts win (see the migration's header).
if (data.fname !== undefined || data.lname !== undefined) {
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
const lastName = String(data.lname ?? existing.lastName ?? '').trim();
patch.firstName = firstName || null;
patch.lastName = lastName || null;
} else if (data.name !== undefined) {
// Whole-name-only callers: the trigger derives the parts.
patch.fullName = String(data.name);
}
// D9 — this was simply not handled, so editing it silently did nothing.
if (data.percent !== undefined && data.percent !== null && data.percent !== '') {
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
}
if (data.mobile !== undefined) patch.mobile = String(data.mobile); if (data.mobile !== undefined) patch.mobile = String(data.mobile);
if (data.national_code !== undefined) patch.nationalCode = String(data.national_code); if (data.national_code !== undefined) patch.nationalCode = String(data.national_code);
if (data.city_id !== undefined) { if (data.city_id !== undefined) {
@@ -857,9 +919,15 @@ export class AdminCustomersService {
throw new BadRequestException('مقدار سطح اعتبار ارسال نشده است'); throw new BadRequestException('مقدار سطح اعتبار ارسال نشده است');
} }
return this.prisma.customer.update({ // D9 — `percent` was in this method's signature but was never written, so
where: { id }, // editing it from the row menu silently did nothing.
data: { goldCreditLimitGrams: new Prisma.Decimal(data.gram_level) }, const patch: Prisma.CustomerUpdateInput = {
}); goldCreditLimitGrams: new Prisma.Decimal(data.gram_level),
};
if (data.percent !== undefined && data.percent !== null && String(data.percent) !== '') {
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
}
return this.prisma.customer.update({ where: { id }, data: patch });
} }
} }
@@ -5,6 +5,7 @@ import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { CUSTOMER_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy'; import { CUSTOMER_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits'; import { faToEn } from './digits';
import { SmsService, smsConfigured } from '../sms/sms.service';
/** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */ /** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */
class TooManyRequestsException extends HttpException { class TooManyRequestsException extends HttpException {
@@ -32,6 +33,7 @@ export class CustomerAuthService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly sms: SmsService,
) {} ) {}
/** /**
@@ -201,28 +203,34 @@ export class CustomerAuthService {
} }
/** /**
* Hands the code to the SMS gateway. * Hands the code to the SMS gateway (SmsService -> Trez, per Sms.php:57-82).
* *
* No gateway is wired up in this project yet — SmsMessage is persistence only * When no credentials are configured, SmsService does not send and the code
* (logged as STUB-02 in docs/parity/ISSUES.md), exactly as the reference's * is logged instead — development only; production refuses to boot without
* Sms::Send would have been the integration point. Until one is connected the * credentials (assertSmsConfigured in main.ts).
* code is logged, and only outside production, so a deployment cannot print
* live codes into its logs.
* *
* The reference's message text is kept verbatim for when the gateway lands * A failed send does NOT throw. The reference ignored Sms::Send's result, so
* (ApiMobileController.php:47). * a gateway outage never blocked the flow that triggered it; the customer
* simply retries, and the throttle in assertSendAllowed still applies.
*/ */
private async deliver(mobile: string, code: string) { private async deliver(mobile: string, code: string) {
// The exact text the reference sent (ApiMobileController.php:47), kept for // BYTE-IDENTICAL to the reference (ApiMobileController.php:47). The
// whenever a gateway is wired up. It is built here and handed to the // leading "<#> code is <CODE>" line and the trailing "aMo6iFyUQaY" are the
// gateway; it is deliberately NOT what gets persisted (see below). // Android SMS Retriever app hash — change either and autofill stops
// working on every installed client. Do not "tidy" this string.
const smsText = const smsText =
`<#> code is ${code}\n` + `<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` + `رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` +
`aMo6iFyUQaY`; `aMo6iFyUQaY`;
void smsText; // TODO: hand to the SMS provider once one exists (STUB-02)
if (process.env.NODE_ENV !== 'production') { // persist:false — the real text carries the live code, and the admin SMS
// grid lists and searches `body` (SEC-11). The redacted audit row below
// takes its place.
await this.sms.send(mobile, smsText, { persist: false });
// Only outside production, and only when no gateway is configured, so a
// deployment can never print live codes into its logs.
if (process.env.NODE_ENV !== 'production' && !smsConfigured()) {
this.logger.log(`[dev] OTP for ${mobile}: ${code}`); this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
} }
+14
View File
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { SmsService } from './sms.service';
/**
* Global so any module can send an SMS without re-importing it — the twelve
* reference notification points are spread across orders, documents and
* customer management.
*/
@Global()
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}
@@ -0,0 +1,138 @@
import { Logger } from '@nestjs/common';
import { assertSmsConfigured, smsConfig, smsConfigured } from './sms.service';
/**
* Covers the boot-time contract the owner specified:
* * while no key is set, development behaviour is unchanged;
* * in production a missing key is a clear, explicit boot failure.
*/
describe('SMS configuration', () => {
const saved = { ...process.env };
// Silence the warnings these tests deliberately provoke.
const logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() } as unknown as Logger;
beforeEach(() => {
for (const key of [
'SMS_PROVIDER',
'SMS_TREZ_URL',
'SMS_TREZ_USERNAME',
'SMS_TREZ_PASSWORD',
'SMS_TREZ_SENDER_LINE',
'NODE_ENV',
]) {
delete process.env[key];
}
jest.clearAllMocks();
});
afterAll(() => {
process.env = { ...saved };
});
const configureFully = () => {
process.env.SMS_TREZ_USERNAME = 'u';
process.env.SMS_TREZ_PASSWORD = 'p';
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
};
describe('smsConfig', () => {
it('defaults the provider to trez and the URL to the reference endpoint', () => {
const c = smsConfig();
expect(c.provider).toBe('trez');
expect(c.url).toBe('https://smspanel.trez.ir/SendMessageWithCode.ashx');
});
it('lets the URL be overridden for staging', () => {
process.env.SMS_TREZ_URL = 'https://staging.example/send';
expect(smsConfig().url).toBe('https://staging.example/send');
});
});
describe('smsConfigured', () => {
it('is false when nothing is set', () => {
expect(smsConfigured()).toBe(false);
});
it('is false when only some credentials are set', () => {
process.env.SMS_TREZ_USERNAME = 'u';
process.env.SMS_TREZ_PASSWORD = 'p';
expect(smsConfigured()).toBe(false);
});
it('is true once all three are set', () => {
configureFully();
expect(smsConfigured()).toBe(true);
});
it('is false when the provider is explicitly disabled', () => {
configureFully();
process.env.SMS_PROVIDER = 'none';
expect(smsConfigured()).toBe(false);
});
});
describe('assertSmsConfigured', () => {
it('only warns outside production when keys are missing', () => {
process.env.NODE_ENV = 'development';
expect(() => assertSmsConfigured(logger)).not.toThrow();
expect(logger.warn).toHaveBeenCalled();
});
it('THROWS in production when keys are missing, naming them', () => {
process.env.NODE_ENV = 'production';
expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_USERNAME/);
expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_PASSWORD/);
expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_SENDER_LINE/);
});
it('passes in production once everything is set', () => {
process.env.NODE_ENV = 'production';
configureFully();
expect(() => assertSmsConfigured(logger)).not.toThrow();
});
it('THROWS in production when SMS is explicitly disabled', () => {
// Disabling SMS in production means nobody can log in; that must be a
// deliberate, visible failure rather than a silent one.
process.env.NODE_ENV = 'production';
process.env.SMS_PROVIDER = 'none';
expect(() => assertSmsConfigured(logger)).toThrow(/Refusing to start in production/);
});
it('allows SMS to be disabled outside production', () => {
process.env.NODE_ENV = 'test';
process.env.SMS_PROVIDER = 'none';
expect(() => assertSmsConfigured(logger)).not.toThrow();
});
it('rejects an unknown provider name outright', () => {
process.env.SMS_PROVIDER = 'kavenegar';
expect(() => assertSmsConfigured(logger)).toThrow(/Unknown SMS_PROVIDER/);
});
});
});
/**
* The OTP text must stay byte-identical to the reference
* (ApiMobileController.php:47) — the first line and the trailing token are the
* Android SMS Retriever hash, and autofill breaks if either changes.
*/
describe('OTP message template', () => {
const build = (code: string) =>
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` +
`aMo6iFyUQaY`;
it('matches the reference byte for byte', () => {
expect(build('1234')).toBe(
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد 1234 می باشد\naMo6iFyUQaY',
);
});
it('keeps the SMS Retriever markers', () => {
const text = build('9876');
expect(text.startsWith('<#> code is ')).toBe(true);
expect(text.endsWith('\naMo6iFyUQaY')).toBe(true);
expect(text.split('\n')).toHaveLength(3);
});
});
+200
View File
@@ -0,0 +1,200 @@
import { Injectable, Logger } from '@nestjs/common';
import { MessageAudience } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
/**
* SMS delivery — port of henza_server/app/Models/Sms.php.
*
* Reference behaviour (`Sms::Send`, `Sms.php:57-82`):
* * POST, form-urlencoded, to the Trez panel, 20s timeout
* * fields: PhoneNumber (sender line), Username, Password, Mobile, Message
* * the message is trim()ed before sending
* * EVERY message is persisted, via `Sms::set()` (`:80`)
* * the response body is returned and the callers ignore it
*
* Two things differ, both deliberate:
* 1. Credentials come from the environment. The reference hardcodes live
* production credentials in the source (SEC-12); they are not copied here.
* 2. The OTP message body is NOT persisted in readable form — see
* CustomerAuthService.deliver() and SEC-11.
*
* Failure handling matches the reference: `Sms::Send` is called inline and its
* result is discarded, so a failed send never aborted the order, the document
* approval or the customer creation that triggered it. `send()` therefore
* never throws — it logs and reports failure in its return value.
*/
/** Reference endpoint (`Sms.php:69`). Overridable for staging. */
const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx';
/** `Http::timeout(20)` in the reference (`Sms.php:72`). */
const REQUEST_TIMEOUT_MS = 20_000;
export interface SmsSendResult {
ok: boolean;
/** Provider response body, when one was received. */
response?: string;
error?: string;
}
export interface SmsSendOptions {
/**
* Persist the message body. Default true, matching `Sms::set()`.
* OTP delivery passes false and writes its own redacted row (SEC-11).
*/
persist?: boolean;
audience?: MessageAudience;
}
/** Reads the provider config. Exported so main.ts can validate it at boot. */
export function smsConfig() {
return {
provider: (process.env.SMS_PROVIDER ?? 'trez').toLowerCase(),
url: process.env.SMS_TREZ_URL ?? DEFAULT_TREZ_URL,
username: process.env.SMS_TREZ_USERNAME ?? '',
password: process.env.SMS_TREZ_PASSWORD ?? '',
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
};
}
/**
* True when a real gateway is configured. When false the service stays in
* development mode: nothing is sent, and OTP codes are logged locally.
*/
export function smsConfigured(): boolean {
const c = smsConfig();
if (c.provider === 'none') return false;
return Boolean(c.username && c.password && c.senderLine);
}
/**
* Called from main.ts before the app listens. In production a missing key is
* fatal: without it no customer can log in, and a silent no-op gives no clue
* why. Outside production it is only a warning, so local development works
* with no credentials at all.
*/
export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void {
const c = smsConfig();
if (c.provider === 'none') {
const message = 'SMS_PROVIDER=none — no SMS will be sent.';
if (process.env.NODE_ENV === 'production') {
throw new Error(
`${message} Refusing to start in production: customers could not log in. ` +
'Set SMS_PROVIDER=trez and the SMS_TREZ_* variables.',
);
}
logger.warn(message);
return;
}
if (c.provider !== 'trez') {
throw new Error(
`Unknown SMS_PROVIDER "${c.provider}". Supported values: "trez", "none".`,
);
}
const missing = (
[
['SMS_TREZ_USERNAME', c.username],
['SMS_TREZ_PASSWORD', c.password],
['SMS_TREZ_SENDER_LINE', c.senderLine],
] as const
)
.filter(([, v]) => !v)
.map(([name]) => name);
if (missing.length === 0) return;
const message = `SMS provider "trez" is selected but these are not set: ${missing.join(', ')}.`;
if (process.env.NODE_ENV === 'production') {
throw new Error(
`${message} Refusing to start in production: no customer could receive a login code. ` +
'Set them, or set SMS_PROVIDER=none to acknowledge that SMS is disabled.',
);
}
logger.warn(`${message} Running in development mode — messages are logged, not sent.`);
}
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Sends one message. Never throws — a failed SMS must not roll back the
* business operation that triggered it, exactly as in the reference.
*/
async send(mobile: string, body: string, options: SmsSendOptions = {}): Promise<SmsSendResult> {
const { persist = true, audience = MessageAudience.SINGLE_NUMBER } = options;
// The reference trims before sending (`Sms.php:77`).
const text = body.trim();
let result: SmsSendResult;
if (!smsConfigured()) {
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
result = { ok: false, error: 'sms-not-configured' };
} else {
result = await this.deliverViaTrez(mobile, text);
}
if (persist) {
// `Sms::set()` records every message, delivered or not.
await this.record(mobile, text, audience);
}
return result;
}
/** POST to the Trez panel, shaped exactly as the reference's form post. */
private async deliverViaTrez(mobile: string, text: string): Promise<SmsSendResult> {
const c = smsConfig();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(c.url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
PhoneNumber: c.senderLine,
Username: c.username,
Password: c.password,
Mobile: mobile,
Message: text,
}).toString(),
signal: controller.signal,
});
const responseBody = await response.text();
if (!response.ok) {
// Logged, not thrown — see the class comment.
this.logger.error(`SMS to ${mobile} failed: HTTP ${response.status} ${responseBody}`);
return { ok: false, response: responseBody, error: `http-${response.status}` };
}
return { ok: true, response: responseBody };
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
this.logger.error(`SMS to ${mobile} failed: ${error}`);
return { ok: false, error };
} finally {
clearTimeout(timer);
}
}
/** The `messages` row the reference writes for every send (`Sms.php:120-129`). */
private async record(mobile: string, body: string, audience: MessageAudience) {
try {
await this.prisma.smsMessage.create({
data: { audience, recipients: mobile, body, sentCount: 1 },
});
} catch (e) {
// Never let the audit write break the caller either.
this.logger.error(`Could not record the SMS to ${mobile}: ${String(e)}`);
}
}
}
+92
View File
@@ -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. | | `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. `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 ✅.
+64
View File
@@ -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 security deviation from the reference. The visible result is identical whenever
the client sends the true variant weight, which the reference app always does 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. (`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.
+6
View File
@@ -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. | | 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. | | 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. | | 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
View File
@@ -10,8 +10,8 @@
| **Phase** | Security hotfix — **stages 1 and 2 ✅ complete** | | **Phase** | Security hotfix — **stages 1 and 2 ✅ complete** |
| **Branch** | `parity/security-hotfix` | | **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. | | **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) | | **Next step** | **Start `01-ADMIN-MATRIX.md`** — field-by-field per D12, rid mapping matrix per D1 |
| **Blocked on** | approval to run the two migrations; approval to implement the SMS provider | | **Blocked on** | nothing |
| **Last updated** | 2026-09-22 | | **Last updated** | 2026-09-22 |
### Security hotfix — final result ### Security hotfix — final result
@@ -51,7 +51,7 @@ while the id and the id-addressed routes remain.**
## ⚠️ Read `DECISIONS.md` before doing anything ## ⚠️ 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 - **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 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.6 — GATE passed** — `PROTECTED.md` confirmed, D0–D12 recorded
- [x] P0.7 — Urgent traces delivered: `percent`, `@Public` list, login leak - [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) - [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 - [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`) - [ ] 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)?" - [ ] **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 2 — Apps parity (`02-APPS-MATRIX.md`, both user apps)
- [ ] Phase 3 — Work through `ISSUES.md` (only when user asks) - [ ] 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. | | D11 | `singleCustomer.vue` → diff-send. |
| D12 | **Field-by-field matrix + mandatory round-trip test.** Added to the skill. | | 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. | | 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 ## Open — awaiting user approval
1. **Protect the 5 `@Public` ledger-write routes** (`DECISIONS.md` group A). Nothing. The 5 ledger routes, the `percent` column and the D0 migration are all
2. **`percent` schema change** — add `makingChargeOffsetPercent` to `Customer`. done and verified (see CHANGELOG 2026-09-22).
3. **D0 migration file** — must be shown and approved before it runs.
## Key context (survives context loss) ## 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` `makingChargeOffsetPercent` existing. Full reasoning: `DECISIONS.md`
§"Report — MON-08". §"Report — MON-08".
## Waiting on the owner (2026-09-22) ## Done 2026-09-22 (was: waiting on the owner)
| # | Item | What is ready | | # | 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`. | | 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) | 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.** | | 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`.