import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OrderChannel, OrderStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { CatalogService } from '../catalog/catalog.service'; import { LedgerService } from '../accounting/ledger.service'; import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service'; import { CreateOrderDto, CreateOrderItemInputDto } from './dto/create-order.dto'; import { WageService } from '../pricing/wage.service'; import { SmsService } from '../sms/sms.service'; import { brandName } from '../../common/brand'; interface ComputedItem { productVariantId: string; quantity: number; rashWeightGramsSnapshot: Prisma.Decimal; wagePercentSnapshot: Prisma.Decimal; discountPercentSnapshot: Prisma.Decimal; computedWeightGrams: Prisma.Decimal; flatRialCharge: Prisma.Decimal; } @Injectable() export class OrdersService { private readonly logger = new Logger(OrdersService.name); constructor( private readonly prisma: PrismaService, private readonly catalog: CatalogService, private readonly ledger: LedgerService, private readonly accounting: AccountingHooksService, private readonly wage: WageService, private readonly sms: SmsService, ) {} /** * Places a stock order. Per line item: * w = rashWeightGrams * (1 + (wagePercent - discountPercent) / 100) * mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here — * only while the order is delivered (see updateStatus / syncOrderDebt). */ /** * @param customerId taken from the caller's token by the controller, NEVER * from the request body. The body's `customerId` is ignored; see the note * on CreateOrderDto. */ async createOrder( dto: CreateOrderDto, customerId: string, opts: { placedBy: 'customer' | 'operator'; /** * Set when a sales expert placed this through «ورود به حساب مشتری». The * order is the customer's in every other respect; this only records who * actually pressed the button, which the reference lost entirely by * lending out the customer's own token. */ actingAsExpertId?: string; } = { placedBy: 'operator' }, ) { const customer = await this.prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) throw new NotFoundException(`Customer ${customerId} not found`); // MON-05 (owner ruling 2026-10-08): «معلق کردن سفارش» stops the customer // ordering from the app. Legacy stored the switch (customers.sts) but no // code read it, so it stopped nothing. An operator placing an order FOR // the customer from the panel is not blocked — that is how a suspended // account is still served. if (opts.placedBy === 'customer' && !customer.canPlaceOrders) { throw new ForbiddenException('ثبت سفارش برای حساب شما فعلاً غیرفعال است. لطفاً با پشتیبانی تماس بگیرید.'); } // The apps still send customerId in the body (removed from them in Phase 2). // It is not used for anything; a mismatch means either a stale client or an // attempt to order on someone else's account, and both are worth seeing. if (dto.customerId && dto.customerId !== customerId) { this.logger.warn( `Order body carried customerId=${dto.customerId} but the token is ${customerId}; ` + `the token wins.`, ); } // MON-08 (owner ruling 2026-10-08): an app order is priced by the server's // making-charge formula (pricing/wage-formula.ts — legacy's by default), the // same figure the app showed. Whatever the app sends for wage, discount or // rial charge is ignored, so a tampered request cannot set its own price. // An operator ordering from the panel keeps the numbers they entered, as // legacy's order builder did. const quotes = opts.placedBy === 'customer' ? await this.wage.quote(customerId, dto.items.map((i) => i.productVariantId)) : null; const computedItems: ComputedItem[] = await Promise.all( dto.items.map(async (item) => { const variant = await this.catalog.getVariantOrThrow(item.productVariantId); const rashWeight = new Prisma.Decimal(variant.rashWeightGrams); const quote = quotes?.get(variant.id); if (quotes && !quote) throw new NotFoundException(`Variant ${variant.id} not found`); const wagePercent = new Prisma.Decimal(quote ? quote.wagePercentSnapshot : item.wagePercent); const discountPercent = new Prisma.Decimal(quote ? quote.discountPercentSnapshot : (item.discountPercent ?? 0)); if (quote) this.warnOnPriceMismatch(variant.id, item, wagePercent, discountPercent); const netPercent = wagePercent.sub(discountPercent); const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1)); return { productVariantId: variant.id, quantity: item.quantity, rashWeightGramsSnapshot: rashWeight, wagePercentSnapshot: wagePercent, discountPercentSnapshot: discountPercent, computedWeightGrams, // Legacy app: rial_pay is always 0 (cart.vue); only an operator sets one. flatRialCharge: new Prisma.Decimal(quotes ? 0 : (item.flatRialCharge ?? 0)), }; }), ); const totalGoldWeightGrams = computedItems.reduce( (sum, item) => sum.add(item.computedWeightGrams.mul(item.quantity)), new Prisma.Decimal(0), ); const totalRashWeightGrams = computedItems.reduce( (sum, item) => sum.add(item.rashWeightGramsSnapshot.mul(item.quantity)), new Prisma.Decimal(0), ); const wageWeightGrams = totalGoldWeightGrams.sub(totalRashWeightGrams); const flatRialCharge = computedItems.reduce( (sum, item) => sum.add(item.flatRialCharge.mul(item.quantity)), new Prisma.Decimal(0), ); // legacy: inValidOrder = (allGram + customer.remain) > customer.gram_level const isSuspendedOverCreditLimit = totalGoldWeightGrams .add(customer.goldDebtGrams) .gt(customer.goldCreditLimitGrams); const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED; // App (B2B) orders are always WHOLESALE. Legacy wrote them to `orders`, // not `orders_orders` (RETAIL). The DTO default was RETAIL and the apps // never sent `channel`, so every app order landed on the retail list. // Operators still pick the channel (admin MakeOrder forces WHOLESALE; // the retail list builder can pass RETAIL). A client-supplied channel on // the customer path is ignored — same posture as MON-08 pricing. const channel = opts.placedBy === 'customer' ? OrderChannel.WHOLESALE : (dto.channel ?? OrderChannel.RETAIL); if (opts.placedBy === 'customer' && dto.channel && dto.channel !== OrderChannel.WHOLESALE) { this.logger.warn( `Order body carried channel=${dto.channel} from the app; forcing WHOLESALE.`, ); } const placed = await this.prisma.$transaction(async (tx) => { const order = await tx.order.create({ data: { orderNumber: await this.nextOrderNumber(tx), customerId, branchId: dto.branchId, channel, status: initialStatus, totalGoldWeightGrams, wageWeightGrams, flatRialCharge, isSuspendedOverCreditLimit, // Legacy `'reagent_id' => auth()->user()->reagent_id` // (ApiMobileController.php:601): the customer's expert at THIS // moment, fixed on the order so a later change of expert never moves // past sales (owner ruling 2026-10-08). salesAgentId: customer.referredByAgentId, items: { create: computedItems.map((item) => ({ productVariantId: item.productVariantId, quantity: item.quantity, rashWeightGramsSnapshot: item.rashWeightGramsSnapshot, wagePercentSnapshot: item.wagePercentSnapshot, discountPercentSnapshot: item.discountPercentSnapshot, computedWeightGrams: item.computedWeightGrams, flatRialCharge: item.flatRialCharge, })), }, statusEvents: { create: { toStatus: initialStatus, note: isSuspendedOverCreditLimit ? 'Credit limit exceeded' : undefined }, }, }, include: { items: true, statusEvents: true }, }); // D27 — the document rule may say «at order placement». await this.accounting.orderStatusReached(tx, order.id, initialStatus); return order; }); // Legacy sent these after the order was committed // (ApiMobileController.php:733-766), so a rolled-back order never texts // anyone. Fire-and-forget per D20. // The audit trail for «ورود به حساب مشتری». Deliberately logged after the // commit, so it only ever claims an order that exists. if (opts.actingAsExpertId) { this.logger.log( `Order ${placed.orderNumber} for customer ${customerId} was placed by ` + `sales expert ${opts.actingAsExpertId} acting as them.`, ); } this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit); return placed; } /** * MON-08 step 2: the server's own figures are what the order is charged, but * a client that sent something else is worth seeing. An ordinary app sends * exactly what the server derives (DECISIONS §"Report — MON-08"), so a * mismatch means a stale app version, a promotion that expired between the * product page and checkout, or a tampered request. * * `flatRialCharge` is included because the app hardcodes 0 (cart.vue:179) and * only an operator ever sets one — anything non-zero from a customer is a * client that should not be sending it. */ private warnOnPriceMismatch( variantId: string, sent: CreateOrderItemInputDto, wagePercent: Prisma.Decimal, discountPercent: Prisma.Decimal, ) { const differs: string[] = []; if (!wagePercent.eq(sent.wagePercent)) { differs.push(`wagePercent sent=${sent.wagePercent} used=${wagePercent.toString()}`); } if (!discountPercent.eq(sent.discountPercent ?? 0)) { differs.push(`discountPercent sent=${sent.discountPercent ?? 0} used=${discountPercent.toString()}`); } if ((sent.flatRialCharge ?? 0) !== 0) { differs.push(`flatRialCharge sent=${sent.flatRialCharge} used=0`); } if (differs.length) { this.logger.warn( `Order pricing for variant ${variantId}: the server's figures are used, ` + `the client's are ignored — ${differs.join('; ')}`, ); } } /** * The admins who are told a new order was placed. * * Legacy hardcoded four numbers (ApiMobileController.php:740-743); this reads * a comma-separated env var, as the complete-profile notifier already does. * Unset means nobody is notified and the order itself still goes through. */ private adminMobiles(): string[] { return (process.env.ORDER_ADMIN_MOBILES ?? '') .split(',') .map((m) => m.trim()) .filter(Boolean); } /** * Legacy ApiMobileController::setOrder:733-766, brand read from BRAND_NAME. * * Three audiences: the admins, the customer, and the customer's sales expert. * Legacy's fourth message — «در سیستم ویرایش شد» (:734-737) — is * «عمداً تکرار نشد»: no target path lets a customer edit a placed order, so * that branch could never fire. * * `Notify::store` sat beside each of these in legacy (Firebase push); that is * PUSH-01, deferred to the PWA port. */ private notifyOrderPlaced( orderNumber: string, customer: { mobile: string; firstName: string | null; lastName: string | null; referredByAgentId: string | null }, totalGoldWeightGrams: Prisma.Decimal, isSuspendedOverCreditLimit: boolean, ) { const fire = (to: string, body: string) => { void this.sms.send(to, body).catch((e: unknown) => { this.logger.error(`Order SMS to ${to} failed: ${String(e)}`); }); }; const name = `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim(); // Legacy printed Utility::round($allGram) — two decimals, trailing zeros // dropped, which is what Decimal's own string form gives. const weight = totalGoldWeightGrams.toDecimalPlaces(2).toString(); const adminMessage = `${brandName()}\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`; for (const admin of this.adminMobiles()) fire(admin, adminMessage); fire( customer.mobile, isSuspendedOverCreditLimit ? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. ${brandName()}` : `سفارش شما به شماره ${orderNumber} به ثبت رسید. ${brandName()}`, ); if (customer.referredByAgentId) { void this.notifySalesAgent(customer.referredByAgentId, name, orderNumber, weight); } } /** Legacy :762-765 — the referring expert is texted too. */ private async notifySalesAgent(agentId: string, customerName: string, orderNumber: string, weight: string) { try { const agent = await this.prisma.adminUser.findUnique({ where: { id: agentId }, select: { mobile: true }, }); if (!agent?.mobile) return; await this.sms.send( agent.mobile, `${brandName()}\nسفارشی توسط ${customerName} به شماره ${orderNumber}و به وزن ${weight} به ثبت رسید.`, ); } catch (e: unknown) { this.logger.error(`Order SMS to sales agent ${agentId} failed: ${String(e)}`); } } async updateStatus(orderId: string, newStatus: OrderStatus, actorAdminUserId?: string, note?: string) { return this.prisma.$transaction(async (tx) => { const order = await tx.order.findUnique({ where: { id: orderId } }); if (!order) throw new NotFoundException(`Order ${orderId} not found`); const updated = await tx.order.update({ where: { id: orderId }, data: { status: newStatus, deliveredAt: newStatus === OrderStatus.DELIVERED_TO_CUSTOMER ? new Date() : order.deliveredAt, }, }); await tx.orderStatusEvent.create({ data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminUserId }, }); // Debt follows the status, as legacy's recomputed `sts > 4` did: owed // while delivered (to the expert or the customer), not otherwise, never // twice. See LedgerService.syncOrderDebt (ACC-03 / MON-04). await this.ledger.syncOrderDebt(orderId, tx); // D27 — the accounting document (پیش‌فاکتور) follows the owner's rule on // which status posts it, queued in this same transaction. if (newStatus === OrderStatus.CANCELLED && order.status !== OrderStatus.CANCELLED) { await this.accounting.orderCancelled(tx, orderId); } else if (newStatus !== order.status) { await this.accounting.orderStatusReached(tx, orderId, newStatus); } return updated; }); } /** Unscoped read. Only reachable from the admin-guarded route. */ getById(orderId: string) { return this.prisma.order.findUnique({ where: { id: orderId }, include: { items: true, statusEvents: true, ledgerEntries: true }, }); } /** * A customer reading one of their own orders. * * Someone else's order is reported as 404, not 403: a 403 would confirm the * id exists, which is the same enumeration leak the id-addressed customer * routes had. */ async getOwnedById(orderId: string, customerId: string) { const order = await this.prisma.order.findFirst({ where: { id: orderId, customerId }, include: { items: true, statusEvents: true, ledgerEntries: true }, }); if (!order) throw new NotFoundException('سفارش یافت نشد'); return order; } /** * Short global sequential number for the customer, SMS and search * (`100`, `101`, …). Allocated from Postgres `order_number_seq` inside the * placement transaction so concurrent creates cannot collide. Legacy used * the auto-increment order id the same way; the old `ORD-{ts}-{rand}` form * was too long for SMS and the list UI. */ private async nextOrderNumber(tx: Prisma.TransactionClient): Promise { const rows = await tx.$queryRaw>` SELECT nextval('order_number_seq') AS n `; const n = rows[0]?.n; if (n === undefined || n === null) { throw new Error('order_number_seq returned no value'); } return String(n); } }