diff --git a/apps/api/scripts/legacy-import/steps/people.ts b/apps/api/scripts/legacy-import/steps/people.ts index ed9fd6d..592e07e 100644 --- a/apps/api/scripts/legacy-import/steps/people.ts +++ b/apps/api/scripts/legacy-import/steps/people.ts @@ -468,11 +468,17 @@ export async function customers(ctx: Ctx) { shopImageUrl: await ctx.imageUrl(r.shop_image_id, S), avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S), birthDate: birthday(r.birth_date, r.created_at), - canPlaceOrders: int(r.sts) !== -1, + // Owner ruling 2026-10-08 (MON-05, option 2): everyone may order after the + // move. Legacy customers.sts was never read by any code — most of the 313 + // «عدم سفارش» rows are just the sign-up default (ApiMobileController.php:704) + // — so carrying it over would suddenly block real customers once the new + // system enforces the switch. Only a deliberate suspension after go-live counts. + canPlaceOrders: true, isActive: int(r.active) !== -1, isArchived: int(r.is_archive) === 1, }; if (r.reagent_id !== null && !owned.referredByAgentId) ctx.report.count(S, 'referrer not mapped (not a migrated expert)'); + if (int(r.sts) === -1) ctx.report.count(S, 'legacy «عدم سفارش» (never enforced) — set to allowed (owner, option 2)'); if (r.birth_date !== null && !owned.birthDate) ctx.report.count(S, 'birth date dropped (sign-up default or impossible)'); const res = await upsert(ctx, 'customer', r.id, { diff --git a/apps/api/src/modules/orders/orders.controller.ts b/apps/api/src/modules/orders/orders.controller.ts index 5b1f542..b2eefc9 100644 --- a/apps/api/src/modules/orders/orders.controller.ts +++ b/apps/api/src/modules/orders/orders.controller.ts @@ -26,7 +26,7 @@ export class OrdersController { @CustomerAuth() @Post() create(@Body() dto: CreateOrderDto, @CurrentCustomer() me: AuthenticatedCustomer) { - return this.orders.createOrder(dto, me.id); + return this.orders.createOrder(dto, me.id, { placedBy: 'customer' }); } /** diff --git a/apps/api/src/modules/orders/orders.service.spec.ts b/apps/api/src/modules/orders/orders.service.spec.ts index c69f91a..75ebda9 100644 --- a/apps/api/src/modules/orders/orders.service.spec.ts +++ b/apps/api/src/modules/orders/orders.service.spec.ts @@ -48,3 +48,37 @@ describe('OrdersService.createOrder — sales expert credit', () => { expect(created[0].salesAgentId).toBeNull(); }); }); + +/** 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 })) } }; + 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); + } + 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' }); + }); +}); diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index bbd6541..8386f08 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +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'; @@ -38,10 +38,23 @@ export class OrdersService { * from the request body. The body's `customerId` is ignored; see the note * on CreateOrderDto. */ - async createOrder(dto: CreateOrderDto, customerId: string) { + async createOrder( + dto: CreateOrderDto, + customerId: string, + opts: { placedBy: 'customer' | 'operator' } = { 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. diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index 438275a..f561856 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -47,7 +47,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | MON-02 | High | REF `henza_server` wallet package | The wallet `balance` column **mixes grams and toman in one field**, and money operations are not wrapped in DB transactions. | `_inv-ref-server.md` §7. | Separate the units; wrap mutations in transactions. | | MON-03 | ~~High~~ **NO EFFECT (verified 2026-10-08)** — `Prisma.Decimal(4.331802)` converts through the number's shortest decimal string, i.e. exactly `4.331802`; nothing is pre-rounded | TGT `apps/api/src/modules/accounting/ledger.service.ts:189` | `GRAMS_PER_MESGHAL = 4.331802` is a JS **float** used inside an otherwise-`Prisma.Decimal` CASH repayment formula, so the divisor is pre-rounded before the Decimal division. | Read at `ledger.service.ts:182-190`; the source comment already flags the formula for stakeholder review. | Make the constant a `Decimal` (or a string literal passed to `Decimal`). | | MON-04 | ~~High~~ **FIXED 2026-10-08 — see ACC-03** | TGT `apps/api/src/modules/orders/orders.service.ts` | Debt is recognised at `DELIVERED_TO_AGENT`, but the idempotency guard checks `deliveredAt`, which is only set by `DELIVERED_TO_CUSTOMER` — so the debt trigger is not reliably idempotent. There is also **no status-transition validation** anywhere. | api inventory §8. | Guard on the same field the trigger fires on; add an explicit transition table. | -| MON-05 | Medium | TGT `apps/api` | `canPlaceOrders` and `verificationStep` are stored and toggled by the admin panel but **never enforced** on any order path. | api inventory §8. | Enforce at order creation, or remove the toggles. | +| MON-05 | ~~Medium~~ **FIXED 2026-10-08 (owner option 2)** — `canPlaceOrders` now blocks orders placed from the app (403); an operator can still order for the customer. Legacy never read `customers.sts` (313 active customers carry «عدم سفارش», mostly the sign-up default), so the import sets everyone to allowed; only a deliberate suspension after go-live blocks. `verificationStep` is still not enforced, as in legacy. | TGT `apps/api` | `canPlaceOrders` and `verificationStep` are stored and toggled by the admin panel but **never enforced** on any order path. | api inventory §8. | Enforce at order creation, or remove the toggles. | | MON-06 | Medium | TGT `apps/api` | `Setting` rows are never seeded — `wallet.config`, `wallet.birthday`, `promotion.auto`, `mobile_main_slider` only materialise on first write, so a fresh install has undefined behaviour until an admin saves each one. | api inventory §7. | Seed the defaults. | | MON-07 | Medium | REF `henza_server/app/Models/ProductVariant.php:183-190` vs `SingleCustomerOrders.php:67`, `Order.php:66` | Two **divergent order-status enums** coexist (0–6 and 1–7) for what is conceptually one flow. | Read both models. | Unify, or document the mapping explicitly. |