migration
This commit is contained in:
@@ -171,7 +171,13 @@ export class AdminCustomersController {
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
verify(
|
||||
@Body()
|
||||
body: { customer_id: string; sts_verify_step_one?: number; sts_verify_step_two?: number },
|
||||
body: {
|
||||
customer_id: string;
|
||||
sts_verify_step_one?: number;
|
||||
sts_verify_step_two?: number;
|
||||
/** Optional, only read on a reject. */
|
||||
reason?: string | null;
|
||||
},
|
||||
) {
|
||||
return this.customers.verify(body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { VerificationStep } from '@prisma/client';
|
||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { AdminCustomersService } from './admin-customers.service';
|
||||
|
||||
/**
|
||||
* Owner rulings 2026-10-07:
|
||||
* - a reject stores REJECTED with an optional reason (it used to fall back to
|
||||
* UNVERIFIED / STEP_ONE_SUBMITTED and leave no trace);
|
||||
* - every value sits in its own column: no mobile in nationalCode, no ''
|
||||
* password — an absent national code is NULL, an absent password is the
|
||||
* explicit OTP-only marker.
|
||||
*/
|
||||
describe('AdminCustomersService', () => {
|
||||
function build(current: Record<string, unknown> = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED }) {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const tx = {
|
||||
customer: {
|
||||
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
writes.push(data);
|
||||
return { ...current, ...data };
|
||||
}),
|
||||
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
writes.push(data);
|
||||
return { id: 'new', ...data };
|
||||
}),
|
||||
findUnique: jest.fn(async () => current),
|
||||
},
|
||||
};
|
||||
const prisma = { ...tx, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) };
|
||||
const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn() };
|
||||
const sms = { send: jest.fn(async () => undefined) };
|
||||
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never);
|
||||
return { service, writes, accounting };
|
||||
}
|
||||
|
||||
describe('verify()', () => {
|
||||
it('a step-one reject stores REJECTED with the reason and time', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_one: 3, reason: ' کارت ملی خوانا نیست ' });
|
||||
expect(writes[0]).toMatchObject({
|
||||
verificationStep: VerificationStep.REJECTED,
|
||||
verificationRejectionReason: 'کارت ملی خوانا نیست',
|
||||
});
|
||||
expect(writes[0].verificationRejectedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('a step-two reject is REJECTED too, and the reason is optional', async () => {
|
||||
const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 });
|
||||
expect(writes[0]).toMatchObject({ verificationStep: VerificationStep.REJECTED, verificationRejectionReason: null });
|
||||
});
|
||||
|
||||
it('accepting a rejected customer clears the reason', async () => {
|
||||
const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.REJECTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_one: 2 });
|
||||
expect(writes[0]).toEqual({
|
||||
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
|
||||
verificationRejectionReason: null,
|
||||
verificationRejectedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('step-two accept verifies and tells accounting once', async () => {
|
||||
const { service, writes, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_two: 2 });
|
||||
expect(writes[0].verificationStep).toBe(VerificationStep.VERIFIED);
|
||||
expect(accounting.customerVerified).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a reject never tells accounting', async () => {
|
||||
const { service, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 });
|
||||
expect(accounting.customerVerified).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('national code and password', () => {
|
||||
it('create() without a national code or password stores NULL and the OTP-only marker', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.create({ fname: 'علی', lname: 'رضایی', mobile: '09120000000', percent: '3' });
|
||||
expect(writes[0].nationalCode).toBeNull();
|
||||
expect(writes[0].mobile).toBe('09120000000');
|
||||
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
|
||||
});
|
||||
|
||||
it('create() keeps a typed national code, digits normalised', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.create({ fname: 'علی', mobile: '09120000000', percent: '3', national_code: ' ۰۰۱۲۳۴۵۶۷۸ ' });
|
||||
expect(writes[0].nationalCode).toBe('0012345678');
|
||||
});
|
||||
|
||||
it('createSingle() never copies the mobile into nationalCode', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.createSingle({ fname: 'علی', mobile: '09120000000' });
|
||||
expect(writes[0].nationalCode).toBeNull();
|
||||
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
|
||||
@@ -67,6 +68,16 @@ export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'arch
|
||||
* `sts_verify_step_one !== 2`, i.e. only an ACCEPTED first step unlocks them.
|
||||
* The new schema models one ordered enum, so these numbers are derived from it.
|
||||
*/
|
||||
/**
|
||||
* A national code as the operator typed it, digits normalised; empty -> null.
|
||||
* The column is optional and unique (2026-10-07): an absent code is NULL,
|
||||
* never the mobile or '' — two customers without one must not collide, and
|
||||
* the column must only ever hold a national code.
|
||||
*/
|
||||
function nationalCodeOrNull(raw: unknown): string | null {
|
||||
return faToEn(String(raw ?? '').trim()) || null;
|
||||
}
|
||||
|
||||
function verifyStepOne(step: VerificationStep): number {
|
||||
switch (step) {
|
||||
case VerificationStep.UNVERIFIED:
|
||||
@@ -314,6 +325,8 @@ export class AdminCustomersService {
|
||||
sts_verify_step_one: verifyStepOne(c.verificationStep),
|
||||
sts_verify_step_two: verifyStepTwo(c.verificationStep),
|
||||
verificationStep: c.verificationStep,
|
||||
verification_rejection_reason: c.verificationRejectionReason,
|
||||
verification_rejected_at: c.verificationRejectedAt,
|
||||
national_id_image: c.nationalIdImageUrl,
|
||||
business_license: c.businessLicenseUrl,
|
||||
shop_image: c.shopImageUrl,
|
||||
@@ -631,8 +644,9 @@ export class AdminCustomersService {
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
mobile,
|
||||
nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode
|
||||
passwordHash: '',
|
||||
// singleCustomer.vue collects no national code.
|
||||
nationalCode: null,
|
||||
passwordHash: OTP_ONLY_PASSWORD_HASH,
|
||||
cityId: (data.city_id as string) || null,
|
||||
kind: CustomerKind.RETAIL,
|
||||
},
|
||||
@@ -660,7 +674,7 @@ export class AdminCustomersService {
|
||||
// can never log in: the app's own faToEn turns the typed number into ASCII
|
||||
// and the lookup misses.
|
||||
const mobile = faToEn(String(data.mobile ?? '').trim());
|
||||
const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim();
|
||||
const nationalCode = nationalCodeOrNull(data.national_code ?? data.nationalCode);
|
||||
|
||||
if (!fullName || !mobile) {
|
||||
throw new BadRequestException('نام و موبایل الزامی است');
|
||||
@@ -691,12 +705,12 @@ export class AdminCustomersService {
|
||||
// (AdminController.php:338) and is applied client-side.
|
||||
makingChargeOffsetPercent: new Prisma.Decimal(String(data.percent)),
|
||||
mobile,
|
||||
nationalCode: nationalCode || mobile, // schema requires it and it is unique
|
||||
nationalCode,
|
||||
// The form collects a password (bulkCustomer.vue / ReagentCustomer.vue).
|
||||
// It used to be discarded and '' stored, so the operator typed a password
|
||||
// that silently did nothing. Hashed here when supplied; '' still means
|
||||
// "no password yet", which no bcrypt hash can ever match.
|
||||
passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : '',
|
||||
// that silently did nothing. Hashed here when supplied; otherwise the
|
||||
// account signs in by OTP only.
|
||||
passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : OTP_ONLY_PASSWORD_HASH,
|
||||
cityId: (data.city_id as string) ?? null,
|
||||
goldCreditLimitGrams: data.gram_level
|
||||
? new Prisma.Decimal(String(data.gram_level))
|
||||
@@ -761,7 +775,7 @@ export class AdminCustomersService {
|
||||
// that lock NOT ported, so the field stays editable here and is normalised
|
||||
// instead. Logged in ISSUES as PERS-01.
|
||||
if (data.mobile !== undefined) patch.mobile = faToEn(String(data.mobile));
|
||||
if (data.national_code !== undefined) patch.nationalCode = String(data.national_code);
|
||||
if (data.national_code !== undefined) patch.nationalCode = nationalCodeOrNull(data.national_code);
|
||||
if (data.city_id !== undefined) {
|
||||
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
|
||||
}
|
||||
@@ -950,35 +964,50 @@ export class AdminCustomersService {
|
||||
* Legacy's payload is `{ customer_id, sts_verify_step_one }` or
|
||||
* `{ customer_id, sts_verify_step_two }`, where the value is 2 (accept) or
|
||||
* 3 (reject) — see OmdeList.vue acceptStepOne/rejectStepOne. Accepted here in
|
||||
* exactly that shape so the ported view posts unchanged.
|
||||
* exactly that shape so the ported view posts unchanged, plus an optional
|
||||
* `reason` on a reject.
|
||||
*
|
||||
* A reject at either step records REJECTED with its reason and time (owner
|
||||
* ruling 2026-10-07). It used to send the customer back to UNVERIFIED /
|
||||
* STEP_ONE_SUBMITTED, so a rejection left no trace and looked like a
|
||||
* customer who had simply not finished. Same rule as the legacy import:
|
||||
* a rejected first OR second step is REJECTED (DATA-MIGRATION-MAP §5.5).
|
||||
* Accepting again clears the reason.
|
||||
*/
|
||||
async verify(payload: {
|
||||
customer_id: string;
|
||||
sts_verify_step_one?: number;
|
||||
sts_verify_step_two?: number;
|
||||
reason?: string | null;
|
||||
}) {
|
||||
const id = payload.customer_id;
|
||||
const c = await this.prisma.customer.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
let next: VerificationStep;
|
||||
const step = payload.sts_verify_step_one ?? payload.sts_verify_step_two;
|
||||
if (step === undefined) throw new BadRequestException('مرحلهی تایید مشخص نشده است');
|
||||
|
||||
if (payload.sts_verify_step_one !== undefined) {
|
||||
next =
|
||||
payload.sts_verify_step_one === 2
|
||||
? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending
|
||||
: VerificationStep.UNVERIFIED; // rejected
|
||||
} else if (payload.sts_verify_step_two !== undefined) {
|
||||
next =
|
||||
payload.sts_verify_step_two === 2
|
||||
? VerificationStep.VERIFIED
|
||||
: VerificationStep.STEP_ONE_SUBMITTED; // rejected -> back to step one accepted
|
||||
let data: Prisma.CustomerUpdateInput;
|
||||
if (step === 2) {
|
||||
data = {
|
||||
verificationStep:
|
||||
payload.sts_verify_step_one !== undefined
|
||||
? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending
|
||||
: VerificationStep.VERIFIED,
|
||||
verificationRejectionReason: null,
|
||||
verificationRejectedAt: null,
|
||||
};
|
||||
} else {
|
||||
throw new BadRequestException('مرحلهی تایید مشخص نشده است');
|
||||
data = {
|
||||
verificationStep: VerificationStep.REJECTED,
|
||||
verificationRejectionReason: String(payload.reason ?? '').trim() || null,
|
||||
verificationRejectedAt: new Date(),
|
||||
};
|
||||
}
|
||||
const next = data.verificationStep;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.customer.update({ where: { id }, data: { verificationStep: next } });
|
||||
const updated = await tx.customer.update({ where: { id }, data });
|
||||
// D27 — a verified customer gets an account in the accounting software
|
||||
// (queued; searched by mobile first, so an existing account is linked).
|
||||
if (next === VerificationStep.VERIFIED && c.verificationStep !== VerificationStep.VERIFIED) {
|
||||
|
||||
Reference in New Issue
Block a user