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
@@ -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);
});
});