import { Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { OrdersService } from './orders.service'; /** Order SMS is fire-and-forget (D20); these suites only assert order data. */ const smsStub = { send: jest.fn(async () => ({ ok: true })) }; /** Stands in for Postgres `nextval('order_number_seq')` inside the create tx. */ const nextOrderNumberRaw = () => jest.fn(async () => [{ n: 100n }]); /** * Owner ruling 2026-10-08: an order is credited to the customer's expert AT * THE MOMENT IT IS PLACED (legacy `orders.reagent_id`), and a later change of * the customer's expert never moves it. */ describe('OrdersService.createOrder — sales expert credit', () => { function build(referredByAgentId: string | null) { const created: Record[] = []; const tx = { order: { create: jest.fn(async ({ data }: { data: Record }) => { created.push(data); return { id: 'o1', ...data }; }), }, $queryRaw: nextOrderNumberRaw(), }; const prisma = { customer: { findUnique: jest.fn(async () => ({ id: 'c1', referredByAgentId, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000), })), }, adminUser: { findUnique: jest.fn(async () => null) }, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; const accounting = { orderStatusReached: jest.fn() }; const wage = { quote: jest.fn() }; const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never, smsStub as never); return { service, created }; } const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never; it("records the customer's current expert on the order", async () => { const { service, created } = build('agent-A'); await service.createOrder(dto, 'c1'); expect(created[0].salesAgentId).toBe('agent-A'); }); it('assigns a short numeric orderNumber from the sequence', async () => { const { service, created } = build(null); await service.createOrder(dto, 'c1'); expect(created[0].orderNumber).toBe('100'); }); it('records no expert for a customer without one', async () => { const { service, created } = build(null); await service.createOrder(dto, 'c1'); expect(created[0].salesAgentId).toBeNull(); }); }); /** A server quote of 10% for every variant. */ const wageStub = { quote: jest.fn(async (_c: string, ids: string[]) => new Map(ids.map((id) => [id, { wagePercentSnapshot: '10', discountPercentSnapshot: '0' }]))), }; /** MON-05 (owner ruling 2026-10-08): «عدم سفارش» stops app orders only. */ describe('OrdersService.createOrder — order ban', () => { function build(canPlaceOrders: boolean) { const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) }, $queryRaw: nextOrderNumberRaw(), }; const prisma = { customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000), })), }, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never); } const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never; it('refuses a suspended customer ordering from the app', async () => { await expect(build(false).createOrder(dto, 'c1', { placedBy: 'customer' })).rejects.toThrow('غیرفعال'); }); it('lets an operator place an order for a suspended customer', async () => { await expect(build(false).createOrder(dto, 'c1', { placedBy: 'operator' })).resolves.toMatchObject({ id: 'o1' }); }); it('lets an allowed customer order from the app', async () => { await expect(build(true).createOrder(dto, 'c1', { placedBy: 'customer' })).resolves.toMatchObject({ id: 'o1' }); }); }); /** MON-08 (owner ruling 2026-10-08): an app order is priced by the server, never by the app. */ describe('OrdersService.createOrder — server-side making charge', () => { function build(rialWage?: number | string | null) { const created: Record[] = []; const tx = { order: { create: jest.fn(async ({ data }: { data: Record }) => { created.push(data); return { id: 'o1', ...data }; }), }, $queryRaw: nextOrderNumberRaw(), }; const prisma = { customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) }, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10', ...(rialWage !== undefined ? { rialWage } : {}), })), }; const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never); return { service, created }; } // A tampered request: zero making charge, a 50% discount and a rial charge. const tampered = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 0, discountPercent: 50, flatRialCharge: 999 }] } as never; it('ignores what the app sends and uses the server quote', async () => { const { service, created } = build(); await service.createOrder(tampered, 'c1', { placedBy: 'customer' }); expect(created[0].totalGoldWeightGrams.toString()).toBe('11'); expect(created[0].flatRialCharge.toString()).toBe('0'); }); it('keeps the numbers an operator entered in the panel', async () => { const { service, created } = build(); await service.createOrder(tampered, 'c1', { placedBy: 'operator' }); expect(created[0].totalGoldWeightGrams.toString()).toBe('5'); expect(created[0].flatRialCharge.toString()).toBe('999'); }); it('charges the variant rial wage on an app order and ignores a tampered body', async () => { const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); try { const { service, created } = build(500000); await service.createOrder(tampered, 'c1', { placedBy: 'customer' }); expect(created[0].items.create[0].flatRialCharge.toString()).toBe('500000'); expect(created[0].flatRialCharge.toString()).toBe('500000'); const lines = warn.mock.calls.map((c) => String(c[0])).join('\n'); expect(lines).toContain('flatRialCharge sent=999 used=500000'); } finally { warn.mockRestore(); } }); it('keeps an operator rial figure when the variant has its own wage', async () => { const { service, created } = build(500000); await service.createOrder(tampered, 'c1', { placedBy: 'operator' }); expect(created[0].items.create[0].flatRialCharge.toString()).toBe('999'); expect(created[0].flatRialCharge.toString()).toBe('999'); }); it('copies the variant rial wage when an operator omits the field', async () => { const omitted = { items: [{ productVariantId: 'v1', quantity: 2, wagePercent: 10 }] } as never; const { service, created } = build(500000); await service.createOrder(omitted, 'c1', { placedBy: 'operator' }); expect(created[0].items.create[0].flatRialCharge.toString()).toBe('500000'); expect(created[0].flatRialCharge.toString()).toBe('1000000'); }); /** * MON-08 step 3 (owner ruling): «تست مقایسه‌ای که نشان دهد نتیجه برای کاربر * عادی یکسان است». An unmodified app sends what the server derives * (DECISIONS §"Report — MON-08"), so deriving server-side must not move the * total for an ordinary customer. */ it('gives an honest app exactly the total it sent', async () => { const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10, discountPercent: 0, flatRialCharge: 0 }] } as never; const a = build(); await a.service.createOrder(honest, 'c1', { placedBy: 'customer' }); const b = build(); await b.service.createOrder(honest, 'c1', { placedBy: 'operator' }); expect(a.created[0].totalGoldWeightGrams.toString()).toBe(b.created[0].totalGoldWeightGrams.toString()); expect(a.created[0].flatRialCharge.toString()).toBe(b.created[0].flatRialCharge.toString()); }); /** MON-08 step 2: a discarded client figure must leave a trace. */ it('logs what the client sent when it differs from the server', async () => { const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); try { await build().service.createOrder(tampered, 'c1', { placedBy: 'customer' }); const lines = warn.mock.calls.map((c) => String(c[0])).join('\n'); expect(lines).toContain('wagePercent sent=0 used=10'); expect(lines).toContain('discountPercent sent=50 used=0'); expect(lines).toContain('flatRialCharge sent=999 used=0'); } finally { warn.mockRestore(); } }); /** * «ورود به حساب مشتری»: the order is the customer's in every respect — their * account, their pricing — and the expert is recorded only in the log, which * is the audit trail the reference lost by lending out the customer's token. */ it('records which expert placed an order on a customer’s behalf', async () => { const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); try { const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never; const { service, created } = build(); await service.createOrder(honest, 'c1', { placedBy: 'customer', actingAsExpertId: 'adm_9' }); // Still the customer's order, priced by the server. expect(created[0].customerId).toBe('c1'); expect(created[0].totalGoldWeightGrams.toString()).toBe('11'); const lines = log.mock.calls.map((c) => String(c[0])).join('\n'); expect(lines).toContain('sales expert adm_9 acting as them'); } finally { log.mockRestore(); } }); it('logs no expert for an ordinary customer order', async () => { const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); try { const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never; await build().service.createOrder(honest, 'c1', { placedBy: 'customer' }); const lines = log.mock.calls.map((c) => String(c[0])).join('\n'); expect(lines).not.toContain('acting as'); } finally { log.mockRestore(); } }); it('stays quiet when the app sent the right figures', async () => { const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); try { const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never; await build().service.createOrder(honest, 'c1', { placedBy: 'customer' }); const pricing = warn.mock.calls.filter((c) => String(c[0]).includes('Order pricing')); expect(pricing).toHaveLength(0); } finally { warn.mockRestore(); } }); }); /** App orders are B2B wholesale; RETAIL is only for the admin retail list. */ describe('OrdersService.createOrder — channel', () => { function build() { const created: Record[] = []; const tx = { order: { create: jest.fn(async ({ data }: { data: Record }) => { created.push(data); return { id: 'o1', ...data }; }), }, $queryRaw: nextOrderNumberRaw(), }; const prisma = { customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000), })), }, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; const service = new OrdersService( prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never, ); return { service, created }; } const items = [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }]; it('stores an app order as WHOLESALE even when the body omits channel', async () => { const { service, created } = build(); await service.createOrder({ items } as never, 'c1', { placedBy: 'customer' }); expect(created[0].channel).toBe('WHOLESALE'); }); it('ignores a RETAIL channel sent by the app', async () => { const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); try { const { service, created } = build(); await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'customer' }); expect(created[0].channel).toBe('WHOLESALE'); expect(warn.mock.calls.map((c) => String(c[0])).join('\n')).toContain('forcing WHOLESALE'); } finally { warn.mockRestore(); } }); it('keeps an operator-supplied RETAIL channel', async () => { const { service, created } = build(); await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'operator' }); expect(created[0].channel).toBe('RETAIL'); }); it('defaults an operator order without channel to RETAIL', async () => { const { service, created } = build(); await service.createOrder({ items } as never, 'c1', { placedBy: 'operator' }); expect(created[0].channel).toBe('RETAIL'); }); }); /** * Legacy ApiMobileController::setOrder:733-766 texted three audiences on every * placed order. None of it was wired in the target until now. */ describe('OrdersService.createOrder — order SMS', () => { function build(opts: { overLimit?: boolean; agentId?: string | null; agentMobile?: string | null } = {}) { const sent: { to: string; body: string }[] = []; const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) }, $queryRaw: nextOrderNumberRaw(), }; const prisma = { customer: { findUnique: jest.fn(async () => ({ id: 'c1', mobile: '09120000001', firstName: 'علی', lastName: 'رضایی', canPlaceOrders: true, referredByAgentId: opts.agentId ?? null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(opts.overLimit ? 1 : 1000), })), }, adminUser: { findUnique: jest.fn(async () => ({ mobile: opts.agentMobile ?? null })) }, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; const sms = { send: jest.fn(async (to: string, body: string) => { sent.push({ to, body }); return { ok: true }; }) }; const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, sms as never); return { service, sent }; } const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never; // SMS is deliberately not awaited (D20), so let the microtasks drain. const settle = () => new Promise((r) => setImmediate(r)); afterEach(() => { delete process.env.ORDER_ADMIN_MOBILES; }); it('texts the customer that the order was placed', async () => { const { service, sent } = build(); await service.createOrder(dto, 'c1', { placedBy: 'customer' }); await settle(); const toCustomer = sent.find((s) => s.to === '09120000001'); expect(toCustomer?.body).toContain('به ثبت رسید. تهران تاج'); expect(toCustomer?.body).not.toContain('معلق'); }); it('tells the customer an over-limit order was suspended', async () => { const { service, sent } = build({ overLimit: true }); await service.createOrder(dto, 'c1', { placedBy: 'customer' }); await settle(); const toCustomer = sent.find((s) => s.to === '09120000001'); expect(toCustomer?.body).toContain('عبور از سقف مجاز گرمی در حالت معلق قرار گرفت'); }); it('texts every configured admin with the name and weight', async () => { process.env.ORDER_ADMIN_MOBILES = '09121111111, 09122222222'; const { service, sent } = build(); await service.createOrder(dto, 'c1', { placedBy: 'customer' }); await settle(); const admins = sent.filter((s) => s.to.startsWith('0912') && s.to !== '09120000001'); expect(admins.map((a) => a.to)).toEqual(['09121111111', '09122222222']); expect(admins[0].body).toContain('علی رضایی'); expect(admins[0].body).toContain('11'); }); it('texts nobody extra when no admin numbers are configured', async () => { const { service, sent } = build(); await service.createOrder(dto, 'c1', { placedBy: 'customer' }); await settle(); expect(sent).toHaveLength(1); expect(sent[0].to).toBe('09120000001'); }); it("texts the customer's sales expert", async () => { const { service, sent } = build({ agentId: 'agent-A', agentMobile: '09123333333' }); await service.createOrder(dto, 'c1', { placedBy: 'customer' }); await settle(); const toAgent = sent.find((s) => s.to === '09123333333'); expect(toAgent?.body).toContain('سفارشی توسط علی رضایی'); }); it('places the order even when the expert has no mobile', async () => { const { service, sent } = build({ agentId: 'agent-A', agentMobile: null }); await expect(service.createOrder(dto, 'c1', { placedBy: 'customer' })).resolves.toMatchObject({ id: 'o1' }); await settle(); expect(sent).toHaveLength(1); }); });